/* eslint-disable no-underscore-dangle */
import { Runner } from './runner';
import { i18n, i18nWithLanguage } from './i18n';
import {
    apiboardFromJboard,
    apiboardToJboard,
    APIJBoard,
    gbbFromJboard,
    gbbToJboard,
    JBoard
} from './board_formats';
import { Value, ValueInteger, ValueString, ValueStructure } from './value';
import {
    ASTDef,
    ASTPatternStructure,
    GbsInterpreterException,
    N_PatternStructure,
    N_PatternTimeout,
    SourceReader,
    Token
} from '@gobstones/gobstones-parser';
import { boolFromValue, RuntimeState } from './runtime';
import { Frame } from './vm';

const DEFAULT_INFINITE_LOOP_TIMEOUT = 3000; /* millisecs */
const DEFAULT_LANGUAGE = 'es';

interface InterpreterInternalState {
    infiniteLoopTimeout: number;
    language: string;
    runner: Runner;
}

interface Snapshot {
    contextNames: string[];
    board: APIJBoard;
    region: string;
    regionStack: string | string[];
}

interface Program {
    alias: string;
    interpret: (board: APIJBoard) => void;
}

interface Declaration {
    alias: string;
    name: string;
}

/* load a board in the API format into a fresh RuntimeState */
function apiboardToState(apiboard: APIJBoard): RuntimeState {
    const state = new RuntimeState();
    state.load(apiboardToJboard(apiboard));
    return state;
}

/* Dump a RuntimeState to a board in the API format */
const apiboardFromState = (state: RuntimeState): APIJBoard => apiboardFromJboard(state.dump());

/* Backwards-compatible type/value with special cases for some types */
function apivalueFromValue(value: Value): { type: string; value: number | boolean | string } {
    const composedValue = (
        componentKind: string
    ): { type: string; value: number | boolean | string } => {
        const elements = value[componentKind].map((it: Value) => {
            const apiValue = apivalueFromValue(it);
            const value2 = apiValue ? apiValue.value : undefined;

            return value2;
        });

        return {
            type: value.type().toString(),
            value: elements
        };
    };

    if (value === undefined) {
        return undefined;
    }
    if (value.isInteger()) {
        return {
            type: i18n('TYPE:Integer'),
            value: (value as ValueInteger).asNumber()
        };
    } else if (value.isBoolean()) {
        return {
            type: i18n('TYPE:Bool'),
            value: boolFromValue(value as ValueStructure)
        };
    } else if (value.isString()) {
        return {
            type: i18n('TYPE:String'),
            value: (value as ValueString).string
        };
    } else if (value.isTuple()) {
        return composedValue('components');
    } else if (value.isList()) {
        return composedValue('elements');
    } else if (value.isStructure()) {
        return {
            type: (value as ValueStructure).typeName,
            value: value.toString()
        };
    } else {
        return {
            type: value.type().toString(),
            value: value.toString()
        };
    }
}

class GobstonesInterpreterError {
    public message: string;
    public reason: { code: string; detail: string[] };
    public on: {
        range: { start: { row: number; column: number }; end: { row: number; column: number } };
        region: string;
        regionStack: string | string[];
    };
    public constructor(exception: GbsInterpreterException) {
        this.message = exception.message;
        this.reason = {
            code: exception.reason,
            detail: exception.args
        };
        this.on = {
            range: {
                start: {
                    row: exception.startPos.line,
                    column: exception.startPos.column
                },
                end: {
                    row: exception.endPos.line,
                    column: exception.endPos.column
                }
            },
            region: exception.startPos.region,
            regionStack: undefined
        };
    }
}

class ParseError extends GobstonesInterpreterError {
    public constructor(exception: GbsInterpreterException) {
        super(exception);
    }
}

class ExecutionError extends GobstonesInterpreterError {
    public snapshots: Snapshot[];
    public constructor(
        exception: GbsInterpreterException,
        snapshots: Snapshot[],
        regionStack: string | string[]
    ) {
        super(exception);

        const isTimeout = this.reason.code === 'timeout';
        this.snapshots = isTimeout ? [snapshots[snapshots.length - 1]] : snapshots;
        this.on.region =
            typeof regionStack === 'string' ? regionStack : regionStack[regionStack.length - 1];
        this.on.regionStack = regionStack;
    }
}

