import EventEmitter from 'eventemitter3';
import { HEART_BEATING_INTERVAL, WINS_THRESHOLD } from '../constants/constants';
import BackendError from '../errors/BackendError';
import Locator from '../services/Locator';
import { createCasinoRoundsMode } from '../utils/createCasinoRoundsMode';
import { createCasinoRoundsBetData } from '../utils/freeRounds';
import {
    createBetData,
    loadGambleLimit,
    loadMaxMultiplier,
    loadSpaceBarHandlingState,
    loadTurboModeState,
    navigateToLobby,
    saveSpaceBarHandlingState,
    saveTurboModeState,
} from '../utils/utils';
import BalanceModel from './BalanceModel';
import Currency from './Currency';
import EndRoundModel, { EndRoundModelContext } from './EndRoundModel';
import FreeSpinsMode from './GameModes/FreeSpinsMode';
import RespinsMode from './GameModes/RespinsMode';
import ModelDataInitializer from './modelInit/ModelDataInitializer';
import ModeStack from './ModeStack';
import PaytableModel, { PaytableData, PaytableMachineConfig } from './PaytableModel';
import VolumeModel from './VolumeModel';

import type { CurrencyMeta, InitResp } from '../types/api';
import type { AutoGameConfig } from './AutoGameModel';
import type BaseGameData from './BaseGameData';
import Mode from './GameModes/Mode';
import {LinesConfig} from "src/models/index";

/** Supported events emitted by the slot model. */
type ModelEvents = "error" | "performBuyBonusButtonAction" | "performPaytableAction" |
  "respinsGameTriggered" | "respinsGameRestore" | "exitRespinGame" | "exitBonusGame" |
  "exitBonusGame" | "showBigWin" | "showMaxWin" | "showTotalWin" | "casinoRoundsTriggered" |
  "casinoRoundsRestore" | "exitCasinoRounds" | "switchToCasinoRoundsMode" | "performRulesAction" |
  "balance:changed" | "heartbeat:error" | "connection:degraded"

/** Minimal API response shape used during model bootstrap. */
interface InitPayload extends InitResp
{

}

/** Feature multipliers supplied by the backend. */
type FeatureMultipliers = Record<string, number> & {
    bonusBuy: number;
    freespinBuy: number;
    bonusChance: number;
};

/** Snapshot payload generated by {@link ModelDataInitializer}. */
interface ModelSnapshot
{
    balance: number;
    currency: CurrencyMeta;
    modes: Mode[];
    featureMultipliers: FeatureMultipliers;
    /** Paytable rows array or backend record keyed by symbol id. */
    paytableData: unknown;
    baseBet: number;
    machineConfig: unknown;
}

type ModelSnapshotInput = Partial<ModelSnapshot> | undefined;

/** Accepted configuration for starting auto game sessions. */
interface AutoGameSettingsInput
{
    issued?: number | string;
    startingBalance?: number;
    advanced?: AutoGameConfig['advanced'];
}

/** Heartbeat payload structure. */
interface HeartBeatPayload
{
    balance?: {
        wallet?: unknown;
    };
}

const DEFAULT_FEATURE_MULTIPLIERS: FeatureMultipliers = {
    bonusBuy: 1,
    freespinBuy: 1,
    bonusChance: 1,
};

/** Ensures the provided value is a mode constructor. */
type BonusModeConstructor = new (
    betConfig: ReturnType<typeof createBetData>,
    gameData: BaseGameData,
    lines: LinesConfig,
) => Mode;

