import {
    isDialog,
    type Dialog,
    type DialogChoice,
    type DialogNode,
    type RPGAction,
    type RPGActions,
    type RPGVariables,
} from "./types";

import {
    evaluateConditions,
    parseCondition,
    type ParsedCondition,
} from "./utils/conditions";

import { isEvalContext, type EvalContext } from "./core/world";
import { resolveVariables, executeAction } from "./utils/variables";

export interface DialogRuntimeOptions {
    variables: RPGVariables;
    actions: RPGActions;
}

export interface DialogNodeView {
    id: string;
    nextNodeId?: string;
    speaker: string;
    text: string;
    choices?: DialogChoice[];
}

export namespace Dialogs {
    export function getConditions(dialog: Dialog): ParsedCondition[] {
        const conditions = new Map<string, ParsedCondition>();
        for (const node of dialog.nodes) {
            for (const condition of node.conditions ?? []) {
                const parsed = parseCondition(condition);
                conditions.set(condition, parsed);
            }
        }
        return Array.from(conditions.values());
    }

    export function getPossibleActions(dialog: Dialog) {
        const actions = new Set<string>();
        for (const node of dialog.nodes) {
            for (const action of node.actions ?? []) {
                actions.add(typeof action === 'string' ? action : action.type);
            }
        }
        return Array.from(actions);
    }

    export function validate(
        dialog: unknown,
        actions: string[],
        speakers: string[],
    ): string[] {
        if (!isDialog(dialog)) return ['invalid dialog structure'];

        const errors: string[] = [];
        const nodeIds = new Set(dialog.nodes.map(n => n.id));
        const actionSet = new Set(actions);
        const speakerSet = new Set(speakers);

        if (!nodeIds.has(dialog.startNodeId))
            errors.push(`startNodeId '${dialog.startNodeId}' does not match any node id`);

        for (const node of dialog.nodes) {
            if (!speakerSet.has(node.speaker)) {
                errors.push(`node '${node.id}': unknown speaker '${node.speaker}'`);
            }

            if (node.nextNodeId !== undefined && !nodeIds.has(node.nextNodeId))
                errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);

            for (const action of node.actions ?? []) {
                const type = typeof action === 'string' ? action : action.type;
                if (!actionSet.has(type)) {
                    errors.push(`node '${node.id}': unknown action type '${type}'`);
                }
            }

            for (const choice of node.choices ?? []) {
                if (choice.nextNodeId !== undefined && !nodeIds.has(choice.nextNodeId)) {
                    errors.push(`node '${node.id}': choice '${choice.text}': nextNodeId '${choice.nextNodeId}' does not match any node id`);
                }
            }
        }