class NormalExecutionResult {
    public finalBoard: JBoard;
    public snapshots: Snapshot[];
    public returnValue: { type: string; value: string | number | boolean };
    public actualReturnValue: Value;
    public constructor(finalBoard, snapshots: Snapshot[], returnValue: Value) {
        this.finalBoard = finalBoard;
        this.snapshots = snapshots;
        this.returnValue = apivalueFromValue(returnValue);

        /* Actual return value */
        this.actualReturnValue = returnValue;
    }
}

class InteractiveExecutionResult {
    public keys: Token[];
    public timeout: Token;
    // eslint-disable-next-line @typescript-eslint/ban-types
    public onInit: Function;
    // eslint-disable-next-line @typescript-eslint/ban-types
    public onKey: Function;
    // eslint-disable-next-line @typescript-eslint/ban-types
    public onTimeout: Function;
    public constructor(state: InterpreterInternalState) {
        this.keys = this._collectKeyNames(state);
        this.timeout = this._timeoutValue(state);
        this.onInit = this._onInitFunction(state);
        this.onKey = this._onKeyFunction(state);
        this.onTimeout = this._onTimeoutFunction(state);
    }

    public _hasInit(state: InterpreterInternalState): boolean {
        for (const branch of state.runner.symbolTable.program.branches) {
            const p = branch.pattern;
            if (p.tag === N_PatternStructure && p.constructorName.value === i18n('CONS:INIT')) {
                return true;
            }
        }
        return false;
    }

    public _hasTimeout(state): boolean {
        return this.timeout !== undefined;
    }

    public _collectKeyNames(state: InterpreterInternalState): Token[] {
        const keys = [];
        for (const branch of state.runner.symbolTable.program.branches) {
            const p: { tag: symbol } = branch.pattern;
            if (
                p.tag === N_PatternStructure &&
                (p as ASTPatternStructure).constructorName.value !== i18n('CONS:INIT')
            ) {
                keys.push((p as ASTPatternStructure).constructorName.value);
            }
        }
        return keys;
    }

    public _timeoutValue(state: InterpreterInternalState): Token {
        for (const branch of state.runner.symbolTable.program.branches) {
            if (branch.pattern.tag === N_PatternTimeout) {
                return branch.pattern.timeout;
            }
        }
        return undefined;
    }

    /* Return a function that, when called, continues running
     * the interactive program feeding it with the INIT event.
     *
     * If the interactive program does not have an entry for the
     * INIT event, the returned function has no effect.
     */
    // eslint-disable-next-line @typescript-eslint/ban-types
    public _onInitFunction(state: InterpreterInternalState): Function {
        if (this._hasInit(state)) {
            return () =>
                i18nWithLanguage(state.language, () =>
                    this._onEvent(
                        state,
                        new ValueStructure(i18n('TYPE:Event'), i18n('CONS:INIT'), {})
                    )
                );
        } else {
            return () =>
                i18nWithLanguage(state.language, () => apiboardFromState(state.runner.globalState));
        }
    }

    /* Return a function that, when called, continues running
     * the interactive program feeding it with the TIMEOUT event.
     *
     * If the interactive program does not have an entry for the
     * TIMEOUT event, the returned function has no effect.
     */
    // eslint-disable-next-line @typescript-eslint/ban-types
    public _onTimeoutFunction(state: InterpreterInternalState): Function {
        if (this._hasTimeout(state)) {
            return () =>
                i18nWithLanguage(state.language, () =>
                    this._onEvent(
                        state,
                        new ValueStructure(i18n('TYPE:Event'), i18n('CONS:TIMEOUT'), {})
                    )
                );
        } else {
            return () =>
                i18nWithLanguage(state.language, () => apiboardFromState(state.runner.globalState));
        }
    }

    /* Return a function that, when called with a key code, continues running
     * the interactive program feeding it with the given key event.
     *
     * If the interactive program does not have an entry for the given
     * key, this results in a runtime error.
     */
    // eslint-disable-next-line @typescript-eslint/ban-types
    public _onKeyFunction(state: InterpreterInternalState): Function {
        return (keyCode: string) =>
            i18nWithLanguage(state.language, () =>
                this._onEvent(state, new ValueStructure(i18n('TYPE:Event'), keyCode, {}))
            );
    }

