import BaseMachine from '../slotMachines/BaseMachine';
import { HighlightLandedSymbolSystemCompatibleEntity } from '../systems/HighlightLandedSymbolSystem';
import { CanBeBlocked, CanBeBlurred, CanBeLanded, CanBeMasked, CanPlayBouncing, CanPlayIdle, Entity } from '../types/Entity';
import { ScreenMapRecord } from '../types/ScreenInputMap';
import Pool from '../utils/Pool';
import BaseSystem from './BaseSystem';
import Reel from './reels/Reel';

/**
 * Represents an entity compatible with the ReelsSpinSystem.
 * This entity must be able to be blocked, play idle animations, play bouncing animations, and be blurred.
 */
export type ReelsSpinSystemCompatibleEntity = Entity
    & CanBeBlocked
    & CanPlayIdle
    & CanPlayBouncing
    & CanBeBlurred
    & CanBeLanded
    & CanBeMasked;

/** Configuration settings for the reel spinning behavior in the ReelsSpinSystem. */
export interface ReelsSpinSystemSettings
{
    accelerationTime: number,
    backCoef: number,
    maxSpeed: number,
    reelStartDelay: number,
    reelStopDelay: number,
    spinningDuration: number,
    blur: true,
    bounce: true,
}

/** Enum representing different states of the spin process. */
const SPIN_STATES = {
    STOPPED: 'stopped',
    STARTING: 'starting',
    SPINNING: 'spinning',
    STOPPING: 'stopping'
};

/**
 * @class ReelsSpinSystem
 * @extends BaseSystem
 * @description Controls the spinning behavior of slot machine reels, including acceleration, sustained spinning, and deceleration.
 * This class manages reel state transitions, timing for each stage of the spin, and the display of symbols on each reel.
 */
export default class ReelsSpinSystem extends BaseSystem<ReelsSpinSystemSettings>
{
    /**
     * Default configuration settings for reel spin behavior.
     * @type {ReelsSpinSystemSettings}
     */
    static defaultSettings: ReelsSpinSystemSettings = {
        accelerationTime: 1,
        backCoef: 1.1,
        maxSpeed: 40,
        reelStartDelay: 0.2,
        reelStopDelay: 0.5,
        spinningDuration: 2,
        blur: true,
        bounce: true,
    };

    static override requiredFlags: string[] = ['isSpinning'];
    static override requiredMaps: string[] = ['symbols', 'preventReelStop', 'preventReelsSpin'];
    static override requiredInputs: string[] = ['screenMap'];
    static override requiredMethods: string[] = ['createSymbol'];
    static override requiredUtils: string[] = ['grid'];
    static override sharedSystemData: string[] = ['spinSystemState', 'currentStoppingReel'];

    public symbolKeys: string[];
    protected reels: Reel[];
    private _state: string;
    // Weighted random selection support
    private _weightedKeys: { key: string, weight: number, acc: number }[] = [];
    private _totalWeight: number = 0;
    protected elapsedTime: number = 0;
    pool: Pool<ReelsSpinSystemCompatibleEntity>;
    protected startingReel: number;
    protected stoppingReel: number;
    protected stoppingStartTime: number;

    get state()
    {
        return this._state;
    }

    set state(value: string)
    {
        this._state = value;
        this.machine.sharedSystemData.spinSystemState = value;
    }

    /**
     * Initializes the reel spin system with specified machine settings and prepares reels.
     * @class
     * @param {BaseMachine} machine - The slot machine instance that the spin system controls.
     */
    constructor(machine: BaseMachine)
    {
        super(machine);
        this.setSettings(ReelsSpinSystem.defaultSettings);
        this.symbolKeys = Object.keys(machine.config.symbols);
        this._recomputeWeights();
        this.reels = this._initializeReels();
        this.initPreventReelsStopMap();
        this.initPreventReelsSpinMap();
        this.state = SPIN_STATES.STOPPED;
        this.elapsedTime = 0;
        this.pool = new Pool();
        this.startingReel = 0;
        this.stoppingReel = 0;
        this.stoppingStartTime = 0;
        this._initializeSharedSystemData();
    }

    /** Sets up the reels to begin spinning from an idle state, initializing symbols and firing a start event. */
    override onEnter(): void
    {
        this.state = SPIN_STATES.STARTING;
        this.machine.flags.isSpinning = true;
        this.startingReel = 0;
        this.stoppingReel = 0;
        this.elapsedTime = 0;
        this.resetIsLandedFlags();
        this.reels.forEach((reel, i) =>
        {
            reel.setSymbols(this._getSymbolsFromScreen(i));
        });
        this.machine.events.addEvent('outProcessingStarted');
    }