/** Central gameplay model orchestrating balance, modes and flow control. */
export default class Model
    extends EventEmitter<ModelEvents>
    implements EndRoundModelContext
{
    public readonly modeStack: ModeStack;

    public waitingForCasinoRoundsNotification = false;

    public waitingForRestore = false;

    public waitingForWinProcessing = false;

    public waitingForModeSwitch = false;

    public readonly volumeModel: VolumeModel;

    public gambleLimit: number;

    public maxMultiplier: number;

    private balanceModel: BalanceModel;

    private endRoundModel: EndRoundModel;

    private currencyModel?: Currency;

    private paytableModel: PaytableModel;

    private featureMultiplierMap: FeatureMultipliers;

    private spaceBarHandlingActive: boolean;

    private turboModeEnabled: boolean;

    private turboModeForced: boolean;

    private spinButtonHeld: boolean;

    private fastStop: boolean;

    private skipAnimation: boolean;

    private titleValue: string;

    private heartBeatTimer: ReturnType<typeof setTimeout> | null = null;

    private heartBeatRunning = false;

    private heartBeatStop: (() => void) | null = null;

    public constructor()
    {
        super();
        this.modeStack = new ModeStack();
        this.balanceModel = new BalanceModel(0);
        this.endRoundModel = new EndRoundModel(this);
        this.currencyModel = undefined;
        this.paytableModel = Model.createFallbackPaytable();
        this.featureMultiplierMap = { ...DEFAULT_FEATURE_MULTIPLIERS };
        this.spaceBarHandlingActive = loadSpaceBarHandlingState();
        this.turboModeEnabled = loadTurboModeState();
        this.turboModeForced = false;
        this.spinButtonHeld = false;
        this.fastStop = false;
        this.skipAnimation = false;
        this.waitingForCasinoRoundsNotification = false;
        this.waitingForRestore = false;
        this.waitingForWinProcessing = false;
        this.waitingForModeSwitch = false;
        this.gambleLimit = loadGambleLimit();
        this.maxMultiplier = loadMaxMultiplier();
        this.titleValue = 'unnamed game';
        this.volumeModel = new VolumeModel();
    }

    /** Current game title. */
    public get title(): string
    {
        return this.titleValue;
    }

    /** Current wallet balance. */
    public get balance(): number
    {
        return this.balanceModel.getBalance();
    }

    /** Currently active gameplay mode. */
    public get currentMode(): Mode
    {
        return this.requireCurrentMode();
    }

    /** Currency metadata used for formatting. */
    public get currency(): Currency | undefined
    {
        return this.currencyModel;
    }

    /** Paytable descriptor sourced from the backend. */
    public get paytable(): PaytableModel
    {
        return this.paytableModel;
    }

    /** Feature multipliers for purchasable features. */
    public get featureMultipliers(): FeatureMultipliers
    {
        return this.featureMultiplierMap;
    }

    /**
     * Calculates the stake that should be presented to the player once bonus chance is enabled.
     * @param baseBet
     */
    public resolveFeatureStake(baseBet: number): number
    {
        const multiplier = this.betFeatureActive ? this.getBonusChanceMultiplier() : 1;

        return baseBet * multiplier;
    }

    /** Enables or disables turbo mode. */
    public set turboMode(state: boolean)
    {
        this.turboModeEnabled = state;
        saveTurboModeState(state);
    }

    public get turboMode(): boolean
    {
        return this.turboModeEnabled;
    }

    /** Indicates whether spin animations should be skipped. */
    public get isSkipAnimation(): boolean
    {
        return this.skipAnimation;
    }

    /** Whether the bet feature is active in the current mode. */
    public get betFeatureActive(): boolean
    {
        return this.requireCurrentMode().betFeatureActive;
    }

    /** Win amount of the current mode. */
    public get win(): number
    {
        return this.requireCurrentMode().win;
    }

    public set win(value: number)
    {
        this.requireCurrentMode().win = value;
    }

    /** Total win accumulated in the current mode. */
    public get totalWin(): number
    {
        return this.requireCurrentMode().totalWin;
    }

    /** Whether end-round processing is still running. */
    public get isRoundEndProcessing(): boolean
    {
        return this.endRoundModel.isRoundEndProcessing;
    }

    /** Indicates if fast-stop should be applied to the current round. */
    public isFastStopActive(): boolean
    {
        return this.fastStop;
    }

    /** Indicates if turbo mode is currently forced by holding the spin button. */
    public isTurboModeForced(): boolean
    {
        return this.turboModeForced;
    }

    /** Checks if the current mode has an active round. */
    public isRoundStarted(): boolean
    {
        return this.requireCurrentMode().isRoundStarted();
    }

    /**
     * Bootstraps the model by requesting init data and preparing internal state.
     * @param title
     * @param slotMachineConfig
     */
    public async init(title: string, slotMachineConfig: unknown): Promise<this>
    {
        this.stopHeartBeat();
        this.titleValue = title;

        try
        {
            const rawData = await this.loadData();

            this.initializeModel(rawData, slotMachineConfig);

            return this;
        }
        catch (error)
        {
            this.emit('error', error);
            throw error;
        }
    }

    /** Fetches the initial game state from the backend. */
    public async loadData(): Promise<InitPayload>
    {
        const rawData = await Locator.network.init() as InitPayload | undefined;

        if (!rawData)
        {
            throw new Error('network error');
        }

        if (rawData.errors?.length)
        {
            // @ts-expect-error resolve types
            throw new BackendError('backend errors', rawData.errors);
        }

        return rawData;
    }

    /**
     * Builds models based on the provided snapshot.
     * @param rawData
     * @param slotMachineConfig
     */
    public initializeModel(rawData: InitPayload, slotMachineConfig: unknown): void
    {
        const initializer = new ModelDataInitializer(rawData, slotMachineConfig);

        this.resetFlags();
        this.loadSnapshot(initializer.toSnapshot() as ModelSnapshot);
        this.endRoundModel = new EndRoundModel(this);
    }

    /** Resets transient flags used during async flows. */
    public resetFlags(): void
    {
        this.waitingForRestore = false;
        this.waitingForWinProcessing = false;
        this.waitingForModeSwitch = false;
        this.waitingForCasinoRoundsNotification = false;
        this.spinButtonHeld = false;
        this.fastStop = false;
        this.turboModeForced = false;
        this.skipAnimation = false;
    }

    /**
     * Applies a snapshot payload to restore model state.
     * @param snapshot
     */
    public loadSnapshot(snapshot: ModelSnapshotInput = {}): this
    {
        const modes = this.normalizeModes(snapshot.modes);

        this.balanceModel = new BalanceModel(this.resolveBalance(snapshot.balance));
        this.currencyModel = snapshot.currency ? new Currency(snapshot.currency) : undefined;
        this.paytableModel = this.createPaytable(snapshot);
        this.featureMultiplierMap = this.normalizeFeatureMultipliers(snapshot.featureMultipliers);

        this.modeStack.reset();
        this.modeStack.push(...modes);

        return this;
    }

    /** Current bet value. */
    public get bet(): number
    {
        return this.requireCurrentMode().bet;
    }

    /** Available bet ladder. */
    public get bets(): readonly number[]
    {
        return this.requireCurrentMode().bets;
    }

    /** Decreases the bet to the previous available value. */
    public decreaseBet(): void
    {
        this.requireCurrentMode().decreaseBet();
    }

    /** Increases the bet to the next available value. */
    public increaseBet(): void
    {
        this.requireCurrentMode().increaseBet();
    }

    /**
     * Sets the bet by its underlying value.
     * @param value
     */
    public setBetByValue(value: number): void
    {
        this.requireCurrentMode().setBetByValue(value);
    }

    /** Sets the bet to the maximum available option. */
    public setToMaxBet(): void
    {
        this.requireCurrentMode().setToMaxBet();
    }

    /** Indicates if bet controls are currently locked. */
    public areBetsLocked(): boolean
    {
        return this.requireCurrentMode().areBetsLocked();
    }

    /** Returns whether all audio is muted. */
    public isMute(): boolean
    {
        return this.volumeModel.isAllMuted();
    }

    /** Mutes every audio channel. */
    public muteAll(): void
    {
        this.volumeModel.muteAll();
    }

    /** Restores audio on all channels. */
    public unmuteAll(): void
    {
        this.volumeModel.unmuteAll();
    }

    /** Checks whether an auto game session can be started. */
    public canStartAutoGame(): boolean
    {
        if (this.isRoundEndProcessing)
        {
            return false;
        }

        return this.requireCurrentMode().canStartAutoGame();
    }

    /** Remaining auto-game rounds. */
    public get autoRoundsLeft(): number
    {
        return this.requireCurrentMode().getAutoGameLeft();
    }

    /** Indicates if auto game is currently running. */
    public isAutoGameActive(): boolean
    {
        return this.requireCurrentMode().isAutoGameActive();
    }

    /**
     * Starts auto game with the provided settings.
     * @param settings
     */
    public async performAutoGame(settings: AutoGameSettingsInput): Promise<void>
    {
        if (this.isRoundEndProcessing)
        {
            return;
        }

        if (this.isRoundStarted())
        {
            this.fastStop = true;

            return;
        }

        try
        {
            await this.requireCurrentMode().startAutoGame(
                this.balanceModel,
                this.normalizeAutoGameSettings(settings),
                this.featureMultipliers.bonusChance,
            );
        }
        catch (error)
        {
            this.emit('error', error);
        }
    }

    /** Stops the current auto game session. */
    public stopAutoGame(): void
    {
        this.requireCurrentMode().stopAutoGame();
    }

    /** Indicates if the buy bonus button should be available. */
    public canBuyBonus(): boolean
    {
        return this.requireCurrentMode().canBuyBonus();
    }

    /** Emits the buy bonus action event. */
    public performBuyBonusButtonAction(): void
    {
        this.emit('performBuyBonusButtonAction');
    }

    /** Purchases freespins when available. */
    public async buyFreespins(): Promise<void>
    {
        if (this.isRoundEndProcessing)
        {
            return;
        }

        try
        {
            this.stopHeartBeat();
            await this.requireCurrentMode().buyFreespins(
                this.balanceModel,
                this.featureMultipliers.freespinBuy,
            );
        }
        catch (error)
        {
            this.emit('error', error);
        }
    }

    /** Purchases a bonus feature when available. */
    public async buyBonus(): Promise<void>
    {
        if (this.isRoundEndProcessing)
        {
            return;
        }

        try
        {
            this.stopHeartBeat();
            await this.requireCurrentMode().buyBonus(
                this.balanceModel,
                this.featureMultipliers.bonusBuy,
            );
        }
        catch (error)
        {
            this.emit('error', error);
        }
    }

    /** Toggles the bet feature flag on the current mode. */
    public toggleBetFeatureActive(): void
    {
        this.requireCurrentMode().toggleBetFeatureActive();
    }

    /** Handles spin button click/tap. */
    public async performSpinButtonAction(): Promise<void>
    {
        if (this.isRoundEndProcessing)
        {
            return;
        }

        if (this.isRoundStarted())
        {
            this.fastStop = true;
            this.skipAnimation = true;

            return;
        }

        try
        {
            await this.requireCurrentMode().performSpinButtonAction(
                this.balanceModel,
                this.featureMultipliers.bonusChance,
            );
            this.stopHeartBeat();
        }
        catch (error)
        {
            this.emit('error', error);
        }
    }

    /** Handles press-and-hold on the spin button. */
    public handleSpinButtonHolding(): void
    {
        this.spinButtonHeld = true;

        if (this.isRoundStarted())
        {
            this.turboModeForced = true;
        }
    }

    /** Releases spin button skip state. */
    public performSpinButtonReleaseUp(): void
    {
        this.skipAnimation = false;
    }

    /** Handles releasing the spin button. */
    public handleSpinButtonReleasing(): void
    {
        this.turboModeForced = false;
        this.spinButtonHeld = false;
        this.skipAnimation = false;
    }

    /** Enters the respins mode using current bet/lines. */
    public enterRespinsMode(): void
    {
        this.enterBonusMode(RespinsMode);
    }

    /** Enters the freespins mode using current bet/lines. */
    public enterFreespinsMode(): void
    {
        this.enterBonusMode(FreeSpinsMode);
    }

    /** Switches to the casino rounds mode. */
    public enterCasinoRoundsMode(): void
    {
        const mode = this.requireCurrentMode();

        mode.addWinToBalance(this.balanceModel);
        mode.resetWin();
        mode.resetTotalWin();

        const casinoMode = createCasinoRoundsMode(
            createCasinoRoundsBetData(mode.gameData.rawData),
            mode.gameData,
            mode.lines,
        );

        this.modeStack.push(casinoMode);
    }

    /** Leaves the current bonus/casino mode. */
    public exitMode(): void
    {
        if (this.modeStack.length <= 1)
        {
            return;
        }

        const mode = this.requireCurrentMode();
        const translatedTotalWin = mode.translatedTotalWin;
        const casinoRounds = mode.gameData.casinoRounds;
        const freeRoundsData = mode.gameData.rawData.free_rounds;
        const { command } = mode.gameData;

        mode.beforeExit(this.balanceModel);
        this.modeStack.pop();

        const previousMode = this.requireCurrentMode();

        previousMode.gameData.resetDataToIdleState();
        previousMode.gameData.command = command;
        previousMode.gameData.state = 'ready';
        previousMode.totalWin += translatedTotalWin;
        previousMode.gameData.casinoRounds = casinoRounds;
        (previousMode.gameData.rawData as { free_rounds?: unknown }).free_rounds = freeRoundsData;
        previousMode.resetWin();
    }

    /** Finalises the round by executing end-round flows. */
    public async performEndRound(): Promise<void>
    {
        if (this.isRoundEndProcessing)
        {
            console.warn('End round processing is already in progress.');

            return;
        }

        this.beforeRoundEnding();
        this.requireCurrentMode().endRound(this.balanceModel);
        await this.endRoundModel.preformEndRound();
        this.afterRoundEnding();

        if (this.shouldAutoSpinBePerformed())
        {
            void this.performSpinButtonAction();
        }
        else
        {
            this.startHeartBeat();
        }
    }

    /** Clears fast-stop flag before round finishes. */
    public beforeRoundEnding(): void
    {
        this.fastStop = false;
    }

    /** Executes post-round housekeeping logic. */
    public afterRoundEnding(): void
    {
        this.requireCurrentMode().afterRoundEnding(this.balanceModel);
    }

    /** Determines whether auto spin should start immediately. */
    public shouldAutoSpinBePerformed(): boolean
    {
        return this.requireCurrentMode().shouldAutoSpin() || this.spinButtonHeld;
    }

    /**
     * Adds win amount to the current mode.
     * @param value
     */
    public addWinAmount(value: number): void
    {
        this.requireCurrentMode().addWinAmount(value);
    }

    /** Emits the paytable action event. */
    public performPaytableAction(): void
    {
        this.emit('performPaytableAction');
    }

    /** Emits the rules action event. */
    public performRulesAction(): void
    {
        this.emit('performRulesAction');
    }

    /** Navigates back to the casino lobby (if configured). */
    public performHomeButtonAction(): void
    {
        navigateToLobby((err) => this.emit('error', err));
    }

    /** Marks win processing as complete. */
    public completeWinProcessing(): void
    {
        this.waitingForWinProcessing = false;
    }

    /** Marks restore flow as complete. */
    public completeRestore(): void
    {
        this.waitingForRestore = false;
    }

    /** Marks mode switching as complete. */
    public completeModeSwitching(): void
    {
        this.waitingForModeSwitch = false;
    }

    /** Marks casino rounds notification as complete. */
    public completeCasinoRoundsNotification(): void
    {
        this.waitingForCasinoRoundsNotification = false;
    }

    /** Whether keyboard space-bar handling is active. */
    public get isSpaceBarHandlingActive(): boolean
    {
        return this.spaceBarHandlingActive;
    }

    /** Toggles the space-bar handling flag. */
    public toggleSpaceBarHandling(): void
    {
        this.spaceBarHandlingActive = !this.spaceBarHandlingActive;
        saveSpaceBarHandlingState(this.spaceBarHandlingActive);
    }

    /** Toggles music mute state. */
    public toggleMusicMute(): void
    {
        this.volumeModel.toggleMusic();
    }

    /** Toggles sound effect mute state. */
    public toggleSFXMute(): void
    {
        this.volumeModel.toggleSounds();
    }

    /** Indicates if music is currently muted. */
    public isMusicMute(): boolean
    {
        return this.volumeModel.isMusicMuted();
    }

    /** Indicates if sound effects are currently muted. */
    public isSFXMute(): boolean
    {
        return this.volumeModel.isSoundsMuted();
    }

    /** Current master volume value. */
    public getVolume(): number
    {
        return this.volumeModel.getVolume();
    }

    /**
     * Sets the master volume.
     * @param value
     */
    public setVolume(value: number): void
    {
        this.volumeModel.setVolume(value);
    }

    /** Persists the current volume. */
    public saveVolume(): void
    {
        this.volumeModel.saveVolume();
    }

    /** Checks if the big win threshold has been reached. */
    public isBigWinAchieved(): boolean
    {
        const mode = this.requireCurrentMode();

        return WINS_THRESHOLD.BIG <= mode.gameData.winInRound / mode.bet;
    }

    /** Indicates if the win limit was reached. */
    public isWinLimitReached(): boolean | undefined
    {
        return this.requireCurrentMode().gameData.isWinLimitReached;
    }

    /** Indicates if respins game was triggered. */
    public isRespinsGameTriggered(): boolean
    {
        return this.requireCurrentMode().gameData.isRespinsGameTriggered;
    }

    /** Indicates if respins game was restored. */
    public isRespinsGameRestored(): boolean
    {
        return this.requireCurrentMode().gameData.isRespinsGameRestored;
    }

    /** Indicates if respins game finished. */
    public isRespinsGameFinished(): boolean
    {
        return this.requireCurrentMode().gameData.isRespinsGameFinished;
    }

    /** Indicates if freespins game was triggered. */
    public isFreespinsGameTriggered(): boolean
    {
        return this.requireCurrentMode().gameData.isFreespinsGameTriggered;
    }

    /** Indicates if freespins game was restored. */
    public isFreespinsGameRestored(): boolean
    {
        return this.requireCurrentMode().gameData.isFreespinsGameRestored;
    }

    /** Indicates if freespins game finished. */
    public isFreespinsGameFinished(): boolean
    {
        return this.requireCurrentMode().gameData.isFreespinsGameFinished;
    }

    /** Indicates if casino rounds were triggered. */
    public isCasinoRoundsTriggered(): boolean
    {
        return this.requireCurrentMode().gameData.isCasinoRoundsTriggered;
    }

    /** Indicates if casino rounds were restored. */
    public isCasinoRoundsRestored(): boolean
    {
        return this.requireCurrentMode().gameData.isCasinoRoundsRestored;
    }

    /** Indicates if casino rounds are currently active. */
    public isCasinoRoundsActive(): boolean
    {
        return this.requireCurrentMode().gameData.isCasinoRoundsActive;
    }

    /** Indicates if casino rounds finished. */
    public isCasinoRoundsFinished(): boolean
    {
        return this.requireCurrentMode().gameData.isCasinoRoundsFinished;
    }

    /** Plans a heartbeat polling cycle once the game is idle. */
    public startHeartBeat(): void
    {
        if (this.heartBeatTimer || this.heartBeatRunning)
        {
            return;
        }

        if (this.isRoundStarted() || this.isAutoGameActive() || this.spinButtonHeld)
        {
            return;
        }

        this.heartBeatTimer = setTimeout(() =>
        {
            this.heartBeatTimer = null;

            if (this.isRoundStarted() || this.isAutoGameActive() || this.spinButtonHeld)
            {
                return;
            }

            void this.runHeartBeatLoop();
        }, HEART_BEATING_INTERVAL);
    }

    /** Stops any scheduled heartbeat cycle. */
    public stopHeartBeat(): void
    {
        if (this.heartBeatTimer)
        {
            clearTimeout(this.heartBeatTimer);
            this.heartBeatTimer = null;
        }

        if (this.heartBeatStop)
        {
            this.heartBeatStop();
            this.heartBeatStop = null;
        }
    }

    /** Pulls balance updates while the game remains idle. */
    private async runHeartBeatLoop(): Promise<void>
    {
        if (this.heartBeatRunning)
        {
            return;
        }

        this.heartBeatRunning = true;

        let stopped = false;
        this.heartBeatStop = () =>
        {
            stopped = true;
        };

        let delay = HEART_BEATING_INTERVAL;
        const minDelay = HEART_BEATING_INTERVAL;
        const maxDelay = HEART_BEATING_INTERVAL * 4;
        let failures = 0;

        const sleep = (ms: number): Promise<void> => new Promise((resolve) =>
        {
            setTimeout(resolve, ms);
        });
        const jitter = (ms: number): number => Math.floor(Math.random() * ms);
        const isIdle = (): boolean => !this.isRoundStarted() && !this.isAutoGameActive() && !this.spinButtonHeld;

        try
        {
            while (!stopped)
            {
                if (!isIdle())
                {
                    break;
                }

                try
                {
                    const response = await Locator.network.heartBeat() as HeartBeatPayload;
                    const wallet = this.extractWallet(response);

                    if (wallet !== null && wallet !== this.balanceModel.getBalance())
                    {
                        this.balanceModel.setBalance(wallet);
                        this.emit('balance:changed', wallet);
                    }

                    failures = 0;
                    delay = minDelay;
                }
                catch (error: unknown)
                {
                    failures += 1;
                    delay = Math.min(Math.ceil(delay * 1.5), maxDelay);
                    this.emit('heartbeat:error', error);

                    if (failures === 5)
                    {
                        this.emit('connection:degraded');
                    }
                }

                if (stopped || !isIdle())
                {
                    break;
                }

                await sleep(jitter(delay));
            }
        }
        finally
        {
            this.heartBeatRunning = false;
            this.heartBeatStop = null;

            if (isIdle() && !this.heartBeatTimer)
            {
                this.startHeartBeat();
            }
        }
    }


    private resolveBalance(value: number | undefined): number
    {
        return typeof value === 'number' && Number.isFinite(value) ? value : 0;
    }

    private normalizeModes(value: ModelSnapshotInput['modes']): Mode[]
    {
        if (!Array.isArray(value))
        {
            return [];
        }

        return value.filter((mode): mode is Mode => mode instanceof Mode);
    }

    /** Builds a paytable model from snapshot payloads. */
    private createPaytable(snapshot: ModelSnapshotInput): PaytableModel
    {
        const data = snapshot?.paytableData;
        const baseBet = snapshot?.baseBet;
        const machineConfig = snapshot?.machineConfig;

        if (typeof baseBet !== 'number' || !this.isPaytableConfig(machineConfig))
        {
            return Model.createFallbackPaytable();
        }

        const paytableRows = this.normalizePaytableRows(data, machineConfig);

        if (!paytableRows)
        {
            return Model.createFallbackPaytable();
        }

        try
        {
            return new PaytableModel(paytableRows, baseBet, machineConfig);
        }
        catch
        {
            return Model.createFallbackPaytable();
        }
    }

    /** Provides a safe paytable when backend payload is invalid. */
    private static createFallbackPaytable(): PaytableModel
    {
        return new PaytableModel([[0]], 1, { symbolsKeys: ['fallback'] });
    }

    /**
     * Normalizes paytable data into ordered rows.
     * Ensures wild and scatter symbols gracefully fall back to empty payouts
     * when the backend omits their rows, mirroring legacy behaviour.
     * @throws {Error} When a required raw symbol id is missing in the payload.
     */
    private normalizePaytableRows(
        raw: unknown,
        machineConfig: PaytableMachineConfig,
    ): PaytableData | null
    {
        if (Array.isArray(raw))
        {
            return raw as PaytableData;
        }

        if (!raw || typeof raw !== 'object')
        {
            return null;
        }

        const record = raw as Record<string, unknown>;
        const wildSymbols = new Set(machineConfig.wilds ?? []);
        const scatterSymbols = new Set(machineConfig.scatters ?? []);
        const rows: number[][] = [];

        for (let index = 0; index < machineConfig.symbolsKeys.length; index += 1)
        {
            const symbolKey = machineConfig.symbolsKeys[index];
            const rawId = String(index);
            const entry = record[rawId];

            if (entry === undefined)
            {
                if (wildSymbols.has(symbolKey) || scatterSymbols.has(symbolKey))
                {
                    rows.push([]);
                    continue;
                }

                throw new Error(
                    `Paytable payload is missing row for raw symbol id ${rawId} (${symbolKey}).`,
                );
            }

            if (!Array.isArray(entry) || !entry.every((value) => typeof value === 'number'))
            {
                return null;
            }

            rows.push([...(entry as number[])]);
        }

        return rows as PaytableData;
    }

    private getBonusChanceMultiplier(): number
    {
        const multiplier = this.featureMultiplierMap.bonusChance;

        return Number.isFinite(multiplier) ? Math.max(multiplier, 1) : 1;
    }

    private normalizeFeatureMultipliers(value: ModelSnapshotInput['featureMultipliers']): FeatureMultipliers
    {
        if (!value)
        {
            return { ...DEFAULT_FEATURE_MULTIPLIERS };
        }

        return {
            ...DEFAULT_FEATURE_MULTIPLIERS,
            ...value,
        };
    }

    private normalizeAutoGameSettings(settings: AutoGameSettingsInput): AutoGameConfig
    {
        const issued = typeof settings.issued === 'string'
            ? Number(settings.issued)
            : settings.issued;

        return {
            issued: Number(issued),
            startingBalance: settings.startingBalance,
            advanced: settings.advanced,
        };
    }

    private enterBonusMode(ModeClass: BonusModeConstructor): void
    {
        const mode = this.requireCurrentMode();
        const bets = createBetData([mode.bet]);
        const lines = mode.lines;

        mode.gameData.winInRound = 0;
        this.modeStack.push(new ModeClass(bets, mode.gameData, lines));
        this.requireCurrentMode().gameData.resetDataToIdleState();
    }

    private extractWallet(response: HeartBeatPayload): number | null
    {
        const wallet = response?.balance?.wallet;

        if (typeof wallet !== 'number' || !Number.isFinite(wallet))
        {
            return null;
        }

        return wallet;
    }

    private isPaytableConfig(value: unknown): value is PaytableMachineConfig
    {
        if (!value || typeof value !== 'object')
        {
            return false;
        }

        const candidate = value as PaytableMachineConfig;

        return Array.isArray(candidate.symbolsKeys);
    }

    private requireCurrentMode(): Mode
    {
        const mode = this.modeStack.getCurrentMode();

        if (!mode)
        {
            throw new Error('Model has no active mode.');
        }

        return mode;
    }

    public isAutoGameBlockedByBO () {
        return __OPTIONS__.ui.autospins_dialog === "disable"
    }
}
