import { hljs } from '../../lib.js';
import { power_user } from '../power-user.js';
import { isFalseBoolean, isTrueBoolean, uuidv4 } from '../utils.js';
import { SlashCommand } from './SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './SlashCommandArgument.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
import { SlashCommandParserError } from './SlashCommandParserError.js';
import { AutoCompleteNameResult } from '../autocomplete/AutoCompleteNameResult.js';
import { SlashCommandQuickReplyAutoCompleteOption } from './SlashCommandQuickReplyAutoCompleteOption.js';
import { SlashCommandScope } from './SlashCommandScope.js';
import { SlashCommandVariableAutoCompleteOption } from './SlashCommandVariableAutoCompleteOption.js';
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
import { MacroAutoCompleteOption } from '../autocomplete/MacroAutoCompleteOption.js';
import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
import { SlashCommandDebugController } from './SlashCommandDebugController.js';
import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
import { SlashCommandBreak } from './SlashCommandBreak.js';
import { MacrosParser } from '../macros.js';
import { t } from '../i18n.js';

/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */

/**
 * @enum {Number}
 * @readonly
 * @typedef {{[id:PARSER_FLAG]:boolean}} ParserFlags
 */
export const PARSER_FLAG = {
    'STRICT_ESCAPING': 1,
    'REPLACE_GETVAR': 2,
};

export class SlashCommandParser {
    /** @type {Object.<string, SlashCommand>} */ static commands = {};