    /** Initiates the spinning of the next reel in the sequence, according to the `reelStartDelay` setting. */
    startNext(): void
    {
        // Skip reels prevented from spinning
        while (this.startingReel < this.machine.config.reels && this.isReelSpinPrevented(this.startingReel))
        {
            this.startingReel++;
        }
        if (this.startingReel >= this.machine.config.reels) return;
        this.reels[this.startingReel].start();
        this.startingReel++;
    }

    /**
     * Stops the next reel in the sequence by positioning it at a calculated stop position.
     * Adds any remaining symbols from the screen map to the reel before stopping it.
     */
    stopNext(): void
    {
        const reelMap: ReelsSpinSystemCompatibleEntity[] = this._getReelMapForStop();
        const reel = this.reels[this.stoppingReel];
        const stopPosition = this._calculateStopPosition(reel);

        this._setReelMapToSymbolMap(reelMap.slice().reverse(), this.stoppingReel);
        reel.addSymbols(reelMap, stopPosition);
        reel.stop(stopPosition);
        this.stoppingReel++;
    }

    /**
     * Updates the state and position of each reel in the spin system.
     * Manages transitions between starting, spinning, and stopping states.
     * @param {number} dt - Time delta in milliseconds since the last update.
     */
    override update(dt: number): void
    {
        this.elapsedTime += dt * 0.001;

        if (this.state === SPIN_STATES.STARTING)
        {
            this._processStarting();
        }

        if (this.state === SPIN_STATES.SPINNING)
        {
            this._processSpinning();
        }

        if (this.state === SPIN_STATES.STOPPING)
        {
            this._processStopping();
        }

        this.reels.forEach((reel) => reel.update(dt));
        this.syncReelBlurFlags();
        this.syncReelMaskFlags();

        this.machine.sharedSystemData.spinSystemState = this.state;
        this.machine.sharedSystemData.currentStoppingReel = this.stoppingReel;
        this.machine.sharedSystemData.reelsStates = this.reels.map((reel) => reel.currentState.name);
    }

    /** Synchronises blur flags with reel states, keeping blur only while reels spin. */
    private syncReelBlurFlags(): void
    {
        const blurEnabled = this.settings.blur;

        this.reels.forEach((reel) =>
        {
            const stateName = reel.currentState?.name;
            const shouldBlur = Boolean(blurEnabled && stateName === 'Spinning');

            reel.symbols.forEach(([_, symbol]) =>
            {
                symbol.isBlurred = shouldBlur;
            });
        });
    }

    /** Synchronises symbol masking with reel states, keeping masks during spin and clearing them when bouncing or stopped. */
    private syncReelMaskFlags(): void
    {
        this.reels.forEach((reel) =>
        {
            const stateName = reel.currentState?.name;
            const shouldUnmask = stateName === 'Bouncing' || stateName === 'Stopped';
            const shouldMask = !shouldUnmask;

            reel.symbols.forEach(([_, symbol]) =>
            {
                if (shouldMask)
                {
                    symbol.isMasked = true;
                    return;
                }

                if (shouldUnmask)
                {
                    symbol.isMasked = false;
                }
            });
        });
    }

    /**
     * Retrieves a symbol from the pool or creates a new one if none are available.
     * @param {string} key - The key identifying the symbol to retrieve.
     * @returns {ReelsSpinSystemCompatibleEntity} - The retrieved or newly created symbol instance.
     */
    getSymbol(key: string): ReelsSpinSystemCompatibleEntity
    {
        const symbol = this.pool.retrieve(key);

        return symbol || this.machine.createSymbol(key);
    }

    /**
     * Returns a random symbol key based on optional weights from config.
     * - If symbolsWeights not provided, all symbols are equally likely.
     * - Symbols with weight 0 are excluded.
     * - Symbols without specified weights are given equal default weight among themselves (implemented as weight = 1).
     */
    public getRandomSymbolKey(): string
    {
        // Fast path: if total weight is zero (all zero or no symbols), fall back to uniform from symbolKeys
        if (this._totalWeight <= 0)
        {
            // Should not happen normally; fallback safe
            const idx = Math.floor(Math.random() * this.symbolKeys.length);
            return this.symbolKeys[idx];
        }

        const r = Math.random() * this._totalWeight;
        // Binary search could be used; linear scan is fine for small symbol sets
        for (let i = 0; i < this._weightedKeys.length; i++)
        {
            if (r < this._weightedKeys[i].acc) return this._weightedKeys[i].key;
        }
        // Fallback: return last key
        return this._weightedKeys[this._weightedKeys.length - 1].key;
    }