    /* Continue running the interactive program feeding it with the given
     * eventValue.
     * On success, return a Board.
     * On failure, return an ExecutionError. */
    public _onEvent(state: InterpreterInternalState, eventValue: Value): APIJBoard {
        return i18nWithLanguage(state.language, () => {
            try {
                state.runner.executeEventWithTimeout(eventValue, state.infiniteLoopTimeout);
                return apiboardFromState(state.runner.globalState);
            } catch (exception) {
                if (exception.isGobstonesException === undefined) {
                    throw exception;
                }
                return new ExecutionError(exception, [], state.runner.regionStack());
            }
        });
    }
}

class SnapshotTaker {
    private _runner: Runner;
    private _snapshots: Snapshot[];
    public constructor(runner: Runner) {
        this._runner = runner;
        this._snapshots = [];
    }

    public takeSnapshot(
        routineName: string,
        position: SourceReader,
        callStack: Frame[],
        globalState: RuntimeState
    ): void {
        if (this._shouldTakeSnapshot(routineName, callStack)) {
            this._snapshots.push(this._snapshot(routineName, position, callStack, globalState));
        }
    }

    public snapshots(): Snapshot[] {
        return this._snapshots;
    }

    public _snapshot(
        routineName: string,
        position: SourceReader,
        callStack: Frame[],
        globalState: RuntimeState
    ): Snapshot {
        const snapshot: Snapshot = {
            contextNames: [],
            board: apiboardFromState(globalState),
            region: position.region,
            regionStack: this._runner.regionStack()
        };
        for (const stackFrame of callStack) {
            let name = stackFrame.routineName;
            if (name !== 'program') {
                name = name + '-' + stackFrame.uniqueFrameId.toString();
            }
            snapshot.contextNames.push(name);
        }
        return snapshot;
    }

    public _shouldTakeSnapshot(routineName: string, callStack: Frame[]): boolean {
        const routineNameStack = [];
        for (const stackFrame of callStack) {
            routineNameStack.push(stackFrame.routineName);
        }

        if (this._runner.primitives.isProcedure(routineName)) {
            /* A primitive procedure must be recorded if there are no
             * atomic routines anywhere in the call stack. */
            return this._noAtomicRoutines(routineNameStack);
        } else {
            /* Other routines must be recorded if they have the 'recorded'
             * attribute, and, moreover, there are no atomic routines other
             * than the last one in the call stack. */
            routineNameStack.pop();
            return this._isRecorded(routineName) && this._noAtomicRoutines(routineNameStack);
        }
    }

    public _noAtomicRoutines(routineNameStack): boolean {
        for (const routineName of routineNameStack) {
            if (this._isAtomic(routineName)) {
                return false;
            }
        }
        return true;
    }

    public _isAtomic(routineName): boolean {
        if (routineName === 'program') {
            return false;
        } else if (this._runner.primitives.isProcedure(routineName)) {
            /* Primitive procedure */
            return false;
        } else if (this._runner.symbolTable.isProcedure(routineName)) {
            /* User-defined procedure */
            return false;
        } else {
            /* Function */
            return true;
        }
    }

    public _isRecorded(routineName): boolean {
        if (routineName === 'program') {
            return true;
        } else if (this._runner.primitives.isProcedure(routineName)) {
            /* Primitive procedure */
            return true;
        } else if (this._runner.symbolTable.isProcedure(routineName)) {
            /* User-defined procedure */
            return false;
        } else {
            /* Function */
            return false;
        }
    }
}

class ParseResult {
    public program: Program;
    public declarations: Declaration[];
    public getAttributes: (globalName: string) => Record<string, ASTDef>;
    public constructor(state: InterpreterInternalState) {
        if (state.runner.symbolTable.program === undefined) {
            this.program = undefined;
        } else if (state.runner.symbolTable.isInteractiveProgram()) {
            this.program = this._resultForInteractiveProgram(state);
        } else {
            this.program = this._resultForProgram(state);
        }
        this.declarations = this._collectDeclarations(state.runner);
        this.getAttributes = (globalName) => state.runner.symbolTable.getAttributes(globalName);
    }