    /**
     * @deprecated Use SlashCommandParser.addCommandObject() instead.
     * @param {string} command Command name
     * @param {(namedArguments:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>} callback callback The function to execute when the command is called
     * @param {string[]} aliases List of alternative command names
     * @param {string} helpString Help text shown in autocomplete and command browser
     */
    static addCommand(command, callback, aliases, helpString = '') {
        this.addCommandObject(SlashCommand.fromProps({
            name: command,
            callback,
            aliases,
            helpString,
        }));
    }
    /**
     *
     * @param {SlashCommand} command
     */
    static addCommandObject(command) {
        const reserved = ['/', '#', ':', 'parser-flag', 'breakpoint'];
        for (const start of reserved) {
            if (command.name.toLowerCase().startsWith(start) || (command.aliases ?? []).find(a=>a.toLowerCase().startsWith(start))) {
                throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`);
            }
        }
        this.addCommandObjectUnsafe(command);
    }
    /**
     *
     * @param {SlashCommand} command
     */
    static addCommandObjectUnsafe(command) {
        if ([command.name, ...command.aliases].some(x => Object.hasOwn(this.commands, x))) {
            console.trace('WARN: Duplicate slash command registered!', [command.name, ...command.aliases]);
        }

        const stack = new Error().stack.split('\n').map(it=>it.trim());
        command.isExtension = stack.find(it=>it.includes('/scripts/extensions/')) != null;
        command.isThirdParty = stack.find(it=>it.includes('/scripts/extensions/third-party/')) != null;
        if (command.isThirdParty) {
            command.source = stack.find(it=>it.includes('/scripts/extensions/third-party/')).replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
        } else if (command.isExtension) {
            command.source = stack.find(it=>it.includes('/scripts/extensions/')).replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
        } else {
            const idx = stack.findLastIndex(it=>it.includes('at SlashCommandParser.')) + 1;
            command.source = stack[idx].replace(/^.*?\/((?:scripts\/)?(?:[^/]+)\.js).*$/, '$1');
        }

        this.commands[command.name] = command;

        if (Array.isArray(command.aliases)) {
            command.aliases.forEach((alias) => {
                this.commands[alias] = command;
            });
        }
    }


    get commands() {
        return SlashCommandParser.commands;
    }
    /** @type {Object.<string, string>} */ helpStrings = {};
    /** @type {boolean} */ verifyCommandNames = true;
    /** @type {string} */ text;
    /** @type {number} */ index;
    /** @type {SlashCommandAbortController} */ abortController;
    /** @type {SlashCommandDebugController} */ debugController;
    /** @type {SlashCommandScope} */ scope;
    /** @type {SlashCommandClosure} */ closure;

    /** @type {Object.<PARSER_FLAG,boolean>} */ flags = {};

    /** @type {boolean} */ jumpedEscapeSequence = false;

    /** @type {{start:number, end:number}[]} */ closureIndex;
    /** @type {{start:number, end:number, name:string}[]} */ macroIndex;
    /** @type {SlashCommandExecutor[]} */ commandIndex;
    /** @type {SlashCommandScope[]} */ scopeIndex;

    /** @type {string} */ parserContext;

    get userIndex() { return this.index; }

    get ahead() {
        return this.text.slice(this.index + 1);
    }
    get behind() {
        return this.text.slice(0, this.index);
    }
    get char() {
        return this.text[this.index];
    }
    get endOfText() {
        return this.index >= this.text.length || (/\s/.test(this.char) && /^\s+$/.test(this.ahead));
    }


    constructor() {
        // add dummy commands for help strings / autocomplete
        if (!Object.keys(this.commands).includes('parser-flag')) {
            const help = {};
            help[PARSER_FLAG.REPLACE_GETVAR] = 'Replace all {{getvar::}} and {{getglobalvar::}} macros with scoped variables to avoid double macro substitution.';
            help[PARSER_FLAG.STRICT_ESCAPING] = 'Allows to escape all delimiters with backslash, and allows escaping of backslashes.';
            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'parser-flag',
                unnamedArgumentList: [
                    SlashCommandArgument.fromProps({
                        description: 'The parser flag to modify.',
                        typeList: [ARGUMENT_TYPE.STRING],
                        isRequired: true,
                        enumList: Object.keys(PARSER_FLAG).map(flag=>new SlashCommandEnumValue(flag, help[PARSER_FLAG[flag]])),
                    }),
                    SlashCommandArgument.fromProps({
                        description: 'The state of the parser flag to set.',
                        typeList: [ARGUMENT_TYPE.BOOLEAN],
                        defaultValue: 'on',
                        enumList: commonEnumProviders.boolean('onOff')(),
                    }),
                ],
                splitUnnamedArgument: true,
                helpString: 'Set a parser flag.',
            }));
        }
        if (!Object.keys(this.commands).includes('/')) {
            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: '/',
                aliases: ['#'],
                unnamedArgumentList: [
                    SlashCommandArgument.fromProps({
                        description: 'commentary',
                        typeList: [ARGUMENT_TYPE.STRING],
                    }),
                ],
                helpString: 'Write a comment.',
            }));
        }
        if (!Object.keys(this.commands).includes('breakpoint')) {
            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'breakpoint',
                helpString: 'Set a breakpoint for debugging in the QR Editor.',
            }));
        }
        if (!Object.keys(this.commands).includes('break')) {
            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'break',
                helpString: 'Break out of a loop or closure executed through /run or /:',
                unnamedArgumentList: [
                    SlashCommandArgument.fromProps({ description: 'value to pass down the pipe instead of the current pipe value',
                        typeList: Object.values(ARGUMENT_TYPE),
                    }),
                ],
            }));
        }

        //TODO should not be re-registered from every instance
        this.registerLanguage();
    }
    registerLanguage() {
        // NUMBER mode is copied from highlightjs's own implementation for JavaScript
        // https://tc39.es/ecma262/#sec-literals-numeric-literals
        const decimalDigits = '[0-9](_?[0-9])*';
        const frac = `\\.(${decimalDigits})`;
        // DecimalIntegerLiteral, including Annex B NonOctalDecimalIntegerLiteral
        // https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals
        const decimalInteger = '0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*';
        const NUMBER = {
            className: 'number',
            variants: [
                // DecimalLiteral
                { begin: `(\\b(${decimalInteger})((${frac})|\\.)?|(${frac}))` +
        `[eE][+-]?(${decimalDigits})\\b` },
                { begin: `\\b(${decimalInteger})\\b((${frac})\\b|\\.)?|(${frac})\\b` },

                // DecimalBigIntegerLiteral
                { begin: '\\b(0|[1-9](_?[0-9])*)n\\b' },

                // NonDecimalIntegerLiteral
                { begin: '\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b' },
                { begin: '\\b0[bB][0-1](_?[0-1])*n?\\b' },
                { begin: '\\b0[oO][0-7](_?[0-7])*n?\\b' },

                // LegacyOctalIntegerLiteral (does not include underscore separators)
                // https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals
                { begin: '\\b0[0-7]+n?\\b' },
            ],
            relevance: 0,
        };

        function getQuotedRunRegex() {
            try {
                return new RegExp('(".+?(?<!\\\\)")|((?:[^\\s\\|"]|"[^"]*")*)(\\||$|\\s)');
            } catch {
                // fallback for browsers that don't support lookbehind
                return /(".+?")|(\S+?)(\||$|\s)/;
            }
        }

        const BLOCK_COMMENT = {
            scope: 'comment',
            begin: /\/\*/,
            end: /\*\|/,
            contains: [],
        };
        const COMMENT = {
            scope: 'comment',
            begin: /\/[/#]/,
            end: /\||$|:}/,
            contains: [],
        };
        const ABORT = {
            begin: /\/(abort|breakpoint)/,
            beginScope: 'abort',
            end: /\||$|(?=:})/,
            excludeEnd: false,
            returnEnd: true,
            contains: [],
        };
        const IMPORT = {
            scope: 'command',
            begin: /\/(import)/,
            beginScope: 'keyword',
            end: /\||$|(?=:})/,
            excludeEnd: false,
            returnEnd: true,
            contains: [],
        };
        const BREAK = {
            scope: 'command',
            begin: /\/(break)/,
            beginScope: 'keyword',
            end: /\||$|(?=:})/,
            excludeEnd: false,
            returnEnd: true,
            contains: [],
        };
        const LET = {
            begin: [
                /\/(let|var)\s+/,
            ],
            beginScope: {
                1: 'variable',
            },
            end: /\||$|:}/,
            excludeEnd: false,
            returnEnd: true,
            contains: [],
        };
        const SETVAR = {
            begin: /\/(setvar|setglobalvar)\s+/,
            beginScope: 'variable',
            end: /\||$|:}/,
            excludeEnd: false,
            returnEnd: true,
            contains: [],
        };
        const GETVAR = {
            begin: /\/(getvar|getglobalvar)\s+/,
            beginScope: 'variable',
            end: /\||$|:}/,
            excludeEnd: false,
            returnEnd: true,
            contains: [],
        };
        const RUN = {
            match: [
                /\/:/,
                getQuotedRunRegex(),
                /\||$|(?=:})/,
            ],
            className: {
                1: 'variable.language',
                2: 'title.function.invoke',
            },
            contains: [], // defined later
        };
        const COMMAND = {
            scope: 'command',
            begin: /\/\S+/,
            beginScope: 'title.function',
            end: /\||$|(?=:})/,
            excludeEnd: false,
            returnEnd: true,
            contains: [], // defined later
        };
        const CLOSURE = {
            scope: 'closure',
            begin: /{:/,
            end: /:}(\(\))?/,
            beginScope: 'punctuation',
            endScope: 'punctuation',
            contains: [], // defined later
        };
        const NAMED_ARG = {
            scope: 'property',
            begin: /\w+=/,
            end: '',
        };
        const MACRO = {
            scope: 'variable',
            begin: /{{/,
            end: /}}/,
        };
        const PIPEBREAK = {
            beginScope: 'pipebreak',
            begin: /\|\|/,
            end: '',
        };
        const PIPE = {
            beginScope: 'pipe',
            begin: /\|/,
            end: '',
        };
        BLOCK_COMMENT.contains.push(
            BLOCK_COMMENT,
        );
        RUN.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            hljs.QUOTE_STRING_MODE,
            NUMBER,
            MACRO,
            CLOSURE,
        );
        IMPORT.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            NUMBER,
            MACRO,
            CLOSURE,
            hljs.QUOTE_STRING_MODE,
        );
        BREAK.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            NUMBER,
            MACRO,
            CLOSURE,
            hljs.QUOTE_STRING_MODE,
        );
        LET.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            NUMBER,
            MACRO,
            CLOSURE,
            hljs.QUOTE_STRING_MODE,
        );
        SETVAR.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            NUMBER,
            MACRO,
            CLOSURE,
            hljs.QUOTE_STRING_MODE,
        );
        GETVAR.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            hljs.QUOTE_STRING_MODE,
            NUMBER,
            MACRO,
            CLOSURE,
        );
        ABORT.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            NUMBER,
            MACRO,
            CLOSURE,
            hljs.QUOTE_STRING_MODE,
        );
        COMMAND.contains.push(
            hljs.BACKSLASH_ESCAPE,
            NAMED_ARG,
            NUMBER,
            MACRO,
            CLOSURE,
            hljs.QUOTE_STRING_MODE,
        );
        CLOSURE.contains.push(
            hljs.BACKSLASH_ESCAPE,
            BLOCK_COMMENT,
            COMMENT,
            ABORT,
            IMPORT,
            BREAK,
            NAMED_ARG,
            NUMBER,
            MACRO,
            RUN,
            LET,
            GETVAR,
            SETVAR,
            COMMAND,
            'self',
            hljs.QUOTE_STRING_MODE,
            PIPEBREAK,
            PIPE,
        );
        hljs.registerLanguage('stscript', ()=>({
            case_insensitive: false,
            keywords: [],
            contains: [
                hljs.BACKSLASH_ESCAPE,
                BLOCK_COMMENT,
                COMMENT,
                ABORT,
                IMPORT,
                BREAK,
                RUN,
                LET,
                GETVAR,
                SETVAR,
                COMMAND,
                CLOSURE,
                PIPEBREAK,
                PIPE,
            ],
        }));
    }

    getHelpString() {
        return '<div class="slashHelp">Loading...</div>';
    }

    /**
     *
     * @param {*} text The text to parse.
     * @param {*} index Index to check for names (cursor position).
     */
    async getNameAt(text, index) {
        if (this.text != text) {
            try {
                this.parse(text, false);
            } catch (e) {
                // do nothing
                console.warn(e);
            }
        }
        const executor = this.commandIndex
            .filter(it=>it.start <= index && (it.end >= index || it.end == null))
            .slice(-1)[0]
            ?? null
        ;

        if (executor) {
            const childClosure = this.closureIndex
                .find(it=>it.start <= index && (it.end >= index || it.end == null) && it.start > executor.start)
                ?? null
            ;
            if (childClosure !== null) return null;
            const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);
            if (macro) {
                const frag = document.createRange().createContextualFragment(await (await fetch('/scripts/templates/macros.html')).text());
                const options = [...frag.querySelectorAll('ul:nth-of-type(2n+1) > li')].map(li=>new MacroAutoCompleteOption(
                    li.querySelector('tt').textContent.slice(2, -2).replace(/^([^\s:]+[\s:]+).*$/, '$1'),
                    li.querySelector('tt').textContent,
                    (li.querySelector('tt').remove(),li.innerHTML),
                ));
                for (const macro of MacrosParser) {
                    if (options.find(it => it.name === macro.key)) continue;
                    options.push(new MacroAutoCompleteOption(macro.key, `{{${macro.key}}}`, macro.description || t`No description provided`));
                }
                const result = new AutoCompleteNameResult(
                    macro.name,
                    macro.start + 2,
                    options,
                    false,
                    ()=>`No matching macros for "{{${result.name}}}"`,
                    ()=>'No macros found.',
                );
                return result;
            }
            if (executor.name == ':') {
                const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
                    ?.allVariableNames
                    ?.map(it=>new SlashCommandVariableAutoCompleteOption(it))
                    ?? []
                ;
                try {
                    if ('quickReplyApi' in globalThis) {
                        const qrApi = globalThis.quickReplyApi;
                        options.push(...qrApi.listSets()
                            .map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`))
                            .flat()
                            .map(qr=>new SlashCommandQuickReplyAutoCompleteOption(qr)),
                        );
                    }
                } catch { /* empty */ }
                const result = new AutoCompleteNameResult(
                    executor.unnamedArgumentList[0]?.value.toString(),
                    executor.start,
                    options,
                    true,
                    ()=>`No matching variables in scope and no matching Quick Replies for "${result.name}"`,
                    ()=>'No variables in scope and no Quick Replies found.',
                );
                return result;
            }
            const result = new SlashCommandAutoCompleteNameResult(executor, this.scopeIndex[this.commandIndex.indexOf(executor)], this.commands);
            return result;
        }
        return null;
    }

    /**
     * Moves the index <length> number of characters forward and returns the last character taken.
     * @param {number} length Number of characters to take.
     * @param {boolean} keep Whether to add the characters to the kept text.
     * @returns The last character taken.
     */
    take(length = 1) {
        this.jumpedEscapeSequence = false;
        let content = this.char;
        this.index++;
        if (length > 1) {
            content = this.take(length - 1);
        }
        return content;
    }
    discardWhitespace() {
        while (/\s/.test(this.char)) {
            this.take(); // discard whitespace
            this.jumpedEscapeSequence = false;
        }
    }
    /**
     * Tests if the next characters match a symbol.
     * Moves the index forward if the next characters are backslashes directly followed by the symbol.
     * Expects that the current char is taken after testing.
     * @param {string|RegExp} sequence Sequence of chars or regex character group that is the symbol.
     * @param {number} offset Offset from the current index (won't move the index if offset != 0).
     * @returns Whether the next characters are the indicated symbol.
     */
    testSymbol(sequence, offset = 0) {
        if (!this.flags[PARSER_FLAG.STRICT_ESCAPING]) return this.testSymbolLooseyGoosey(sequence, offset);
        // /echo abc | /echo def
        // -> TOAST: abc
        // -> TOAST: def
        // /echo abc \| /echo def
        // -> TOAST: abc | /echo def
        // /echo abc \\| /echo def
        // -> TOAST: abc \
        // -> TOAST: def
        // /echo abc \\\| /echo def
        // -> TOAST: abc \| /echo def
        // /echo abc \\\\| /echo def
        // -> TOAST: abc \\
        // -> TOAST: def
        // /echo title=\:} \{: | /echo title=\{: \:}
        // -> TOAST: *:}* {:
        // -> TOAST: *{:* :}
        const escapeOffset = this.jumpedEscapeSequence ? -1 : 0;
        const escapes = this.text.slice(this.index + offset + escapeOffset).replace(/^(\\*).*$/s, '$1').length;
        const test = (sequence instanceof RegExp) ?
            (text) => new RegExp(`^${sequence.source}`).test(text) :
            (text) => text.startsWith(sequence)
        ;
        if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) {
            // no backslashes before sequence
            //   -> sequence found
            if (escapes == 0) return true;
            // uneven number of backslashes before sequence
            //   = the final backslash escapes the sequence
            //   = every preceding pair is one literal backslash
            //    -> move index forward to skip the backslash escaping the first backslash or the symbol
            // even number of backslashes before sequence
            //   = every pair is one literal backslash
            //    -> move index forward to skip the backslash escaping the first backslash
            if (!this.jumpedEscapeSequence && offset == 0) {
                this.index++;
                this.jumpedEscapeSequence = true;
            }
            return false;
        }
    }

    testSymbolLooseyGoosey(sequence, offset = 0) {
        const escapeOffset = this.jumpedEscapeSequence ? -1 : 0;
        const escapes = this.text[this.index + offset + escapeOffset] == '\\' ? 1 : 0;
        const test = (sequence instanceof RegExp) ?
            (text) => new RegExp(`^${sequence.source}`).test(text) :
            (text) => text.startsWith(sequence)
        ;
        if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) {
            // no backslashes before sequence
            //   -> sequence found
            if (escapes == 0) return true;
            // otherwise
            //   -> sequence found
            if (!this.jumpedEscapeSequence && offset == 0) {
                this.index++;
                this.jumpedEscapeSequence = true;
            }
            return false;
        }
    }

    replaceGetvar(value) {
        return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (match, cmd, name, idx) => {
            name = name.trim();
            cmd = cmd.toLowerCase();
            const startIdx = this.index - value.length + idx;
            const endIdx = this.index - value.length + idx + match.length;
            // store pipe
            const pipeName = `_PARSER_PIPE_${uuidv4()}`;
            const storePipe = new SlashCommandExecutor(startIdx); {
                storePipe.end = endIdx;
                storePipe.command = this.commands['let'];
                storePipe.name = 'let';
                const nameAss = new SlashCommandUnnamedArgumentAssignment();
                nameAss.value = pipeName;
                const valAss = new SlashCommandUnnamedArgumentAssignment();
                valAss.value = '{{pipe}}';
                storePipe.unnamedArgumentList = [nameAss, valAss];
                this.closure.executorList.push(storePipe);
            }
            // getvar / getglobalvar
            const getvar = new SlashCommandExecutor(startIdx); {
                getvar.end = endIdx;
                getvar.command = this.commands[cmd];
                getvar.name = cmd;
                const nameAss = new SlashCommandUnnamedArgumentAssignment();
                nameAss.value = name;
                getvar.unnamedArgumentList = [nameAss];
                this.closure.executorList.push(getvar);
            }
            // set to temp scoped var
            const varName = `_PARSER_VAR_${uuidv4()}`;
            const setvar = new SlashCommandExecutor(startIdx); {
                setvar.end = endIdx;
                setvar.command = this.commands['let'];
                setvar.name = 'let';
                const nameAss = new SlashCommandUnnamedArgumentAssignment();
                nameAss.value = varName;
                const valAss = new SlashCommandUnnamedArgumentAssignment();
                valAss.value = '{{pipe}}';
                setvar.unnamedArgumentList = [nameAss, valAss];
                this.closure.executorList.push(setvar);
            }
            // return pipe
            const returnPipe = new SlashCommandExecutor(startIdx); {
                returnPipe.end = endIdx;
                returnPipe.command = this.commands['return'];
                returnPipe.name = 'return';
                const varAss = new SlashCommandUnnamedArgumentAssignment();
                varAss.value = `{{var::${pipeName}}}`;
                returnPipe.unnamedArgumentList = [varAss];
                this.closure.executorList.push(returnPipe);
            }
            return `{{var::${varName}}}`;
        });
    }


    parse(text, verifyCommandNames = true, flags = null, abortController = null, debugController = null) {
        this.verifyCommandNames = verifyCommandNames;
        for (const key of Object.keys(PARSER_FLAG)) {
            this.flags[PARSER_FLAG[key]] = flags?.[PARSER_FLAG[key]] ?? power_user.stscript.parser.flags[PARSER_FLAG[key]] ?? false;
        }
        this.abortController = abortController;
        this.debugController = debugController;
        this.text = text;
        this.index = 0;
        this.scope = null;
        this.closureIndex = [];
        this.commandIndex = [];
        this.scopeIndex = [];
        this.macroIndex = [];
        this.parserContext = uuidv4();
        const closure = this.parseClosure(true);
        return closure;
    }

    testClosure() {
        return this.testSymbol('{:');
    }
    testClosureEnd() {
        if (!this.scope.parent) {
            // "root" closure does not have {: and :}
            if (this.index >= this.text.length) return true;
            return false;
        }
        if (!this.verifyCommandNames) {
            if (this.index >= this.text.length) return true;
        } else {
            if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed closure at position ${this.userIndex}`, this.text, this.index);
        }
        return this.testSymbol(':}');
    }
    parseClosure(isRoot = false) {
        const closureIndexEntry = { start:this.index + 1, end:null };
        this.closureIndex.push(closureIndexEntry);
        let injectPipe = true;
        if (!isRoot) this.take(2); // discard opening {:
        const textStart = this.index;
        let closure = new SlashCommandClosure(this.scope);
        closure.parserContext = this.parserContext;
        closure.fullText = this.text;
        closure.abortController = this.abortController;
        closure.debugController = this.debugController;
        this.scope = closure.scope;
        const oldClosure = this.closure;
        this.closure = closure;
        this.discardWhitespace();
        while (this.testNamedArgument()) {
            const arg = this.parseNamedArgument();
            closure.argumentList.push(arg);
            this.scope.variableNames.push(arg.name);
            this.discardWhitespace();
        }
        while (!this.testClosureEnd()) {
            if (this.testBlockComment()) {
                this.parseBlockComment();
            } else if (this.testComment()) {
                this.parseComment();
            } else if (this.testParserFlag()) {
                this.parseParserFlag();
            } else if (this.testRunShorthand()) {
                const cmd = this.parseRunShorthand();
                closure.executorList.push(cmd);
                injectPipe = true;
            } else if (this.testBreakPoint()) {
                const bp = this.parseBreakPoint();
                if (this.debugController) {
                    closure.executorList.push(bp);
                }
            } else if (this.testBreak()) {
                const b = this.parseBreak();
                closure.executorList.push(b);
            } else if (this.testCommand()) {
                const cmd = this.parseCommand();
                cmd.injectPipe = injectPipe;
                closure.executorList.push(cmd);
                injectPipe = true;
            } else {
                while (!this.testCommandEnd()) this.take(); // discard plain text and comments
            }
            this.discardWhitespace();
            // first pipe marks end of command
            if (this.testSymbol('|')) {
                this.take(); // discard first pipe
                // second pipe indicates no pipe injection for the next command
                if (this.testSymbol('|')) {
                    injectPipe = false;
                    this.take(); // discard second pipe
                }
            }
            this.discardWhitespace(); // discard further whitespace
        }
        closure.rawText = this.text.slice(textStart, this.index);
        if (!isRoot) this.take(2); // discard closing :}
        if (this.testSymbol('()')) {
            this.take(2); // discard ()
            closure.executeNow = true;
        }
        closureIndexEntry.end = this.index - 1;
        this.scope = closure.scope.parent;
        this.closure = oldClosure ?? closure;
        return closure;
    }

    testBreakPoint() {
        return this.testSymbol(/\/breakpoint\s*\|/);
    }
    parseBreakPoint() {
        const cmd = new SlashCommandBreakPoint();
        cmd.name = 'breakpoint';
        cmd.command = this.commands['breakpoint'];
        cmd.start = this.index + 1;
        this.take('/breakpoint'.length);
        cmd.end = this.index;
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        return cmd;
    }

    testBreak() {
        return this.testSymbol(/\/break(\s|\||$)/);
    }
    parseBreak() {
        const cmd = new SlashCommandBreak();
        cmd.name = 'break';
        cmd.command = this.commands['break'];
        cmd.start = this.index + 1;
        this.take('/break'.length);
        this.discardWhitespace();
        if (this.testUnnamedArgument()) {
            cmd.unnamedArgumentList.push(...this.parseUnnamedArgument());
        }
        cmd.end = this.index;
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        return cmd;
    }

    testBlockComment() {
        return this.testSymbol('/*');
    }
    testBlockCommentEnd() {
        if (!this.verifyCommandNames) {
            if (this.index >= this.text.length) return true;
        } else {
            if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed block comment at position ${this.userIndex}`, this.text, this.index);
        }
        return this.testSymbol('*|');
    }
    parseBlockComment() {
        const start = this.index + 1;
        const cmd = new SlashCommandExecutor(start);
        cmd.command = this.commands['*'];
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        this.take(); // discard "/"
        cmd.name = this.take(); //set "*" as name
        while (!this.testBlockCommentEnd()) {
            if (this.testBlockComment()) {
                this.parseBlockComment();
            }
            this.take();
        }
        this.take(2); // take closing "*|"
        cmd.end = this.index - 1;
    }

    testComment() {
        return this.testSymbol(/\/[/#]/);
    }
    testCommentEnd() {
        if (!this.verifyCommandNames) {
            if (this.index >= this.text.length) return true;
        } else {
            if (this.endOfText) throw new SlashCommandParserError(`Unclosed comment at position ${this.userIndex}`, this.text, this.index);
        }
        return this.testSymbol('|');
    }
    parseComment() {
        const start = this.index + 1;
        const cmd = new SlashCommandExecutor(start);
        cmd.command = this.commands['/'];
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        this.take(); // discard "/"
        cmd.name = this.take(); // set second "/" or "#" as name
        while (!this.testCommentEnd()) this.take();
        cmd.end = this.index;
    }

    testParserFlag() {
        return this.testSymbol('/parser-flag ');
    }
    testParserFlagEnd() {
        return this.testCommandEnd();
    }
    parseParserFlag() {
        const start = this.index + 1;
        const cmd = new SlashCommandExecutor(start);
        cmd.name = 'parser-flag';
        cmd.unnamedArgumentList = [];
        cmd.command = this.commands[cmd.name];
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        this.take(13); // discard "/parser-flag "
        cmd.startNamedArgs = -1;
        cmd.endNamedArgs = -1;
        cmd.startUnnamedArgs = this.index;
        cmd.unnamedArgumentList = this.parseUnnamedArgument(true);
        const [flag, state] = cmd.unnamedArgumentList ?? [null, null];
        cmd.endUnnamedArgs = this.index;
        if (Object.keys(PARSER_FLAG).includes(flag.value.toString())) {
            this.flags[PARSER_FLAG[flag.value.toString()]] = isTrueBoolean(state?.value.toString() ?? 'on');
        }
        cmd.end = this.index;
    }

    testRunShorthand() {
        return this.testSymbol('/:') && !this.testSymbol(':}', 1);
    }
    testRunShorthandEnd() {
        return this.testCommandEnd();
    }
    parseRunShorthand() {
        const start = this.index + 2;
        const cmd = new SlashCommandExecutor(start);
        cmd.name = ':';
        cmd.unnamedArgumentList = [];
        cmd.command = this.commands['run'];
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        this.take(2); //discard "/:"
        const assignment = new SlashCommandUnnamedArgumentAssignment();
        if (this.testQuotedValue()) assignment.value = this.parseQuotedValue();
        else assignment.value = this.parseValue();
        cmd.unnamedArgumentList = [assignment];
        this.discardWhitespace();
        cmd.startNamedArgs = this.index;
        while (this.testNamedArgument()) {
            const arg = this.parseNamedArgument();
            cmd.namedArgumentList.push(arg);
            this.discardWhitespace();
        }
        cmd.endNamedArgs = this.index;
        this.discardWhitespace();
        // /run shorthand does not take unnamed arguments (the command name practically *is* the unnamed argument)
        if (this.testRunShorthandEnd()) {
            cmd.end = this.index;
            return cmd;
        } else {
            console.warn(this.behind, this.char, this.ahead);
            throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index);
        }
    }

    testCommand() {
        return this.testSymbol('/');
    }
    testCommandEnd() {
        return this.testClosureEnd() || this.testSymbol('|');
    }
    parseCommand() {
        const start = this.index + 1;
        const cmd = new SlashCommandExecutor(start);
        cmd.parserFlags = Object.assign({}, this.flags);
        this.commandIndex.push(cmd);
        this.scopeIndex.push(this.scope.getCopy());
        this.take(); // discard "/"
        while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end
        this.discardWhitespace();
        if (this.verifyCommandNames && !this.commands[cmd.name]) throw new SlashCommandParserError(`Unknown command at position ${this.index - cmd.name.length}: "/${cmd.name}"`, this.text, this.index - cmd.name.length);
        cmd.command = this.commands[cmd.name];
        cmd.startNamedArgs = this.index;
        cmd.endNamedArgs = this.index;
        while (this.testNamedArgument()) {
            const arg = this.parseNamedArgument();
            cmd.namedArgumentList.push(arg);
            cmd.endNamedArgs = this.index;
            this.discardWhitespace();
        }
        this.discardWhitespace();
        cmd.startUnnamedArgs = this.index - (/\s(\s*)$/s.exec(this.behind)?.[1]?.length ?? 0);
        cmd.endUnnamedArgs = this.index;
        if (this.testUnnamedArgument()) {
            const rawQuotesArg = cmd?.namedArgumentList?.find(a => a.name === 'raw');
            const rawQuotes = cmd?.command?.rawQuotes && rawQuotesArg ? !isFalseBoolean(rawQuotesArg?.value?.toString()) : cmd?.command?.rawQuotes;
            cmd.unnamedArgumentList = this.parseUnnamedArgument(cmd.command?.unnamedArgumentList?.length && cmd?.command?.splitUnnamedArgument, cmd?.command?.splitUnnamedArgumentCount, rawQuotes);
            cmd.endUnnamedArgs = this.index;
            if (cmd.name == 'let') {
                const keyArg = cmd.namedArgumentList.find(it=>it.name == 'key');
                if (keyArg) {
                    this.scope.variableNames.push(keyArg.value.toString());
                } else if (typeof cmd.unnamedArgumentList[0]?.value == 'string') {
                    this.scope.variableNames.push(cmd.unnamedArgumentList[0].value);
                }
            } else if (cmd.name == 'import') {
                const value = /**@type {string[]}*/(cmd.unnamedArgumentList.map(it=>it.value));
                for (let i = 0; i < value.length; i++) {
                    const srcName = value[i];
                    let dstName = srcName;
                    if (i + 2 < value.length && value[i + 1] == 'as') {
                        dstName = value[i + 2];
                        i += 2;
                    }
                    this.scope.variableNames.push(dstName);
                }
            }
        }
        if (this.testCommandEnd()) {
            cmd.end = this.index;
            return cmd;
        } else {
            console.warn(this.behind, this.char, this.ahead);
            throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index);
        }
    }

    testNamedArgument() {
        return /^(\w+)=/.test(`${this.char}${this.ahead}`);
    }
    parseNamedArgument() {
        let assignment = new SlashCommandNamedArgumentAssignment();
        assignment.start = this.index;
        let key = '';
        while (/\w/.test(this.char)) key += this.take(); // take chars
        this.take(); // discard "="
        assignment.name = key;
        if (this.testClosure()) {
            assignment.value = this.parseClosure();
        } else if (this.testQuotedValue()) {
            assignment.value = this.parseQuotedValue();
        } else if (this.testListValue()) {
            assignment.value = this.parseListValue();
        } else if (this.testValue()) {
            assignment.value = this.parseValue();
        }
        assignment.end = this.index;
        return assignment;
    }

    testUnnamedArgument() {
        return !this.testCommandEnd();
    }
    testUnnamedArgumentEnd() {
        return this.testCommandEnd();
    }
    parseUnnamedArgument(split, splitCount = null, rawQuotes = false) {
        const wasSplit = split;
        /**@type {SlashCommandClosure|String}*/
        let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
        let isList = split;
        let listValues = [];
        let listQuoted = []; // keep track of which listValues were quoted
        /**@type {SlashCommandUnnamedArgumentAssignment}*/
        let assignment = new SlashCommandUnnamedArgumentAssignment();
        assignment.start = this.index;
        if (!split && !rawQuotes && this.testQuotedValue()) {
            // if the next bit is a quoted value, take the whole value and gather contents as a list
            assignment.value = this.parseQuotedValue();
            assignment.end = this.index;
            isList = true;
            listValues.push(assignment);
            listQuoted.push(true);
            assignment = new SlashCommandUnnamedArgumentAssignment();
            assignment.start = this.index;
        }
        while (!this.testUnnamedArgumentEnd()) {
            if (split && splitCount && listValues.length >= splitCount) {
                // the split count has just been reached: stop splitting, the rest is one singular value
                split = false;
                if (this.testQuotedValue()) {
                    // if the next bit is a quoted value, take the whole value
                    assignment.value = this.parseQuotedValue();
                    assignment.end = this.index;
                    listValues.push(assignment);
                    listQuoted.push(true);
                    assignment = new SlashCommandUnnamedArgumentAssignment();
                    assignment.start = this.index;
                }
            }
            if (this.testClosure()) {
                isList = true;
                if (value.length > 0) {
                    this.indexMacros(this.index - value.length, value);
                    assignment.value = value;
                    listValues.push(assignment);
                    listQuoted.push(false);
                    assignment = new SlashCommandUnnamedArgumentAssignment();
                    assignment.start = this.index;
                    if (!split && this.testQuotedValue()) {
                        // if where currently not splitting and the next bit is a quoted value, take the whole value
                        assignment.value = this.parseQuotedValue();
                        assignment.end = this.index;
                        listValues.push(assignment);
                        listQuoted.push(true);
                        assignment = new SlashCommandUnnamedArgumentAssignment();
                        assignment.start = this.index;
                    } else {
                        value = '';
                    }
                }
                assignment.start = this.index;
                assignment.value = this.parseClosure();
                assignment.end = this.index;
                listValues.push(assignment);
                assignment = new SlashCommandUnnamedArgumentAssignment();
                assignment.start = this.index;
                if (split) this.discardWhitespace();
            } else if (split) {
                if (this.testQuotedValue()) {
                    assignment.start = this.index;
                    assignment.value = this.parseQuotedValue();
                    assignment.end = this.index;
                    listValues.push(assignment);
                    listQuoted.push(true);
                    assignment = new SlashCommandUnnamedArgumentAssignment();
                } else if (this.testListValue()) {
                    assignment.start = this.index;
                    assignment.value = this.parseListValue();
                    assignment.end = this.index;
                    listValues.push(assignment);
                    listQuoted.push(false);
                    assignment = new SlashCommandUnnamedArgumentAssignment();
                } else if (this.testValue()) {
                    assignment.start = this.index;
                    assignment.value = this.parseValue();
                    assignment.end = this.index;
                    listValues.push(assignment);
                    listQuoted.push(false);
                    assignment = new SlashCommandUnnamedArgumentAssignment();
                } else {
                    throw new SlashCommandParserError(`Unexpected end of unnamed argument at index ${this.userIndex}.`);
                }
                this.discardWhitespace();
            } else {
                value += this.take();
                assignment.end = this.index;
            }
        }
        if (isList && value.length > 0) {
            assignment.value = value;
            listValues.push(assignment);
            listQuoted.push(false);
        }
        if (isList) {
            const firstVal = listValues[0];
            if (typeof firstVal?.value == 'string') {
                if (!listQuoted[0]) {
                    // only trim the first part if it wasn't quoted
                    firstVal.value = firstVal.value.trimStart();
                }
                if (firstVal.value.length == 0) {
                    listValues.shift();
                    listQuoted.shift();
                }
            }
            const lastVal = listValues.slice(-1)[0];
            if (typeof lastVal?.value == 'string') {
                if (!listQuoted.slice(-1)[0]) {
                    // only trim the last part if it wasn't quoted
                    lastVal.value = lastVal.value.trimEnd();
                }
                if (lastVal.value.length == 0) {
                    listValues.pop();
                    listQuoted.pop();
                }
            }
            if (wasSplit && splitCount && splitCount + 1 < listValues.length) {
                // if split with a split count and there are more values than expected
                // -> should be result of quoting + additional (non-whitespace) text
                // -> join the parts into one and restore quotes
                const joined = new SlashCommandUnnamedArgumentAssignment();
                joined.start = listValues[splitCount].start;
                joined.end = listValues.slice(-1)[0].end;
                joined.value = '';
                for (let i = splitCount; i < listValues.length; i++) {
                    if (listQuoted[i]) joined.value += `"${listValues[i].value}"`;
                    else joined.value += listValues[i].value;
                }
                listValues = [
                    ...listValues.slice(0, splitCount),
                    joined,
                ];
            }
            return listValues;
        }
        this.indexMacros(this.index - value.length, value);
        value = value.trim();
        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
            value = this.replaceGetvar(value);
        }
        assignment.value = value;
        return [assignment];
    }

    testQuotedValue() {
        return this.testSymbol('"');
    }
    testQuotedValueEnd() {
        if (this.endOfText) {
            if (this.verifyCommandNames) throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index);
            else return true;
        }
        if (!this.verifyCommandNames && this.testClosureEnd()) return true;
        if (this.verifyCommandNames && !this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd()) {
            throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index);
        }
        return this.testSymbol('"') || (!this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd());
    }
    parseQuotedValue() {
        this.take(); // discard opening quote
        let value = '';
        while (!this.testQuotedValueEnd()) value += this.take(); // take all chars until closing quote
        this.take(); // discard closing quote
        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
            value = this.replaceGetvar(value);
        }
        this.indexMacros(this.index - value.length, value);
        return value;
    }

    testListValue() {
        return this.testSymbol('[');
    }
    testListValueEnd() {
        if (this.endOfText) throw new SlashCommandParserError(`Unexpected end of list value at position ${this.index}`, this.text, this.index);
        return this.testSymbol(']');
    }
    parseListValue() {
        let value = this.take(); // take the already tested opening bracket
        while (!this.testListValueEnd()) value += this.take(); // take all chars until closing bracket
        value += this.take(); // take closing bracket
        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
            value = this.replaceGetvar(value);
        }
        this.indexMacros(this.index - value.length, value);
        return value;
    }

    testValue() {
        return !this.testSymbol(/\s/);
    }
    testValueEnd() {
        if (this.testSymbol(/\s/)) return true;
        return this.testCommandEnd();
    }
    parseValue() {
        let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
        while (!this.testValueEnd()) value += this.take(); // take all chars until value end
        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
            value = this.replaceGetvar(value);
        }
        this.indexMacros(this.index - value.length, value);
        return value;
    }

    indexMacros(offset, text) {
        const re = /{{(?:((?:(?!}})[^\s:])+[\s:]*)((?:(?!}}).)*)(}}|}$|$))?/s;
        let remaining = text;
        let localOffset = 0;
        while (remaining.length > 0 && re.test(remaining)) {
            const match = re.exec(remaining);
            this.macroIndex.push({
                start: offset + localOffset + match.index,
                end: offset + localOffset + match.index + (match[0]?.length ?? 0),
                name: match[1] ?? '',
            });
            localOffset += match.index + (match[0]?.length ?? 0);
            remaining = remaining.slice(match.index + (match[0]?.length ?? 0));
        }
    }
}