    /** Precompute cumulative weights from config for fast random selection */
    private _recomputeWeights(): void
    {
        const weightsCfg = this.machine.config.symbolsWeights || {};
        const weighted: { key: string, weight: number, acc: number }[] = [];

        // Assign weights: use provided, default 1, but exclude if 0
        for (const key of this.symbolKeys)
        {
            const w = weightsCfg[key];
            const weight = (w === undefined) ? 1 : Math.max(0, Number(w));
            if (weight <= 0) continue; // exclude zero weights
            weighted.push({ key, weight, acc: 0 });
        }

        // Build cumulative distribution
        let acc = 0;
        for (const item of weighted)
        {
            acc += item.weight;
            item.acc = acc;
        }

        this._weightedKeys = weighted;
        this._totalWeight = acc;
    }

    /**
     * Initializes reels for the slot machine based on the machine configuration.
     * @private
     * @returns {Reel[]} - An array of Reel instances initialized for the system.
     */
    private _initializeReels(): Reel[]
    {
        return Array.from({ length: this.getReelCount() }, (_, i) =>
        {
            const reel: Reel = this.createReel(i);

            reel.onDecelerationEnded = this.onReelDecelerationEnded.bind(this, reel);
            reel.onStop = this.onReelStop.bind(this, i);

            return reel;
        });
    }

    /**
     * Allows subclasses to control how many reels are created. By default, equals to columns count.
     */
    protected getReelCount(): number
    {
        return this.machine.config.reels;
    }

    /**
     * Factory method to create a reel instance. Subclasses may override to provide custom Reel types.
     * @param {number} index - reel index (column)
     */
    protected createReel(index: number): Reel
    {
        return new Reel(this, index);
    }

    private _initializeSharedSystemData()
    {
        this.machine.sharedSystemData.spinSystemState = this.state;
        this.machine.sharedSystemData.currentStoppingReel = this.stoppingReel;
        this.machine.sharedSystemData.reelsStates = this.reels.map((reel) => reel.currentState.name);
    }

    onReelStop(index: number)
    {
        this.machine.events.addEvent('reelStopped', { index });
    }

    protected onReelDecelerationEnded(reel: Reel): void
    {
        this.machine.maps.symbols.forEach((id: string, i: number) =>
        {
            if (reel.index !== this.machine.utils.grid.fromMapIndexToGridCoords(i).x) return;
            const entity: HighlightLandedSymbolSystemCompatibleEntity = this.machine.getChildByName(id);

            if (!entity) return;
            entity.isLanded = true;
        });
    }

    /**
     * Retrieves the symbols for the current reel from the screen map for stopping.
     * Reverses the order to display symbols correctly on the reel.
     * @private
     * @returns {ReelsSpinSystemCompatibleEntity[]} - Array of Symbol instances to stop the reel with.
     */
    protected _getReelMapForStop(): ReelsSpinSystemCompatibleEntity[]
    {
        return this.machine.inputs.screenMap
            .filter((_: ScreenMapRecord, i: number) => i % this.machine.config.reels === this.stoppingReel)
            .map((record: ScreenMapRecord) =>
            {
                const symbol = this.machine.createSymbol(record.key);

                if (record.value !== undefined) {
                    // Assign only when value is explicitly provided
                    // to avoid setting invalid/undefined values on symbols
                    (symbol as any).value = record.value as any;
                }

                return symbol;
            })
            .reverse();
    }

    /**
     * Calculates the appropriate stop position for the reel based on the number of symbols it holds.
     * Ensures that enough symbols are present on the reel to reach the target stop position.
     * @protected
     * @param {Reel} reel - The reel for which to calculate the stop position.
     * @returns {number} - The calculated stop position for the reel.
     */
    protected _calculateStopPosition(reel: Reel): number
    {
        const lastSymbolPosition = reel.getLastSymbolPosition();
        const currPos = Math.ceil(reel.position);
        return lastSymbolPosition > currPos ? lastSymbolPosition + 1 : currPos;
    }

    /**
     * Retrieves the initial symbols from the machine’s screen map for the specified reel index.
     * @private
     * @param {number} index - The index of the reel to retrieve symbols for.
     * @returns {ReelsSpinSystemCompatibleEntity[]} - Array of Symbol instances to initialize the reel with.
     */
    protected _getSymbolsFromScreen(index: number): ReelsSpinSystemCompatibleEntity[]
    {
        return this.machine.maps.symbols
            .filter((_: string, i: number) => i % this.machine.config.reels === index)
            .map((id: string) => this.machine.getChildByName(id));
    }