        return errors;
    }

    export function isValid(
        v: unknown,
        actions: string[],
        speakers: string[],
    ): v is Dialog {
        return validate(v, actions, speakers).length === 0;
    }

    type TestValue = string | number | boolean | undefined;
    type TestVariables = Record<string, TestValue>;

    export interface CoverageIssue {
        nodeId: string;
        description: string;
        combinations: TestVariables[];
    }

    function inferCandidateValues(conditions: ParsedCondition[]): Map<string, TestValue[]> {
        const map = new Map<string, TestValue[]>();

        const add = (variable: string, ...values: TestValue[]) => {
            const existing = map.get(variable) ?? [];
            for (const v of values)
                if (!existing.includes(v)) existing.push(v);
            map.set(variable, existing);
        };

        for (const { variable, operator, value } of conditions) {
            if (operator === undefined) {
                add(variable, undefined, true, false);
            } else if (operator === 'null' || operator === '~null') {
                add(variable, undefined, true);
            } else if (operator === '==' || operator === '!=') {
                let other: TestValue;
                if (typeof value === 'number') other = value + 1;
                else if (typeof value === 'string') other = value === '' ? '_' : '';
                else if (typeof value === 'boolean') other = !value;
                else other = true;
                add(variable, value as TestValue, other);
            } else {
                const n = value as number;
                add(variable, n - 1, n, n + 1);
            }
        }

        return map;
    }

    function cartesianProduct(map: Map<string, TestValue[]>): TestVariables[] {
        const entries = Array.from(map.entries());
        let results: TestVariables[] = [{}];

        for (const [variable, values] of entries) {
            const next: TestVariables[] = [];
            for (const vars of results) {
                for (const value of values) {
                    next.push({ ...vars, [variable]: value });
                }
            }
            results = next;
        }

        return results;
    }

    export async function coverageTest(dialog: Dialog): Promise<CoverageIssue[]> {
        const conditions = getConditions(dialog);
        const candidateMap = inferCandidateValues(conditions);

        if (candidateMap.size === 0)
            candidateMap.set('__dummy__', [undefined]);

        const combinations = cartesianProduct(candidateMap);
        const visited = new Set<string>();
        const issueMap = new Map<string, CoverageIssue>();

        for (const combo of combinations) {
            const variables = Object.fromEntries(
                Object.entries(combo).filter(([, v]) => v !== undefined)
            ) as RPGVariables;

            const engine = new DialogEngine(dialog, { variables, actions: {} });
            const stack: string[] = [dialog.startNodeId];
            const seen = new Set<string>();

            while (stack.length > 0) {
                const nodeId = stack.pop()!;
                if (seen.has(nodeId)) continue;
                seen.add(nodeId);

                const view = await engine.advance(nodeId);
                if (view === null) continue;

                visited.add(view.id);

                if (view.choices !== undefined) {
                    if (view.choices.length === 0) {
                        const existing = issueMap.get(view.id);
                        if (existing) {
                            existing.combinations.push(combo);
                        } else {
                            issueMap.set(view.id, {
                                nodeId: view.id,
                                description: 'all choices eliminated',
                                combinations: [combo],
                            });
                        }
                    } else {
                        for (const choice of view.choices)
                            if (choice.nextNodeId !== undefined) stack.push(choice.nextNodeId);
                    }
                } else if (view.nextNodeId !== undefined) {
                    stack.push(view.nextNodeId);
                }
            }
        }

        const unreachable = dialog.nodes
            .filter(n => !visited.has(n.id))
            .map(n => ({ nodeId: n.id, description: 'unreachable', combinations: [] as TestVariables[] }));

        return [...issueMap.values(), ...unreachable];
    }
}

export class DialogEngine {
    private readonly nodeMap: Map<string, DialogNode>;
    private readonly startNodeId: string;
    private currentNode: DialogNode | null = null;

    constructor(
        dialog: Dialog,
        private readonly options: DialogRuntimeOptions | EvalContext,
    ) {
        this.nodeMap = new Map(dialog.nodes.map(n => [n.id, n]));
        this.startNodeId = dialog.startNodeId;
    }

    private getVariables(): RPGVariables {
        if (isEvalContext(this.options)) return resolveVariables(this.options);
        return this.options.variables;
    }

    private async runAction(action: RPGAction): Promise<void> {
        if (isEvalContext(this.options)) {
            await executeAction(action, this.options);
        } else {
            const type = typeof action === 'string' ? action: action.type;
            const arg = typeof action === 'string' ? undefined : action.arg;
            await this.options.actions[type]?.(arg);
        }
    }

    async advance(nodeId?: string): Promise<DialogNodeView | null> {
        let targetId = nodeId ?? this.currentNode?.nextNodeId ?? (this.currentNode === null ? this.startNodeId : undefined);
        const visited = new Set<string>();

        while (targetId !== undefined) {
            if (visited.has(targetId)) {
                console.warn(`[DialogEngine] cycle detected at node '${targetId}', stopping`);
                return null;
            }
            visited.add(targetId);

            const node = this.nodeMap.get(targetId);
            if (!node) return null;

            const vars = this.getVariables();
            if (!evaluateConditions(node.conditions ?? [], vars)) {
                targetId = node.nextNodeId;
                continue;
            }

            this.currentNode = node;

            for (const action of node.actions ?? []) {
                await this.runAction(action);
            }

            const choices = this.filterChoices(node.choices);

            return {
                id: node.id,
                nextNodeId: node.nextNodeId,
                speaker: node.speaker,
                text: node.text,
                choices,
            };
        }

        return null;
    }

    private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
        if (!choices) return undefined;
        const vars = this.getVariables();
        return choices.filter(choice => {
            if (choice.nextNodeId === undefined) return true;
            const target = this.nodeMap.get(choice.nextNodeId);
            return target ? evaluateConditions(target.conditions ?? [], vars) : false;
        });
    }

    [Symbol.asyncIterator]() {
        return this;
    }

    async next() {
        const nextNode = await this.advance();
        if (nextNode === null) return { done: true, value: null };
        return { done: false, value: nextNode };
    }

    reset() {
        this.currentNode = null;
    }

    get currentSpeaker(): string | null {
        return this.currentNode?.speaker || null;
    }
}