    public _resultForProgram(state: InterpreterInternalState): Program {
        return {
            alias: 'program',
            interpret: (board: APIJBoard) => {
                const snapshotTaker = new SnapshotTaker(state.runner);

                return i18nWithLanguage(state.language, () => {
                    try {
                        state.runner.compile();
                        state.runner.executeWithTimeoutTakingSnapshots(
                            apiboardToState(board),
                            state.infiniteLoopTimeout,
                            snapshotTaker.takeSnapshot.bind(snapshotTaker)
                        );

                        const finalBoard = apiboardFromState(state.runner.globalState);
                        const returnValue = state.runner.result;
                        return new NormalExecutionResult(
                            finalBoard,
                            snapshotTaker.snapshots(),
                            returnValue
                        );
                    } catch (exception) {
                        if (exception.isGobstonesException === undefined) {
                            throw exception;
                        }

                        return new ExecutionError(
                            exception,
                            snapshotTaker.snapshots(),
                            state.runner.regionStack()
                        );
                    }
                });
            }
        };
    }

    public _resultForInteractiveProgram(state: InterpreterInternalState): Program {
        return {
            alias: 'interactiveProgram',
            interpret: (board) =>
                i18nWithLanguage(state.language, () => {
                    try {
                        state.runner.compile();
                        state.runner.initializeVirtualMachine(apiboardToState(board));
                        return new InteractiveExecutionResult(state);
                    } catch (exception) {
                        if (exception.isGobstonesException === undefined) {
                            throw exception;
                        }
                        return new ExecutionError(exception, [], state.runner.regionStack());
                    }
                })
        };
    }

    public _collectDeclarations(runner: Runner): Declaration[] {
        const declarations = [];
        for (const name of runner.symbolTable.allProcedureNames()) {
            if (runner.primitives.isProcedure(name)) {
                continue; /* Skip primitive procedures */
            }
            declarations.push({
                alias: 'procedureDeclaration',
                name
            });
        }
        for (const name of runner.symbolTable.allFunctionNames()) {
            if (runner.primitives.isFunction(name)) {
                continue; /* Skip primitive functions */
            }
            declarations.push({
                alias: 'functionDeclaration',
                name
            });
        }
        return declarations;
    }
}

export class GobstonesInterpreterAPI {
    public config: {
        setLanguage: (code: string) => void;
        setInfiniteLoopTimeout: (milliseconds: number) => void;
        setXGobstonesEnabled: (isEnabled: boolean) => void;
    };
    public gbb: {
        /* Convert a string representing a board in GBB format
         * to a board in the "API" format. */
        read: (gbb: string) => APIJBoard;
        /* Convert a board in the "API" format to a string representing
         * a board in GBB format. */
        write: (apiboard: APIJBoard) => string;
    };
    public getAst: (sourceCode: string) => any;
    public parse: (sourceCode: string) => any;
    // eslint-disable-next-line @typescript-eslint/ban-types
    public _withState: (sourceCode: string, useLinter: boolean, action: Function) => any;
    public constructor() {
        /* Internal state of the interpreter */
        const state = {
            infiniteLoopTimeout: DEFAULT_INFINITE_LOOP_TIMEOUT,
            language: DEFAULT_LANGUAGE,
            runner: new Runner()
        };

        this.config = {
            setLanguage(code) {
                state.language = code;
            },
            setInfiniteLoopTimeout(milliseconds) {
                state.infiniteLoopTimeout = milliseconds;
            },
            setXGobstonesEnabled(isEnabled) {
                /* TODO */
            }
        };

        this.gbb = {
            /* Convert a string representing a board in GBB format
             * to a board in the "API" format. */
            read: (gbb) => apiboardFromJboard(gbbToJboard(gbb)),
            /* Convert a board in the "API" format to a string representing
             * a board in GBB format. */
            write: (apiboard) => gbbFromJboard(apiboardToJboard(apiboard))
        };

        this.getAst = (sourceCode) =>
            this._withState(sourceCode, false, (_state) =>
                _state.runner.abstractSyntaxTree.toMulangLike()
            );

        this.parse = (sourceCode) =>
            this._withState(sourceCode, true, (_state) => new ParseResult(_state));

        this._withState = (sourceCode, useLinter, action) =>
            i18nWithLanguage(state.language, () => {
                try {
                    state.runner.initialize();
                    state.runner.parse(sourceCode);
                    /* Disable checking whether there is a main 'program' present. */
                    state.runner.enableLintCheck('source-should-have-a-program-definition', false);
                    if (useLinter) state.runner.lint();
                    return action(state);
                } catch (exception) {
                    if (exception.isGobstonesException === undefined) {
                        throw exception;
                    }
                    return new ParseError(exception);
                }
            });
    }
}