    /**
     * Handles the timing logic for starting the reels with delays between each.
     * Transitions the state to `SPINNING` once all reels are spinning.
     * @private
     */
    private _processStarting(): void
    {
        if (this.isStartConditionMet())
        {
            this.startNext();
            if (this.settings.reelStartDelay === 0)
            {
                this._processStarting();
            }
        }

        if (this.reels.every((reel, i) => reel.currentState.name === 'Spinning' || this.isReelSpinPrevented(i)))
        {
            this.state = SPIN_STATES.SPINNING;
        }
    }

    private isStartConditionMet(): boolean
    {
        return this.startingReel < this.machine.config.reels
            && this.elapsedTime >= this.startingReel * this.settings.reelStartDelay;
    }

    /**
     * Manages the duration of the spinning state. Transitions to the stopping phase when time elapses.
     * @private
     */
    private _processSpinning(): void
    {
        if (
            this.elapsedTime >= this.settings.spinningDuration
            && this.machine.inputs.screenMap
        )
        {
            this.state = SPIN_STATES.STOPPING;
            this.stoppingStartTime = this.elapsedTime;
            this.machine.events.addEvent('inProcessingStarted');
        }
    }

    /**
     * Handles the timing logic for stopping the reels with delays between each.
     * Ends the spinning session once all reels are stopped and updates the screen map.
     * @private
     */
    private _processStopping(): void
    {
        // Skip reels that should not be stopped/spun at all (permanent skip)
        while (this.stoppingReel < this.machine.config.reels && this.isReelSpinPrevented(this.stoppingReel))
        {
            this.stoppingReel++;
        }
        // If current reel is under temporary stop prevention (e.g., anticipation), wait until cleared
        const isTemporarilyPrevented = !!this.machine.maps.preventReelStop[this.stoppingReel];
        if (this.isStopNextReelConditionMet() && !isTemporarilyPrevented)
        {
            this.stopNext();
        }
        if (this.isEveryReelStopped())
        {
            this.machine.events.addEvent('inProcessingEnded');
            this._updateScreenMap();
            this.state = SPIN_STATES.STOPPED;
            this.machine.flags.isSpinning = false;
        }
    }

    private isEveryReelStopped()
    {
        return this.reels.every((reel, i) => reel.currentState.name === 'Stopped' || this.isReelSpinPrevented(i));
    }
    private isStopNextReelConditionMet()
    {
        return this.stoppingReel < this.machine.config.reels && this.elapsedTime - this.stoppingStartTime >= this.stoppingReel * this.settings.reelStopDelay;
    }

    /**
     * Updates the machine's symbol map based on the final positions of symbols on the reels.
     * @private
     */
    _updateScreenMap()
    {
        const reelsSymbols = this.reels.map((reel) =>
            reel.symbols.slice().reverse());

        for (let i = this.machine.config.slots - 1; i >= 0; i--)
        {
            for (let j = this.machine.config.reels - 1; j >= 0; j--)
            {
                const mapIndex = this.machine.utils.grid.fromCRtoMapIndex(j, i);
                const [_, symbol] = reelsSymbols[j][i];

                this.machine.maps.symbols[mapIndex] = symbol.name;
            }
        }
    }

    private _setReelMapToSymbolMap(symbolsMap: ReelsSpinSystemCompatibleEntity[], reelIndex: number)
    {
        symbolsMap.forEach(((symbol: ReelsSpinSystemCompatibleEntity, i: number) =>
        {
            const mapIndex: number = this.machine.utils.grid.fromCRtoMapIndex(reelIndex, i);

            this.machine.maps.symbols[mapIndex] = symbol.name;
        }));
    }

    private resetIsLandedFlags()
    {
        this.machine.maps.symbols.forEach((id: string) =>
        {
            const e: ReelsSpinSystemCompatibleEntity = this.machine.getChildByName(id);

            e.isLanded = false;
        });
    }

    /** Handles clean-up operations when leaving the system. */
    onLeave(): void
    {
    }

    private initPreventReelsStopMap()
    {
        this.machine.maps.preventReelStop = this.reels.map(() => false);
    }

    private initPreventReelsSpinMap()
    {
        // Default: no reels are prevented from spinning
        this.machine.maps.preventReelsSpin = this.reels.map(() => false);
    }

    private isReelSpinPrevented(index: number): boolean
    {
        const map = this.machine.maps.preventReelsSpin;
        return !!(map && map[index]);
    }

    public override destroy() {
        this.pool.clear();
    }
}
