/** Raw paytable rows aligned with machine symbol order. */
export type PaytableData = ReadonlyArray<ReadonlyArray<number>>;

/** Machine symbols and specials metadata consumed by the paytable. */
export interface PaytableMachineConfig
{
    symbolsKeys: readonly string[];
    wilds?: readonly string[];
    scatters?: readonly string[];
}

/** Maps symbols to payout rows sourced from machine configuration. */
export default class PaytableModel
{
    public readonly symbols: readonly string[];

    public readonly wildSymbols: readonly string[];

    public readonly scatterSymbols: readonly string[];

    public readonly betDivider: number;

    private readonly paytables: number[][];

    private readonly indexLookup: Map<string, number>;

    constructor(rawData: PaytableData, betDivider: number, machineConfig: PaytableMachineConfig)
    {
        const validatedDivider = PaytableModel.validateBetDivider(betDivider);
        const validatedConfig = PaytableModel.normalizeMachineConfig(machineConfig);
        const normalizedPaytables = PaytableModel.normalizePaytables(rawData);

        if (validatedConfig.symbols.length !== normalizedPaytables.length)
        {
            throw new Error('Paytable data count must match machine config symbols count.');
        }

        this.betDivider = validatedDivider;
        this.symbols = validatedConfig.symbols;
        this.wildSymbols = validatedConfig.wildSymbols;
        this.scatterSymbols = validatedConfig.scatterSymbols;
        this.paytables = normalizedPaytables;
        this.indexLookup = PaytableModel.createIndexLookup(this.symbols);

        PaytableModel.ensureSubset(this.wildSymbols, this.symbols, 'wilds');
        PaytableModel.ensureSubset(this.scatterSymbols, this.symbols, 'scatters');
    }

    /**
     * Returns a cloned list of payouts for the provided symbol key.
     * @param symbolKey
     */
    public get(symbolKey: string): number[]
    {
        const index = this.indexLookup.get(symbolKey);

        if (index === undefined)
        {
            throw new Error(`Element ${symbolKey} is not found in the array of symbols.`);
        }

        return [...this.paytables[index]];
    }

    private static validateBetDivider(betDivider: number): number
    {
        if (typeof betDivider !== 'number' || !Number.isFinite(betDivider) || betDivider <= 0)
        {
            throw new Error('betDivider must be a positive finite number.');
        }

        return betDivider;
    }

    private static normalizeMachineConfig(
        machineConfig: PaytableMachineConfig | null | undefined,
    ): {
            symbols: readonly string[];
            wildSymbols: readonly string[];
            scatterSymbols: readonly string[];
        }
    {
        if (!machineConfig)
        {
            throw new Error('Machine config must be provided.');
        }

        const symbols = PaytableModel.cloneStringArray(
            machineConfig.symbolsKeys,
            'symbolsKeys',
        );

        if (symbols.length === 0)
        {
            throw new Error('Machine config must declare at least one symbol key.');
        }

        const uniqueCount = new Set(symbols).size;

        if (uniqueCount !== symbols.length)
        {
            throw new Error('Machine config symbols must be unique.');
        }

        return {
            symbols: Object.freeze(symbols),
            wildSymbols: Object.freeze(
                PaytableModel.cloneOptionalStringArray(machineConfig.wilds, 'wilds'),
            ),
            scatterSymbols: Object.freeze(
                PaytableModel.cloneOptionalStringArray(machineConfig.scatters, 'scatters'),
            ),
        };
    }

    private static cloneStringArray(values: readonly string[] | null | undefined, label: string): string[]
    {
        if (!Array.isArray(values))
        {
            throw new Error(`${label} must be an array of strings.`);
        }

        return values.map((value, index) =>
        {
            if (typeof value !== 'string' || value.trim().length === 0)
            {
                throw new Error(`${label} must contain only non-empty strings (invalid entry at index ${index}).`);
            }

            return value;
        });
    }

    private static cloneOptionalStringArray(values: readonly string[] | null | undefined, label: string): string[]
    {
        if (values == null)
        {
            return [];
        }

        return PaytableModel.cloneStringArray(values, label);
    }

    private static normalizePaytables(rawData: PaytableData | null | undefined): number[][]
    {
        if (!Array.isArray(rawData) || rawData.length === 0)
        {
            throw new Error('Paytable data must be a non-empty array of paytable rows.');
        }

        return rawData.map((row, rowIndex) =>
        {
            if (!Array.isArray(row))
            {
                throw new Error(`Paytable row ${rowIndex} must be an array of numeric payouts.`);
            }

            return row.map((payout, payoutIndex) =>
            {
                if (typeof payout !== 'number' || !Number.isFinite(payout))
                {
                    throw new Error(`Paytable row ${rowIndex} has invalid payout at index ${payoutIndex}.`);
                }

                return payout;
            });
        });
    }

    private static createIndexLookup(symbols: readonly string[]): Map<string, number>
    {
        const lookup = new Map<string, number>();

        symbols.forEach((symbol, index) =>
        {
            lookup.set(symbol, index);
        });

        return lookup;
    }

    private static ensureSubset(
        subset: readonly string[],
        domain: readonly string[],
        label: string,
    ): void
    {
        if (subset.length === 0)
        {
            return;
        }

        const domainKeys = new Set(domain);

        subset.forEach((entry) =>
        {
            if (!domainKeys.has(entry))
            {
                throw new Error(`${label} symbol ${entry} must exist in symbolsKeys.`);
            }
        });
    }
}
