import { hljs, morphdom } from '../../../../lib.js';
import { POPUP_RESULT, POPUP_TYPE, Popup } from '../../../popup.js';
import { setSlashCommandAutoComplete } from '../../../slash-commands.js';
import { SlashCommandAbortController } from '../../../slash-commands/SlashCommandAbortController.js';
import { SlashCommandBreakPoint } from '../../../slash-commands/SlashCommandBreakPoint.js';
import { SlashCommandClosure } from '../../../slash-commands/SlashCommandClosure.js';
import { SlashCommandClosureResult } from '../../../slash-commands/SlashCommandClosureResult.js';
import { SlashCommandDebugController } from '../../../slash-commands/SlashCommandDebugController.js';
import { SlashCommandExecutor } from '../../../slash-commands/SlashCommandExecutor.js';
import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
import { SlashCommandParserError } from '../../../slash-commands/SlashCommandParserError.js';
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
import { accountStorage } from '../../../util/AccountStorage.js';
import { debounce, delay, getSortableDelay, showFontAwesomePicker } from '../../../utils.js';
import { log, quickReplyApi, warn } from '../index.js';
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
import { QuickReplySet } from './QuickReplySet.js';
import { ContextMenu } from './ui/ctx/ContextMenu.js';

export class QuickReply {
    /**
     * @param {{ id?: number; contextList?: any; }} props
     */
    static from(props) {
        props.contextList = (props.contextList ?? []).map((/** @type {any} */ it)=>QuickReplyContextLink.from(it));
        return Object.assign(new this(), props);
    }




    /**@type {number}*/ id;
    /**@type {string}*/ icon;
    /**@type {string}*/ label = '';
    /**@type {boolean}*/ showLabel = false;
    /**@type {string}*/ title = '';
    /**@type {string}*/ message = '';

    /**@type {QuickReplyContextLink[]}*/ contextList;

    /**@type {boolean}*/ preventAutoExecute = true;
    /**@type {boolean}*/ isHidden = false;
    /**@type {boolean}*/ executeOnStartup = false;
    /**@type {boolean}*/ executeOnUser = false;
    /**@type {boolean}*/ executeOnAi = false;
    /**@type {boolean}*/ executeOnChatChange = false;
    /**@type {boolean}*/ executeOnGroupMemberDraft = false;
    /**@type {boolean}*/ executeOnNewChat = false;
    /**@type {boolean}*/ executeBeforeGeneration = false;
    /**@type {string}*/ automationId = '';

    /**@type {function}*/ onExecute;
    /** @type {(qr:QuickReply)=>AsyncGenerator<SlashCommandClosureResult|{closure:SlashCommandClosure, executor:SlashCommandExecutor|SlashCommandClosureResult}, SlashCommandClosureResult, boolean>} */ onDebug;
    /**@type {function}*/ onDelete;
    /**@type {function}*/ onUpdate;
    /**@type {function}*/ onInsertBefore;
    /**@type {function}*/ onTransfer;


    /**@type {HTMLElement}*/ dom;
    /**@type {HTMLElement}*/ domIcon;
    /**@type {HTMLElement}*/ domLabel;
    /**@type {HTMLElement}*/ settingsDom;
    /**@type {HTMLElement}*/ settingsDomIcon;
    /**@type {HTMLInputElement}*/ settingsDomLabel;
    /**@type {HTMLTextAreaElement}*/ settingsDomMessage;

    /**@type {Popup}*/ editorPopup;
    /**@type {HTMLElement}*/ editorDom;

    /**@type {HTMLTextAreaElement}*/ editorMessage;
    /**@type {HTMLTextAreaElement}*/ editorMessageLabel;
    /**@type {HTMLElement}*/ editorSyntax;
    /**@type {HTMLElement}*/ editorExecuteBtn;
    /**@type {HTMLElement}*/ editorExecuteBtnPause;
    /**@type {HTMLElement}*/ editorExecuteBtnStop;
    /**@type {HTMLElement}*/ editorExecuteProgress;
    /**@type {HTMLElement}*/ editorExecuteErrors;
    /**@type {HTMLElement}*/ editorExecuteResult;
    /**@type {HTMLElement}*/ editorDebugState;
    /**@type {Promise}*/ editorExecutePromise;
    /**@type {boolean}*/ isExecuting;
    /**@type {SlashCommandAbortController}*/ abortController;
    /**@type {SlashCommandDebugController}*/ debugController;


    get hasContext() {
        return this.contextList && this.contextList.filter(it => it.set).length > 0;
    }




    unrender() {
        this.dom?.remove();
        this.dom = null;
    }
    updateRender() {
        if (!this.dom) return;
        this.dom.title = this.title || this.message;
        if (this.icon) {
            this.domIcon.classList.remove('qr--hidden');
            if (this.showLabel) this.domLabel.classList.remove('qr--hidden');
            else this.domLabel.classList.add('qr--hidden');
        } else {
            this.domIcon.classList.add('qr--hidden');
            this.domLabel.classList.remove('qr--hidden');
        }
        this.domLabel.textContent = this.label;
        this.dom.classList[this.hasContext ? 'add' : 'remove']('qr--hasCtx');
    }
    render() {
        this.unrender();
        if (!this.dom) {
            const root = document.createElement('div'); {
                this.dom = root;
                root.classList.add('qr--button');
                root.classList.add('menu_button');
                if (this.hasContext) {
                    root.classList.add('qr--hasCtx');
                }
                root.title = this.title || this.message;
                root.addEventListener('contextmenu', (evt) => {
                    log('contextmenu', this, this.hasContext);
                    if (this.hasContext) {
                        evt.preventDefault();
                        evt.stopPropagation();
                        const menu = new ContextMenu(this);
                        menu.show(evt);
                    }
                });
                root.addEventListener('click', (evt)=>{
                    if (evt.ctrlKey) {
                        this.showEditor();
                        return;
                    }
                    this.execute();
                });
                const icon = document.createElement('div'); {
                    this.domIcon = icon;
                    icon.classList.add('qr--button-icon');
                    icon.classList.add('fa-solid');
                    if (!this.icon) icon.classList.add('qr--hidden');
                    else icon.classList.add(this.icon);
                    root.append(icon);
                }
                const lbl = document.createElement('div'); {
                    this.domLabel = lbl;
                    lbl.classList.add('qr--button-label');
                    if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
                    lbl.textContent = this.label;
                    root.append(lbl);
                }
                const expander = document.createElement('div'); {
                    expander.classList.add('qr--button-expander');
                    expander.textContent = '⋮';
                    expander.title = 'Open context menu';
                    expander.addEventListener('click', (evt) => {
                        evt.stopPropagation();
                        evt.preventDefault();
                        const menu = new ContextMenu(this);
                        menu.show(evt);
                    });
                    root.append(expander);
                }
            }
        }
        return this.dom;
    }




    renderSettings(idx) {
        if (!this.settingsDom) {
            const item = document.createElement('div'); {
                this.settingsDom = item;
                item.classList.add('qr--set-item');
                item.setAttribute('data-order', String(idx));
                item.setAttribute('data-id', String(this.id));
                const adder = document.createElement('div'); {
                    adder.classList.add('qr--set-itemAdder');
                    const actions = document.createElement('div'); {
                        actions.classList.add('qr--actions');
                        const addNew = document.createElement('div'); {
                            addNew.classList.add('qr--action');
                            addNew.classList.add('qr--add');
                            addNew.classList.add('menu_button');
                            addNew.classList.add('menu_button_icon');
                            addNew.classList.add('fa-solid');
                            addNew.classList.add('fa-plus');
                            addNew.title = 'Add quick reply';
                            addNew.addEventListener('click', ()=>this.onInsertBefore());
                            actions.append(addNew);
                        }
                        const paste = document.createElement('div'); {
                            paste.classList.add('qr--action');
                            paste.classList.add('qr--paste');
                            paste.classList.add('menu_button');
                            paste.classList.add('menu_button_icon');
                            paste.classList.add('fa-solid');
                            paste.classList.add('fa-paste');
                            paste.title = 'Add quick reply from clipboard';
                            paste.addEventListener('click', async()=>{
                                const text = await navigator.clipboard.readText();
                                this.onInsertBefore(text);
                            });
                            actions.append(paste);
                        }
                        const importFile = document.createElement('div'); {
                            importFile.classList.add('qr--action');
                            importFile.classList.add('qr--importFile');
                            importFile.classList.add('menu_button');
                            importFile.classList.add('menu_button_icon');
                            importFile.classList.add('fa-solid');
                            importFile.classList.add('fa-file-import');
                            importFile.title = 'Add quick reply from JSON file';
                            importFile.addEventListener('click', async()=>{
                                const inp = document.createElement('input'); {
                                    inp.type = 'file';
                                    inp.accept = '.json';
                                    inp.addEventListener('change', async()=>{
                                        if (inp.files.length > 0) {
                                            for (const file of inp.files) {
                                                const text = await file.text();
                                                this.onInsertBefore(text);
                                            }
                                        }
                                    });
                                    inp.click();
                                }
                            });
                            actions.append(importFile);
                        }
                        adder.append(actions);
                    }
                    item.append(adder);
                }
                const itemContent = document.createElement('div'); {
                    itemContent.classList.add('qr--content');
                    const drag = document.createElement('div'); {
                        drag.classList.add('drag-handle');
                        drag.classList.add('ui-sortable-handle');
                        drag.textContent = '☰';
                        itemContent.append(drag);
                    }
                    const lblContainer = document.createElement('div'); {
                        lblContainer.classList.add('qr--set-itemLabelContainer');
                        const icon = document.createElement('div'); {
                            this.settingsDomIcon = icon;
                            icon.title = 'Click to change icon';
                            icon.classList.add('qr--set-itemIcon');
                            icon.classList.add('menu_button');
                            icon.classList.add('fa-fw');
                            if (this.icon) {
                                icon.classList.add('fa-solid');
                                icon.classList.add(this.icon);
                            }
                            icon.addEventListener('click', async()=>{
                                let value = await showFontAwesomePicker();
                                this.updateIcon(value);
                            });
                            lblContainer.append(icon);
                        }
                        const lbl = document.createElement('input'); {
                            this.settingsDomLabel = lbl;
                            lbl.classList.add('qr--set-itemLabel');
                            lbl.classList.add('text_pole');
                            lbl.value = this.label;
                            lbl.addEventListener('input', ()=>this.updateLabel(lbl.value));
                            lblContainer.append(lbl);
                        }
                        itemContent.append(lblContainer);
                    }
                    item.append(itemContent);
                }
                const optContainer = document.createElement('div'); {
                    optContainer.classList.add('qr--set-optionsContainer');
                    const opt = document.createElement('div'); {
                        opt.classList.add('qr--action');
                        opt.classList.add('menu_button');
                        opt.classList.add('fa-fw');
                        opt.classList.add('fa-solid');
                        opt.textContent = '⁝';
                        opt.title = 'Additional options:\n - large editor\n - context menu\n - auto-execution\n - tooltip';
                        opt.addEventListener('click', ()=>this.showEditor());
                        optContainer.append(opt);
                    }
                    itemContent.append(optContainer);
                }
                const mes = document.createElement('textarea'); {
                    this.settingsDomMessage = mes;
                    mes.id = `qr--set--item${this.id}`;
                    mes.classList.add('qr--set-itemMessage');
                    mes.value = this.message;
                    //HACK need to use jQuery to catch the triggered event from the expanded editor
                    $(mes).on('input', ()=>this.updateMessage(mes.value));
                    itemContent.append(mes);
                }
                const actions = document.createElement('div'); {
                    actions.classList.add('qr--actions');
                    const move = document.createElement('div'); {
                        move.classList.add('qr--action');
                        move.classList.add('menu_button');
                        move.classList.add('fa-fw');
                        move.classList.add('fa-solid');
                        move.classList.add('fa-truck-arrow-right');
                        move.title = 'Move quick reply to other set';
                        move.addEventListener('click', ()=>this.onTransfer(this));
                        actions.append(move);
                    }
                    const copy = document.createElement('div'); {
                        copy.classList.add('qr--action');
                        copy.classList.add('menu_button');
                        copy.classList.add('fa-fw');
                        copy.classList.add('fa-solid');
                        copy.classList.add('fa-copy');
                        copy.title = 'Copy quick reply to clipboard';
                        copy.addEventListener('click', async()=>{
                            await navigator.clipboard.writeText(JSON.stringify(this));
                            copy.classList.add('qr--success');
                            await delay(3010);
                            copy.classList.remove('qr--success');
                        });
                        actions.append(copy);
                    }
                    const cut = document.createElement('div'); {
                        cut.classList.add('qr--action');
                        cut.classList.add('menu_button');
                        cut.classList.add('fa-fw');
                        cut.classList.add('fa-solid');
                        cut.classList.add('fa-cut');
                        cut.title = 'Cut quick reply to clipboard (copy and remove)';
                        cut.addEventListener('click', async()=>{
                            await navigator.clipboard.writeText(JSON.stringify(this));
                            this.delete();
                        });
                        actions.append(cut);
                    }
                    const exp = document.createElement('div'); {
                        exp.classList.add('qr--action');
                        exp.classList.add('menu_button');
                        exp.classList.add('fa-fw');
                        exp.classList.add('fa-solid');
                        exp.classList.add('fa-file-export');
                        exp.title = 'Export quick reply as file';
                        exp.addEventListener('click', ()=>{
                            const blob = new Blob([JSON.stringify(this)], { type:'text' });
                            const url = URL.createObjectURL(blob);
                            const a = document.createElement('a'); {
                                a.href = url;
                                a.download = `${this.label}.qr.json`;
                                a.click();
                            }
                        });
                        actions.append(exp);
                    }
                    const del = document.createElement('div'); {
                        del.classList.add('qr--action');
                        del.classList.add('menu_button');
                        del.classList.add('fa-fw');
                        del.classList.add('fa-solid');
                        del.classList.add('fa-trash-can');
                        del.classList.add('redWarningBG');
                        del.title = 'Remove Quick Reply\n---\nShift+Click to skip confirmation';
                        del.addEventListener('click', async(evt)=>{
                            if (!evt.shiftKey) {
                                const result = await Popup.show.confirm(
                                    'Remove Quick Reply',
                                    'Are you sure you want to remove this Quick Reply?',
                                );
                                if (result != POPUP_RESULT.AFFIRMATIVE) {
                                    return;
                                }
                            }
                            this.delete();
                        });
                        actions.append(del);
                    }
                    itemContent.append(actions);
                }
            }
        }
        return this.settingsDom;
    }
    unrenderSettings() {
        this.settingsDom?.remove();
    }

    async showEditor() {
        const response = await fetch('/scripts/extensions/quick-reply/html/qrEditor.html', { cache: 'no-store' });
        if (response.ok) {
            this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--modalEditor');
            /**@type {HTMLElement} */
            // @ts-ignore
            const dom = this.template.cloneNode(true);
            this.editorDom = dom;
            this.editorPopup = new Popup(dom, POPUP_TYPE.TEXT, undefined, { okButton: 'OK', wide: true, large: true, rows: 1 });
            const popupResult = this.editorPopup.show();

            // basics
            /**@type {HTMLElement}*/
            const icon = dom.querySelector('#qr--modal-icon');
            if (this.icon) {
                icon.classList.add('fa-solid');
                icon.classList.add(this.icon);
            }
            else {
                icon.textContent = '…';
            }
            icon.addEventListener('click', async()=>{
                let value = await showFontAwesomePicker();
                if (value === null) return;
                if (this.icon) icon.classList.remove(this.icon);
                if (value == '') {
                    icon.classList.remove('fa-solid');
                    icon.textContent = '…';
                } else {
                    icon.textContent = '';
                    icon.classList.add('fa-solid');
                    icon.classList.add(value);
                }
                this.updateIcon(value);
            });
            /**@type {HTMLInputElement}*/
            const showLabel = dom.querySelector('#qr--modal-showLabel');
            showLabel.checked = this.showLabel;
            showLabel.addEventListener('click', ()=>{
                this.updateShowLabel(showLabel.checked);
            });
            /**@type {HTMLInputElement}*/
            const label = dom.querySelector('#qr--modal-label');
            label.value = this.label;
            label.addEventListener('input', ()=>{
                this.updateLabel(label.value);
            });
            let switcherList;
            // @ts-ignore
            dom.querySelector('#qr--modal-switcher').addEventListener('click', (evt)=>{
                if (switcherList) {
                    switcherList.remove();
                    switcherList = null;
                    return;
                }
                const list = document.createElement('ul'); {
                    switcherList = list;
                    list.classList.add('qr--modal-switcherList');
                    const makeList = (qrs)=>{
                        const setItem = document.createElement('li'); {
                            setItem.classList.add('qr--modal-switcherItem');
                            setItem.addEventListener('click', ()=>{
                                list.innerHTML = '';
                                for (const qrs of quickReplyApi.listSets()) {
                                    const item = document.createElement('li'); {
                                        item.classList.add('qr--modal-switcherItem');
                                        item.addEventListener('click', ()=>{
                                            list.innerHTML = '';
                                            makeList(quickReplyApi.getSetByName(qrs));
                                        });
                                        const lbl = document.createElement('div'); {
                                            lbl.classList.add('qr--label');
                                            lbl.textContent = qrs;
                                            item.append(lbl);
                                        }
                                        list.append(item);
                                    }
                                }
                            });
                            const lbl = document.createElement('div'); {
                                lbl.classList.add('qr--label');
                                const icon = document.createElement('i'); {
                                    icon.classList.add('fa-solid');
                                    icon.classList.add('fa-arrow-alt-circle-right');
                                    icon.classList.add('menu_button');
                                    lbl.append(icon);
                                }
                                const text = document.createElement('span'); {
                                    text.textContent = 'Switch QR Sets...';
                                    lbl.append(text);
                                }
                                setItem.append(lbl);
                            }
                            list.append(setItem);
                        }
                        const addItem = document.createElement('li'); {
                            addItem.classList.add('qr--modal-switcherItem');
                            addItem.addEventListener('click', ()=>{
                                const qr = quickReplyApi.getSetByQr(this).addQuickReply();
                                this.editorPopup.completeAffirmative();
                                qr.showEditor();
                            });
                            const lbl = document.createElement('div'); {
                                lbl.classList.add('qr--label');
                                const icon = document.createElement('i'); {
                                    icon.classList.add('fa-solid');
                                    icon.classList.add('fa-plus');
                                    icon.classList.add('menu_button');
                                    lbl.append(icon);
                                }
                                const text = document.createElement('span'); {
                                    text.textContent = 'Add QR';
                                    lbl.append(text);
                                }
                                addItem.append(lbl);
                            }
                            list.append(addItem);
                        }
                        for (const qr of qrs.qrList.toSorted((a,b)=>a.label.toLowerCase().localeCompare(b.label.toLowerCase()))) {
                            const item = document.createElement('li'); {
                                item.classList.add('qr--modal-switcherItem');
                                if (qr == this) item.classList.add('qr--current');
                                else item.addEventListener('click', ()=>{
                                    this.editorPopup.completeAffirmative();
                                    qr.showEditor();
                                });
                                const lbl = document.createElement('div'); {
                                    lbl.classList.add('qr--label');
                                    lbl.textContent = qr.label;
                                    item.append(lbl);
                                }
                                const id = document.createElement('div'); {
                                    id.classList.add('qr--id');
                                    id.textContent = qr.id.toString();
                                    item.append(id);
                                }
                                const mes = document.createElement('div'); {
                                    mes.classList.add('qr--message');
                                    mes.textContent = qr.message;
                                    item.append(mes);
                                }
                                list.append(item);
                            }
                        }
                    };
                    makeList(quickReplyApi.getSetByQr(this));
                }
                label.parentElement.append(list);
            });
            /**@type {HTMLInputElement}*/
            const title = dom.querySelector('#qr--modal-title');
            title.value = this.title;
            title.addEventListener('input', () => {
                this.updateTitle(title.value);
            });
            /**@type {HTMLElement}*/
            const messageSyntaxInner = dom.querySelector('#qr--modal-messageSyntaxInner');
            this.editorSyntax = messageSyntaxInner;
            /**@type {HTMLInputElement}*/
            const wrap = dom.querySelector('#qr--modal-wrap');
            wrap.checked = JSON.parse(accountStorage.getItem('qr--wrap') ?? 'false');
            wrap.addEventListener('click', () => {
                accountStorage.setItem('qr--wrap', JSON.stringify(wrap.checked));
                updateWrap();
            });
            const updateWrap = () => {
                if (wrap.checked) {
                    message.style.whiteSpace = 'pre-wrap';
                    messageSyntaxInner.style.whiteSpace = 'pre-wrap';
                    if (this.clone) {
                        this.clone.style.whiteSpace = 'pre-wrap';
                    }
                } else {
                    message.style.whiteSpace = 'pre';
                    messageSyntaxInner.style.whiteSpace = 'pre';
                    if (this.clone) {
                        this.clone.style.whiteSpace = 'pre';
                    }
                }
                updateScrollDebounced();
            };
            const updateScroll = (evt) => {
                let left = message.scrollLeft;
                let top = message.scrollTop;
                if (evt) {
                    evt.preventDefault();
                    left = message.scrollLeft + evt.deltaX;
                    top = message.scrollTop + evt.deltaY;
                    message.scrollTo({
                        behavior: 'instant',
                        left,
                        top,
                    });
                }
                messageSyntaxInner.scrollTo({
                    behavior: 'instant',
                    left,
                    top,
                });
            };
            const updateScrollDebounced = updateScroll;
            const updateSyntaxEnabled = ()=>{
                if (syntax.checked) {
                    dom.querySelector('#qr--modal-messageHolder').classList.remove('qr--noSyntax');
                } else {
                    dom.querySelector('#qr--modal-messageHolder').classList.add('qr--noSyntax');
                }
            };
            /**@type {HTMLInputElement}*/
            const tabSize = dom.querySelector('#qr--modal-tabSize');
            tabSize.value = JSON.parse(accountStorage.getItem('qr--tabSize') ?? '4');
            const updateTabSize = () => {
                message.style.tabSize = tabSize.value;
                messageSyntaxInner.style.tabSize = tabSize.value;
                updateScrollDebounced();
            };
            tabSize.addEventListener('change', () => {
                accountStorage.setItem('qr--tabSize', JSON.stringify(Number(tabSize.value)));
                updateTabSize();
            });
            /**@type {HTMLInputElement}*/
            const executeShortcut = dom.querySelector('#qr--modal-executeShortcut');
            executeShortcut.checked = JSON.parse(accountStorage.getItem('qr--executeShortcut') ?? 'true');
            executeShortcut.addEventListener('click', () => {
                accountStorage.setItem('qr--executeShortcut', JSON.stringify(executeShortcut.checked));
            });
            /**@type {HTMLInputElement}*/
            const syntax = dom.querySelector('#qr--modal-syntax');
            syntax.checked = JSON.parse(accountStorage.getItem('qr--syntax') ?? 'true');
            syntax.addEventListener('click', () => {
                accountStorage.setItem('qr--syntax', JSON.stringify(syntax.checked));
                updateSyntaxEnabled();
            });
            // @ts-ignore
            if (navigator.keyboard) {
                // @ts-ignore
                navigator.keyboard.getLayoutMap().then(it=>dom.querySelector('#qr--modal-commentKey').textContent = it.get('Backslash'));
            } else {
                dom.querySelector('#qr--modal-commentKey').closest('small').remove();
            }
            this.editorMessageLabel = dom.querySelector('label[for="qr--modal-message"]');
            /**@type {HTMLTextAreaElement}*/
            const message = dom.querySelector('#qr--modal-message');
            this.editorMessage = message;
            message.value = this.message;
            const updateMessageDebounced = debounce((value)=>this.updateMessage(value), 10);
            message.addEventListener('input', () => {
                updateMessageDebounced(message.value);
                updateScrollDebounced();
            }, { passive:true });
            const getLineStart = ()=>{
                const start = message.selectionStart;
                let lineStart;
                if (start == 0 || message.value[start - 1] == '\n') {
                    // cursor is already at beginning of line
                    // -> keep start
                    lineStart = start;
                } else {
                    // cursor is at end of line or somewhere in the line
                    // -> find last newline before cursor and start after that
                    lineStart = message.value.lastIndexOf('\n', start - 1) + 1;
                }
                return lineStart;
            };
            message.addEventListener('keydown', async(evt) => {
                if (this.isExecuting) return;
                if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
                    // increase indent
                    evt.preventDefault();
                    const start = message.selectionStart;
                    const end = message.selectionEnd;
                    if (end - start > 0 && message.value.substring(start, end).includes('\n')) {
                        evt.stopImmediatePropagation();
                        evt.stopPropagation();
                        const lineStart = getLineStart();
                        message.selectionStart = lineStart;
                        const affectedLines = message.value.substring(lineStart, end).split('\n');
                        // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                        document.execCommand('insertText', false, `\t${affectedLines.join('\n\t')}`);
                        message.selectionStart = start + 1;
                        message.selectionEnd = end + affectedLines.length;
                        message.dispatchEvent(new Event('input', { bubbles:true }));
                    } else if (!(ac.isReplaceable && ac.isActive)) {
                        evt.stopImmediatePropagation();
                        evt.stopPropagation();
                        // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                        document.execCommand('insertText', false, '\t');
                        message.dispatchEvent(new Event('input', { bubbles:true }));
                    }
                } else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
                    // decrease indent
                    evt.preventDefault();
                    evt.stopImmediatePropagation();
                    evt.stopPropagation();
                    const start = message.selectionStart;
                    const end = message.selectionEnd;
                    const lineStart = getLineStart();
                    message.selectionStart = lineStart;
                    const affectedLines = message.value.substring(lineStart, end).split('\n');
                    const newText = affectedLines.map(it=>it.replace(/^\t/, '')).join('\n');
                    const delta = affectedLines.join('\n').length - newText.length;
                    // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                    if (delta > 0) {
                        if (newText == '') {
                            document.execCommand('delete', false);
                        } else {
                            document.execCommand('insertText', false, newText);
                        }
                        message.selectionStart = start - (affectedLines[0].startsWith('\t') ? 1 : 0);
                        message.selectionEnd = end - delta;
                        message.dispatchEvent(new Event('input', { bubbles:true }));
                    } else {
                        message.selectionStart = start;
                    }
                } else if (evt.key == 'Enter' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey && !(ac.isReplaceable && ac.isActive)) {
                    // new line, keep indent
                    const start = message.selectionStart;
                    let lineStart = getLineStart();
                    const indent = /^([^\S\n]*)/.exec(message.value.slice(lineStart))[1] ?? '';
                    if (indent.length) {
                        evt.stopImmediatePropagation();
                        evt.stopPropagation();
                        evt.preventDefault();
                        // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                        document.execCommand('insertText', false, `\n${indent}`);
                        message.selectionStart = start + 1 + indent.length;
                        message.selectionEnd  = message.selectionStart;
                        message.dispatchEvent(new Event('input', { bubbles:true }));
                    }
                } else if (evt.key == 'Enter' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
                    if (executeShortcut.checked) {
                        // execute QR
                        evt.stopImmediatePropagation();
                        evt.stopPropagation();
                        evt.preventDefault();
                        const selectionStart = message.selectionStart;
                        const selectionEnd = message.selectionEnd;
                        message.blur();
                        await this.executeFromEditor();
                        if (document.activeElement != message) {
                            message.focus();
                            message.selectionStart = selectionStart;
                            message.selectionEnd = selectionEnd;
                        }
                    }
                } else if (evt.key == 'F9' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
                    // toggle breakpoint
                    evt.stopImmediatePropagation();
                    evt.stopPropagation();
                    evt.preventDefault();
                    preBreakPointStart = message.selectionStart;
                    preBreakPointEnd = message.selectionEnd;
                    toggleBreakpoint();
                } else if (evt.code == 'Backslash' && evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
                    // toggle block comment
                    // (evt.code will use the same physical key on the keyboard across different keyboard layouts)
                    evt.stopImmediatePropagation();
                    evt.stopPropagation();
                    evt.preventDefault();
                    // check if we are inside a comment -> uncomment
                    const parser = new SlashCommandParser();
                    parser.parse(message.value, false);
                    const start = message.selectionStart;
                    const end = message.selectionEnd;
                    const comment = parser.commandIndex.findLast(it=>it.name == '*' && (it.start <= start && it.end >= start || it.start <= end && it.end >= end));
                    if (comment) {
                        // uncomment
                        let content = message.value.slice(comment.start + 1, comment.end - 1);
                        let len = content.length;
                        content = content.replace(/^ /, '');
                        const offsetStart = len - content.length;
                        len = content.length;
                        content = content.replace(/ $/, '');
                        const offsetEnd = len - content.length;
                        message.selectionStart = comment.start - 1;
                        message.selectionEnd = comment.end + 1;
                        // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                        document.execCommand('insertText', false, content);
                        message.selectionStart = start - (start >= comment.start ? 2 + offsetStart : 0);
                        message.selectionEnd = end - 2 - offsetStart - (end >= comment.end ? 2 + offsetEnd : 0);
                    } else {
                        // comment
                        const lineStart = getLineStart();
                        const lineEnd = message.value.indexOf('\n', end);
                        message.selectionStart = lineStart;
                        message.selectionEnd = lineEnd;
                        // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                        document.execCommand('insertText', false, `/* ${message.value.slice(lineStart, lineEnd)} *|`);
                        message.selectionStart = start + 3;
                        message.selectionEnd = end + 3;
                    }
                    message.dispatchEvent(new Event('input', { bubbles:true }));
                }
            });
            const ac = await setSlashCommandAutoComplete(message, true);
            message.addEventListener('wheel', (evt)=>{
                updateScrollDebounced(evt);
            });
            // @ts-ignore
            message.addEventListener('scroll', (evt)=>{
                updateScrollDebounced();
            });
            let preBreakPointStart;
            let preBreakPointEnd;
            /**
             * @param {SlashCommandBreakPoint} bp
             */
            const removeBreakpoint = (bp)=>{
                // start at -1 because "/" is not included in start-end
                let start = bp.start - 1;
                // step left until forward slash "/"
                while (message.value[start] != '/') start--;
                // step left while whitespace (except newline) before start
                while (/[^\S\n]/.test(message.value[start - 1])) start--;
                // if newline before indent, include the newline for removal
                if (message.value[start - 1] == '\n') start--;
                let end = bp.end;
                // step right while whitespace
                while (/\s/.test(message.value[end])) end++;
                // if pipe after whitepace, include pipe for removal
                if (message.value[end] == '|') end++;
                message.selectionStart = start;
                message.selectionEnd = end;
                // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                document.execCommand('insertText', false, '');
                message.dispatchEvent(new Event('input', { bubbles:true }));
                let postStart = preBreakPointStart;
                let postEnd = preBreakPointEnd;
                // set caret back to where it was
                if (preBreakPointStart <= start) {
                    // selection start was before breakpoint: do nothing
                } else if (preBreakPointStart > start && preBreakPointEnd < end) {
                    // selection start was inside breakpoint: move to index before breakpoint
                    postStart = start;
                } else if (preBreakPointStart >= end) {
                    // selection start was behind breakpoint: move back by length of removed string
                    postStart = preBreakPointStart - (end - start);
                }
                if (preBreakPointEnd <= start) {
                    // do nothing
                } else if (preBreakPointEnd > start && preBreakPointEnd < end) {
                    // selection end was inside breakpoint: move to index before breakpoint
                    postEnd = start;
                } else if (preBreakPointEnd >= end) {
                    // selection end was behind breakpoint: move back by length of removed string
                    postEnd = preBreakPointEnd - (end - start);
                }
                return { start:postStart, end:postEnd };
            };
            /**
             * @param {SlashCommandExecutor} cmd
             */
            const addBreakpoint = (cmd)=>{
                // start at -1 because "/" is not included in start-end
                let start = cmd.start - 1;
                let indent = '';
                // step left until forward slash "/"
                while (message.value[start] != '/') start--;
                // step left while whitespace (except newline) before start, collect the whitespace to help build indentation
                while (/[^\S\n]/.test(message.value[start - 1])) {
                    start--;
                    indent += message.value[start];
                }
                // if newline before indent, include the newline
                if (message.value[start - 1] == '\n') {
                    start--;
                    indent = `\n${indent}`;
                }
                const breakpointText = `${indent}/breakpoint |`;
                message.selectionStart = start;
                message.selectionEnd = start;
                // document.execCommand is deprecated (and potentially buggy in some browsers) but the only way to retain undo-history
                document.execCommand('insertText', false, breakpointText);
                message.dispatchEvent(new Event('input', { bubbles:true }));
                return breakpointText.length;
            };
            const toggleBreakpoint = ()=>{
                const idx = message.selectionStart;
                let postStart = preBreakPointStart;
                let postEnd = preBreakPointEnd;
                const parser = new SlashCommandParser();
                parser.parse(message.value, false);
                const cmdIdx = parser.commandIndex.findLastIndex(it=>it.start <= idx);
                if (cmdIdx > -1) {
                    const cmd = parser.commandIndex[cmdIdx];
                    if (cmd instanceof SlashCommandBreakPoint) {
                        const bp = cmd;
                        const { start, end } = removeBreakpoint(bp);
                        postStart = start;
                        postEnd = end;
                    } else if (parser.commandIndex[cmdIdx - 1] instanceof SlashCommandBreakPoint) {
                        const bp = parser.commandIndex[cmdIdx - 1];
                        const { start, end } = removeBreakpoint(bp);
                        postStart = start;
                        postEnd = end;
                    } else {
                        const len = addBreakpoint(cmd);
                        postStart += len;
                        postEnd += len;
                    }
                    message.selectionStart = postStart;
                    message.selectionEnd = postEnd;
                }
            };
            message.addEventListener('pointerdown', (evt)=>{
                if (!evt.ctrlKey || !evt.altKey) return;
                preBreakPointStart = message.selectionStart;
                preBreakPointEnd = message.selectionEnd;
            });
            message.addEventListener('pointerup', async(evt)=>{
                if (!evt.ctrlKey || !evt.altKey || message.selectionStart != message.selectionEnd) return;
                toggleBreakpoint();
            });
            /** @type {any} */
            const resizeListener = debounce((evt) => {
                updateScrollDebounced(evt);
                if (document.activeElement == message) {
                    message.blur();
                    message.focus();
                }
            });
            window.addEventListener('resize', resizeListener);
            updateSyntaxEnabled();
            const updateSyntax = ()=>{
                if (messageSyntaxInner && syntax.checked) {
                    morphdom(
                        messageSyntaxInner,
                        `<div>${hljs.highlight(`${message.value}${message.value.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value}</div>`,
                        { childrenOnly: true },
                    );
                    updateScrollDebounced();
                }
            };
            let lastSyntaxUpdate = 0;
            const fpsTime = 1000 / 30;
            let lastMessageValue = null;
            let wasSyntax = null;
            const updateSyntaxLoop = ()=>{
                const now = Date.now();
                // fps limit
                if (now - lastSyntaxUpdate < fpsTime) return requestAnimationFrame(updateSyntaxLoop);
                // elements don't exist (yet?)
                if (!messageSyntaxInner || !message)  return requestAnimationFrame(updateSyntaxLoop);
                // elements no longer part of the document
                if (!messageSyntaxInner.closest('body')) return;
                // debugger is running
                if (this.isExecuting) {
                    lastMessageValue = null;
                    return requestAnimationFrame(updateSyntaxLoop);
                }
                // value hasn't changed
                if (wasSyntax == syntax.checked && lastMessageValue == message.value) return requestAnimationFrame(updateSyntaxLoop);
                wasSyntax = syntax.checked;
                lastSyntaxUpdate = now;
                lastMessageValue = message.value;
                updateSyntax();
                requestAnimationFrame(updateSyntaxLoop);
            };
            requestAnimationFrame(()=>updateSyntaxLoop());
            message.style.setProperty('text-shadow', 'none', 'important');
            updateWrap();
            updateTabSize();

            // context menu
            /**@type {HTMLTemplateElement}*/
            const tpl = dom.querySelector('#qr--ctxItem');
            const linkList = dom.querySelector('#qr--ctxEditor');
            const fillQrSetSelect = (/**@type {HTMLSelectElement}*/select, /**@type {QuickReplyContextLink}*/ link) => {
                [{ name: 'Select a QR set' }, ...QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase()))].forEach(qrs => {
                    const opt = document.createElement('option'); {
                        opt.value = qrs.name;
                        opt.textContent = qrs.name;
                        opt.selected = qrs.name == link.set?.name;
                        select.append(opt);
                    }
                });
            };
            const addCtxItem = (/**@type {QuickReplyContextLink}*/link, /**@type {number}*/idx) => {
                /**@type {HTMLElement} */
                // @ts-ignore
                const itemDom = tpl.content.querySelector('.qr--ctxItem').cloneNode(true); {
                    itemDom.setAttribute('data-order', String(idx));

                    /**@type {HTMLSelectElement} */
                    const select = itemDom.querySelector('.qr--set');
                    fillQrSetSelect(select, link);
                    select.addEventListener('change', () => {
                        link.set = QuickReplySet.get(select.value);
                        this.updateContext();
                    });

                    /**@type {HTMLInputElement} */
                    const chain = itemDom.querySelector('.qr--isChained');
                    chain.checked = link.isChained;
                    chain.addEventListener('click', () => {
                        link.isChained = chain.checked;
                        this.updateContext();
                    });

                    itemDom.querySelector('.qr--delete').addEventListener('click', () => {
                        itemDom.remove();
                        this.contextList.splice(this.contextList.indexOf(link), 1);
                        this.updateContext();
                    });

                    linkList.append(itemDom);
                }
            };
            [...this.contextList].forEach((link, idx) => addCtxItem(link, idx));
            dom.querySelector('#qr--ctxAdd').addEventListener('click', () => {
                const link = new QuickReplyContextLink();
                this.contextList.push(link);
                addCtxItem(link, this.contextList.length - 1);
            });
            const onContextSort = () => {
                this.contextList = Array.from(linkList.querySelectorAll('.qr--ctxItem')).map((it,idx) => {
                    const link = this.contextList[Number(it.getAttribute('data-order'))];
                    it.setAttribute('data-order', String(idx));
                    return link;
                });
                this.updateContext();
            };
            // @ts-ignore
            $(linkList).sortable({
                delay: getSortableDelay(),
                stop: () => onContextSort(),
            });

            // auto-exec
            /**@type {HTMLInputElement}*/
            const preventAutoExecute = dom.querySelector('#qr--preventAutoExecute');
            preventAutoExecute.checked = this.preventAutoExecute;
            preventAutoExecute.addEventListener('click', ()=>{
                this.preventAutoExecute = preventAutoExecute.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const isHidden = dom.querySelector('#qr--isHidden');
            isHidden.checked = this.isHidden;
            isHidden.addEventListener('click', ()=>{
                this.isHidden = isHidden.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeOnStartup = dom.querySelector('#qr--executeOnStartup');
            executeOnStartup.checked = this.executeOnStartup;
            executeOnStartup.addEventListener('click', ()=>{
                this.executeOnStartup = executeOnStartup.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeOnUser = dom.querySelector('#qr--executeOnUser');
            executeOnUser.checked = this.executeOnUser;
            executeOnUser.addEventListener('click', ()=>{
                this.executeOnUser = executeOnUser.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeOnAi = dom.querySelector('#qr--executeOnAi');
            executeOnAi.checked = this.executeOnAi;
            executeOnAi.addEventListener('click', ()=>{
                this.executeOnAi = executeOnAi.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeOnChatChange = dom.querySelector('#qr--executeOnChatChange');
            executeOnChatChange.checked = this.executeOnChatChange;
            executeOnChatChange.addEventListener('click', ()=>{
                this.executeOnChatChange = executeOnChatChange.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeOnGroupMemberDraft = dom.querySelector('#qr--executeOnGroupMemberDraft');
            executeOnGroupMemberDraft.checked = this.executeOnGroupMemberDraft;
            executeOnGroupMemberDraft.addEventListener('click', ()=>{
                this.executeOnGroupMemberDraft = executeOnGroupMemberDraft.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeBeforeGeneration = dom.querySelector('#qr--executeBeforeGeneration');
            executeBeforeGeneration.checked = this.executeBeforeGeneration;
            executeBeforeGeneration.addEventListener('click', ()=>{
                this.executeBeforeGeneration = executeBeforeGeneration.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const executeOnNewChat = dom.querySelector('#qr--executeOnNewChat');
            executeOnNewChat.checked = this.executeOnNewChat;
            executeOnNewChat.addEventListener('click', ()=>{
                this.executeOnNewChat = executeOnNewChat.checked;
                this.updateContext();
            });
            /**@type {HTMLInputElement}*/
            const automationId = dom.querySelector('#qr--automationId');
            automationId.value = this.automationId;
            automationId.addEventListener('input', () => {
                this.automationId = automationId.value;
                this.updateContext();
            });

            /**@type {HTMLElement}*/
            const executeProgress = dom.querySelector('#qr--modal-executeProgress');
            this.editorExecuteProgress = executeProgress;
            /**@type {HTMLElement}*/
            const executeErrors = dom.querySelector('#qr--modal-executeErrors');
            this.editorExecuteErrors = executeErrors;
            /**@type {HTMLElement}*/
            const executeResult = dom.querySelector('#qr--modal-executeResult');
            this.editorExecuteResult = executeResult;
            /**@type {HTMLElement}*/
            const debugState = dom.querySelector('#qr--modal-debugState');
            this.editorDebugState = debugState;
            /**@type {HTMLElement}*/
            const executeBtn = dom.querySelector('#qr--modal-execute');
            this.editorExecuteBtn = executeBtn;
            executeBtn.addEventListener('click', async()=>{
                await this.executeFromEditor();
            });
            /**@type {HTMLElement}*/
            const executeBtnPause = dom.querySelector('#qr--modal-pause');
            this.editorExecuteBtnPause = executeBtnPause;
            executeBtnPause.addEventListener('click', async()=>{
                if (this.abortController) {
                    if (this.abortController.signal.paused) {
                        this.abortController.continue('Continue button clicked');
                        this.editorExecuteProgress.classList.remove('qr--paused');
                    } else {
                        this.abortController.pause('Pause button clicked');
                        this.editorExecuteProgress.classList.add('qr--paused');
                    }
                }
            });
            /**@type {HTMLElement}*/
            const executeBtnStop = dom.querySelector('#qr--modal-stop');
            this.editorExecuteBtnStop = executeBtnStop;
            executeBtnStop.addEventListener('click', async()=>{
                this.abortController?.abort('Stop button clicked');
            });

            /**@type {HTMLTextAreaElement} */
            const inputOg = document.querySelector('#send_textarea');
            const inputMirror = dom.querySelector('#qr--modal-send_textarea');
            // @ts-ignore
            inputMirror.value = inputOg.value;
            const inputOgMo = new MutationObserver(muts=>{
                if (muts.find(it=>[...it.removedNodes].includes(inputMirror) || [...it.removedNodes].find(n=>n.contains(inputMirror)))) {
                    inputOg.removeEventListener('input', inputOgListener);
                }
            });
            inputOgMo.observe(document.body, { childList:true });
            const inputOgListener = ()=>{
                // @ts-ignore
                inputMirror.value = inputOg.value;
            };
            inputOg.addEventListener('input', inputOgListener);
            inputMirror.addEventListener('input', ()=>{
                // @ts-ignore
                inputOg.value = inputMirror.value;
            });

            /**@type {HTMLElement}*/
            const resumeBtn = dom.querySelector('#qr--modal-resume');
            resumeBtn.addEventListener('click', ()=>{
                this.debugController?.resume();
            });
            /**@type {HTMLElement}*/
            const stepBtn = dom.querySelector('#qr--modal-step');
            stepBtn.addEventListener('click', ()=>{
                this.debugController?.step();
            });
            /**@type {HTMLElement}*/
            const stepIntoBtn = dom.querySelector('#qr--modal-stepInto');
            stepIntoBtn.addEventListener('click', ()=>{
                this.debugController?.stepInto();
            });
            /**@type {HTMLElement}*/
            const stepOutBtn = dom.querySelector('#qr--modal-stepOut');
            stepOutBtn.addEventListener('click', ()=>{
                this.debugController?.stepOut();
            });
            /**@type {HTMLElement}*/
            const minimizeBtn = dom.querySelector('#qr--modal-minimize');
            minimizeBtn.addEventListener('click', ()=>{
                this.editorDom.classList.add('qr--minimized');
            });
            const maximizeBtn = dom.querySelector('#qr--modal-maximize');
            maximizeBtn.addEventListener('click', ()=>{
                this.editorDom.classList.remove('qr--minimized');
            });
            /**@type {boolean}*/
            let isResizing = false;
            let resizeStart;
            let wStart;
            /**@type {HTMLElement}*/
            const resizeHandle = dom.querySelector('#qr--resizeHandle');
            resizeHandle.addEventListener('pointerdown', (evt)=>{
                if (isResizing) return;
                isResizing = true;
                evt.preventDefault();
                resizeStart = evt.x;
                // @ts-ignore
                wStart = dom.querySelector('#qr--qrOptions').offsetWidth;
                const dragListener = debounce((evt)=>{
                    const w = wStart + resizeStart - evt.x;
                    // @ts-ignore
                    dom.querySelector('#qr--qrOptions').style.setProperty('--width', `${w}px`);
                }, 5);
                window.addEventListener('pointerup', ()=>{
                    // @ts-ignore
                    window.removeEventListener('pointermove', dragListener);
                    isResizing = false;
                }, { once:true });
                // @ts-ignore
                window.addEventListener('pointermove', dragListener);
            });

            await popupResult;

            window.removeEventListener('resize', resizeListener);
        } else {
            warn('failed to fetch qrEditor template');
        }
    }

    getEditorPosition(start, end, message = null) {
        const inputRect = this.editorMessage.getBoundingClientRect();
        const style = window.getComputedStyle(this.editorMessage);
        if (!this.clone) {
            this.clone = document.createElement('div');
            for (const key of style) {
                this.clone.style[key] = style[key];
            }
            this.clone.style.position = 'fixed';
            this.clone.style.visibility = 'hidden';
            const mo = new MutationObserver(muts=>{
                if (muts.find(it=>[...it.removedNodes].includes(this.editorMessage) || [...it.removedNodes].find(n=>n.contains(this.editorMessage)))) {
                    this.clone?.remove();
                    this.clone = null;
                }
            });
            mo.observe(document.body, { childList:true });
        }
        document.body.append(this.clone);
        this.clone.style.width = `${inputRect.width}px`;
        this.clone.style.height = `${inputRect.height}px`;
        this.clone.style.left = `${inputRect.left}px`;
        this.clone.style.top = `${inputRect.top}px`;
        this.clone.style.whiteSpace = style.whiteSpace;
        this.clone.style.tabSize = style.tabSize;
        const text = message ?? this.editorMessage.value;
        const before = text.slice(0, start);
        this.clone.textContent = before;
        const locator = document.createElement('span');
        locator.textContent = text.slice(start, end);
        this.clone.append(locator);
        this.clone.append(text.slice(end));
        this.clone.scrollTop = this.editorSyntax.scrollTop;
        this.clone.scrollLeft = this.editorSyntax.scrollLeft;
        const locatorRect = locator.getBoundingClientRect();
        const bodyRect = document.body.getBoundingClientRect();
        const location = {
            left: locatorRect.left - bodyRect.left,
            right: locatorRect.right - bodyRect.left,
            top: locatorRect.top - bodyRect.top,
            bottom: locatorRect.bottom - bodyRect.top,
        };
        // this.clone.remove();
        return location;
    }
    async executeFromEditor() {
        if (this.isExecuting) return;
        this.editorPopup.onClosing = ()=>false;
        const uuidCheck = /^[0-9a-z]{8}(-[0-9a-z]{4}){3}-[0-9a-z]{12}$/;
        const oText = this.message;
        this.isExecuting = true;
        this.editorDom.classList.add('qr--isExecuting');
        const noSyntax = this.editorDom.querySelector('#qr--modal-messageHolder').classList.contains('qr--noSyntax');
        if (noSyntax) {
            this.editorDom.querySelector('#qr--modal-messageHolder').classList.remove('qr--noSyntax');
        }
        this.editorExecuteBtn.classList.add('qr--busy');
        this.editorExecuteProgress.style.setProperty('--prog', '0');
        this.editorExecuteErrors.classList.remove('qr--hasErrors');
        this.editorExecuteResult.classList.remove('qr--hasResult');
        this.editorExecuteProgress.classList.remove('qr--error');
        this.editorExecuteProgress.classList.remove('qr--success');
        this.editorExecuteProgress.classList.remove('qr--paused');
        this.editorExecuteProgress.classList.remove('qr--aborted');
        this.editorExecuteErrors.innerHTML = '';
        this.editorExecuteResult.innerHTML = '';
        const syntax = this.editorDom.querySelector('#qr--modal-messageSyntaxInner');
        const updateScroll = (evt) => {
            let left = syntax.scrollLeft;
            let top = syntax.scrollTop;
            if (evt) {
                evt.preventDefault();
                left = syntax.scrollLeft + evt.deltaX;
                top = syntax.scrollTop + evt.deltaY;
                syntax.scrollTo({
                    behavior: 'instant',
                    left,
                    top,
                });
            }
            this.editorMessage.scrollTo({
                behavior: 'instant',
                left,
                top,
            });
        };
        const updateScrollDebounced = updateScroll;
        syntax.addEventListener('wheel', (evt)=>{
            updateScrollDebounced(evt);
        });
        // @ts-ignore
        syntax.addEventListener('scroll', (evt)=>{
            updateScrollDebounced();
        });
        try {
            this.abortController = new SlashCommandAbortController();
            this.debugController = new SlashCommandDebugController();
            this.debugController.onBreakPoint = async(closure, executor)=>{
                this.editorDom.classList.add('qr--isPaused');
                syntax.innerHTML = hljs.highlight(`${closure.fullText}${closure.fullText.slice(-1) == '\n' ? ' ' : ''}`, { language:'stscript', ignoreIllegals:true })?.value;
                this.editorMessageLabel.innerHTML = '';
                if (uuidCheck.test(closure.source)) {
                    const p0 = document.createElement('span'); {
                        p0.textContent = 'anonymous: ';
                        this.editorMessageLabel.append(p0);
                    }
                    const p1 = document.createElement('strong'); {
                        p1.textContent = executor.source.slice(0,5);
                        this.editorMessageLabel.append(p1);
                    }
                    const p2 = document.createElement('span'); {
                        p2.textContent = executor.source.slice(5, -5);
                        this.editorMessageLabel.append(p2);
                    }
                    const p3 = document.createElement('strong'); {
                        p3.textContent = executor.source.slice(-5);
                        this.editorMessageLabel.append(p3);
                    }
                } else {
                    this.editorMessageLabel.textContent = executor.source;
                }
                const source = closure.source;
                this.editorDebugState.innerHTML = '';
                let ci = -1;
                const varNames = [];
                const macroNames = [];
                /**
                 * @param {SlashCommandScope} scope
                 */
                const buildVars = (scope, isCurrent = false)=>{
                    if (!isCurrent) {
                        ci--;
                    }
                    const c = this.debugController.stack.slice(ci)[0];
                    const wrap = document.createElement('div'); {
                        wrap.classList.add('qr--scope');
                        if (isCurrent) {
                            const executor = this.debugController.cmdStack.slice(-1)[0];
                            { // named args
                                const namedTitle = document.createElement('div'); {
                                    namedTitle.classList.add('qr--title');
                                    namedTitle.textContent = `Named Args - /${executor.name}`;
                                    if (executor.command.name == 'run') {
                                        namedTitle.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`;
                                    }
                                    wrap.append(namedTitle);
                                }
                                const keys = new Set([...Object.keys(this.debugController.namedArguments ?? {}), ...(executor.namedArgumentList ?? []).map(it=>it.name)]);
                                for (const key of keys) {
                                    if (key[0] == '_') continue;
                                    const item = document.createElement('div'); {
                                        item.classList.add('qr--var');
                                        const k = document.createElement('div'); {
                                            k.classList.add('qr--key');
                                            k.textContent = key;
                                            item.append(k);
                                        }
                                        const vUnresolved = document.createElement('div'); {
                                            vUnresolved.classList.add('qr--val');
                                            vUnresolved.classList.add('qr--singleCol');
                                            const val = executor.namedArgumentList.find(it=>it.name == key)?.value;
                                            if (val instanceof SlashCommandClosure) {
                                                vUnresolved.classList.add('qr--closure');
                                                vUnresolved.title = val.rawText;
                                                vUnresolved.textContent = val.toString();
                                            } else if (val === undefined) {
                                                vUnresolved.classList.add('qr--undefined');
                                                vUnresolved.textContent = 'undefined';
                                            } else {
                                                let jsonVal;
                                                try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                                if (jsonVal && typeof jsonVal == 'object') {
                                                    vUnresolved.textContent = JSON.stringify(jsonVal, null, 2);
                                                } else {
                                                    vUnresolved.textContent = val;
                                                    vUnresolved.classList.add('qr--simple');
                                                }
                                            }
                                            item.append(vUnresolved);
                                        }
                                        const vResolved = document.createElement('div'); {
                                            vResolved.classList.add('qr--val');
                                            vResolved.classList.add('qr--singleCol');
                                            if (this.debugController.namedArguments === undefined) {
                                                vResolved.classList.add('qr--unresolved');
                                            } else {
                                                const val = this.debugController.namedArguments?.[key];
                                                if (val instanceof SlashCommandClosure) {
                                                    vResolved.classList.add('qr--closure');
                                                    vResolved.title = val.rawText;
                                                    vResolved.textContent = val.toString();
                                                } else if (val === undefined) {
                                                    vResolved.classList.add('qr--undefined');
                                                    vResolved.textContent = 'undefined';
                                                } else {
                                                    let jsonVal;
                                                    try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                                    if (jsonVal && typeof jsonVal == 'object') {
                                                        vResolved.textContent = JSON.stringify(jsonVal, null, 2);
                                                    } else {
                                                        vResolved.textContent = val;
                                                        vResolved.classList.add('qr--simple');
                                                    }
                                                }
                                            }
                                            item.append(vResolved);
                                        }
                                        wrap.append(item);
                                    }
                                }
                            }
                            { // unnamed args
                                const unnamedTitle = document.createElement('div'); {
                                    unnamedTitle.classList.add('qr--title');
                                    unnamedTitle.textContent = `Unnamed Args - /${executor.name}`;
                                    if (executor.command.name == 'run') {
                                        unnamedTitle.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`;
                                    }
                                    wrap.append(unnamedTitle);
                                }
                                let i = 0;
                                let unnamed = this.debugController.unnamedArguments ?? [];
                                if (!Array.isArray(unnamed)) unnamed = [unnamed];
                                // @ts-ignore
                                while (unnamed.length < executor.unnamedArgumentList?.length ?? 0) unnamed.push(undefined);
                                // @ts-ignore
                                unnamed = unnamed.map((it,idx)=>[executor.unnamedArgumentList?.[idx], it]);
                                // @ts-ignore
                                for (const arg of unnamed) {
                                    i++;
                                    const item = document.createElement('div'); {
                                        item.classList.add('qr--var');
                                        const k = document.createElement('div'); {
                                            k.classList.add('qr--key');
                                            k.textContent = i.toString();
                                            item.append(k);
                                        }
                                        const vUnresolved = document.createElement('div'); {
                                            vUnresolved.classList.add('qr--val');
                                            vUnresolved.classList.add('qr--singleCol');
                                            const val = arg[0]?.value;
                                            if (val instanceof SlashCommandClosure) {
                                                vUnresolved.classList.add('qr--closure');
                                                vUnresolved.title = val.rawText;
                                                vUnresolved.textContent = val.toString();
                                            } else if (val === undefined) {
                                                vUnresolved.classList.add('qr--undefined');
                                                vUnresolved.textContent = 'undefined';
                                            } else {
                                                let jsonVal;
                                                try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                                if (jsonVal && typeof jsonVal == 'object') {
                                                    vUnresolved.textContent = JSON.stringify(jsonVal, null, 2);
                                                } else {
                                                    vUnresolved.textContent = val;
                                                    vUnresolved.classList.add('qr--simple');
                                                }
                                            }
                                            item.append(vUnresolved);
                                        }
                                        const vResolved = document.createElement('div'); {
                                            vResolved.classList.add('qr--val');
                                            vResolved.classList.add('qr--singleCol');
                                            if (this.debugController.unnamedArguments === undefined) {
                                                vResolved.classList.add('qr--unresolved');
                                            } else if ((Array.isArray(this.debugController.unnamedArguments) ? this.debugController.unnamedArguments : [this.debugController.unnamedArguments]).length < i) {
                                                // do nothing
                                            } else {
                                                const val = arg[1];
                                                if (val instanceof SlashCommandClosure) {
                                                    vResolved.classList.add('qr--closure');
                                                    vResolved.title = val.rawText;
                                                    vResolved.textContent = val.toString();
                                                } else if (val === undefined) {
                                                    vResolved.classList.add('qr--undefined');
                                                    vResolved.textContent = 'undefined';
                                                } else {
                                                    let jsonVal;
                                                    try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                                    if (jsonVal && typeof jsonVal == 'object') {
                                                        vResolved.textContent = JSON.stringify(jsonVal, null, 2);
                                                    } else {
                                                        vResolved.textContent = val;
                                                        vResolved.classList.add('qr--simple');
                                                    }
                                                }
                                            }
                                            item.append(vResolved);
                                        }
                                        wrap.append(item);
                                    }
                                }
                            }
                        }
                        // current scope
                        const title = document.createElement('div'); {
                            title.classList.add('qr--title');
                            title.textContent = isCurrent ? 'Current Scope' : 'Parent Scope';
                            if (c.source == source) {
                                let hi;
                                title.addEventListener('pointerenter', ()=>{
                                    const loc = this.getEditorPosition(Math.max(0, c.executorList[0].start - 1), c.executorList.slice(-1)[0].end, c.fullText);
                                    const layer = syntax.getBoundingClientRect();
                                    hi = document.createElement('div');
                                    hi.classList.add('qr--highlight-secondary');
                                    hi.style.left = `${loc.left - layer.left}px`;
                                    hi.style.width = `${loc.right - loc.left}px`;
                                    hi.style.top = `${loc.top - layer.top + syntax.scrollTop}px`;
                                    hi.style.height = `${loc.bottom - loc.top}px`;
                                    syntax.append(hi);
                                });
                                title.addEventListener('pointerleave', ()=>hi?.remove());
                            }
                            wrap.append(title);
                        }
                        for (const key of Object.keys(scope.variables)) {
                            const isHidden = varNames.includes(key);
                            if (!isHidden) varNames.push(key);
                            const item = document.createElement('div'); {
                                item.classList.add('qr--var');
                                if (isHidden) item.classList.add('qr--isHidden');
                                const k = document.createElement('div'); {
                                    k.classList.add('qr--key');
                                    k.textContent = key;
                                    item.append(k);
                                }
                                const v = document.createElement('div'); {
                                    v.classList.add('qr--val');
                                    const val = scope.variables[key];
                                    if (val instanceof SlashCommandClosure) {
                                        v.classList.add('qr--closure');
                                        v.title = val.rawText;
                                        v.textContent = val.toString();
                                    } else if (val === undefined) {
                                        v.classList.add('qr--undefined');
                                        v.textContent = 'undefined';
                                    } else {
                                        let jsonVal;
                                        try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                        if (jsonVal && typeof jsonVal == 'object') {
                                            v.textContent = JSON.stringify(jsonVal, null, 2);
                                        } else {
                                            v.textContent = val;
                                            v.classList.add('qr--simple');
                                        }
                                    }
                                    item.append(v);
                                }
                                wrap.append(item);
                            }
                        }
                        for (const key of Object.keys(scope.macros)) {
                            const isHidden = macroNames.includes(key);
                            if (!isHidden) macroNames.push(key);
                            const item = document.createElement('div'); {
                                item.classList.add('qr--macro');
                                if (isHidden) item.classList.add('qr--isHidden');
                                const k = document.createElement('div'); {
                                    k.classList.add('qr--key');
                                    k.textContent = key;
                                    item.append(k);
                                }
                                const v = document.createElement('div'); {
                                    v.classList.add('qr--val');
                                    const val = scope.macros[key];
                                    if (val instanceof SlashCommandClosure) {
                                        v.classList.add('qr--closure');
                                        v.title = val.rawText;
                                        v.textContent = val.toString();
                                    } else if (val === undefined) {
                                        v.classList.add('qr--undefined');
                                        v.textContent = 'undefined';
                                    } else {
                                        let jsonVal;
                                        try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                        if (jsonVal && typeof jsonVal == 'object') {
                                            v.textContent = JSON.stringify(jsonVal, null, 2);
                                        } else {
                                            v.textContent = val;
                                            v.classList.add('qr--simple');
                                        }
                                    }
                                    item.append(v);
                                }
                                wrap.append(item);
                            }
                        }
                        const pipeItem = document.createElement('div'); {
                            pipeItem.classList.add('qr--pipe');
                            const k = document.createElement('div'); {
                                k.classList.add('qr--key');
                                k.textContent = 'pipe';
                                pipeItem.append(k);
                            }
                            const v = document.createElement('div'); {
                                v.classList.add('qr--val');
                                const val = scope.pipe;
                                if (val instanceof SlashCommandClosure) {
                                    v.classList.add('qr--closure');
                                    v.title = val.rawText;
                                    v.textContent = val.toString();
                                } else if (val === undefined) {
                                    v.classList.add('qr--undefined');
                                    v.textContent = 'undefined';
                                } else {
                                    let jsonVal;
                                    try { jsonVal = JSON.parse(val); } catch { /* empty */ }
                                    if (jsonVal && typeof jsonVal == 'object') {
                                        v.textContent = JSON.stringify(jsonVal, null, 2);
                                    } else {
                                        v.textContent = val;
                                        v.classList.add('qr--simple');
                                    }
                                }
                                pipeItem.append(v);
                            }
                            wrap.append(pipeItem);
                        }
                        if (scope.parent) {
                            wrap.append(buildVars(scope.parent));
                        }
                    }
                    return wrap;
                };
                const buildStack = ()=>{
                    const wrap = document.createElement('div'); {
                        wrap.classList.add('qr--stack');
                        const title = document.createElement('div'); {
                            title.classList.add('qr--title');
                            title.textContent = 'Call Stack';
                            wrap.append(title);
                        }
                        let ei = -1;
                        for (const executor of this.debugController.cmdStack.toReversed()) {
                            ei++;
                            const c = this.debugController.stack.toReversed()[ei];
                            const item = document.createElement('div'); {
                                item.classList.add('qr--item');
                                if (executor.source == source) {
                                    let hi;
                                    item.addEventListener('pointerenter', ()=>{
                                        const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end, c.fullText);
                                        const layer = syntax.getBoundingClientRect();
                                        hi = document.createElement('div');
                                        hi.classList.add('qr--highlight-secondary');
                                        hi.style.left = `${loc.left - layer.left}px`;
                                        hi.style.width = `${loc.right - loc.left}px`;
                                        hi.style.top = `${loc.top - layer.top + syntax.scrollTop}px`;
                                        hi.style.height = `${loc.bottom - loc.top}px`;
                                        syntax.append(hi);
                                    });
                                    item.addEventListener('pointerleave', ()=>hi?.remove());
                                }
                                const cmd = document.createElement('div'); {
                                    cmd.classList.add('qr--cmd');
                                    cmd.textContent = `/${executor.name}`;
                                    if (executor.command.name == 'run') {
                                        cmd.textContent += `${(executor.name == ':' ? '' : ' ')}${executor.unnamedArgumentList[0]?.value}`;
                                    }
                                    item.append(cmd);
                                }
                                const src = document.createElement('div'); {
                                    src.classList.add('qr--source');
                                    const line = closure.fullText.slice(0, executor.start).split('\n').length;
                                    if (uuidCheck.test(executor.source)) {
                                        const p1 = document.createElement('span'); {
                                            p1.classList.add('qr--fixed');
                                            p1.textContent = executor.source.slice(0,5);
                                            src.append(p1);
                                        }
                                        const p2 = document.createElement('span'); {
                                            p2.classList.add('qr--truncated');
                                            p2.textContent = '…';
                                            src.append(p2);
                                        }
                                        const p3 = document.createElement('span'); {
                                            p3.classList.add('qr--fixed');
                                            p3.textContent = `${executor.source.slice(-5)}:${line}`;
                                            src.append(p3);
                                        }
                                        src.title = `anonymous: ${executor.source}`;
                                    } else {
                                        src.textContent = `${executor.source}:${line}`;
                                    }
                                    item.append(src);
                                }
                                wrap.append(item);
                            }
                        }
                    }
                    return wrap;
                };
                this.editorDebugState.append(buildVars(closure.scope, true));
                this.editorDebugState.append(buildStack());
                this.editorDebugState.classList.add('qr--active');
                const loc = this.getEditorPosition(Math.max(0, executor.start - 1), executor.end, closure.fullText);
                const layer = syntax.getBoundingClientRect();
                const hi = document.createElement('div');
                hi.classList.add('qr--highlight');
                if (this.debugController.namedArguments === undefined) {
                    hi.classList.add('qr--unresolved');
                }
                hi.style.left = `${loc.left - layer.left}px`;
                hi.style.width = `${loc.right - loc.left}px`;
                hi.style.top = `${loc.top - layer.top + syntax.scrollTop}px`;
                hi.style.height = `${loc.bottom - loc.top}px`;
                syntax.append(hi);
                const isStepping = await this.debugController.awaitContinue();
                hi.remove();
                this.editorDebugState.textContent = '';
                this.editorDebugState.classList.remove('qr--active');
                this.editorDom.classList.remove('qr--isPaused');
                return isStepping;
            };
            const result = await this.onDebug(this);
            if (this.abortController?.signal?.aborted) {
                this.editorExecuteProgress.classList.add('qr--aborted');
            } else {
                this.editorExecuteResult.textContent = result?.toString();
                this.editorExecuteResult.classList.add('qr--hasResult');
                this.editorExecuteProgress.classList.add('qr--success');
            }
            this.editorExecuteProgress.classList.remove('qr--paused');
        } catch (ex) {
            this.editorExecuteErrors.classList.add('qr--hasErrors');
            this.editorExecuteProgress.classList.add('qr--error');
            this.editorExecuteProgress.classList.remove('qr--paused');
            if (ex instanceof SlashCommandParserError) {
                this.editorExecuteErrors.innerHTML = `
                    <div>${ex.message}</div>
                    <div>Line: ${ex.line} Column: ${ex.column}</div>
                    <pre style="text-align:left;">${ex.hint}</pre>
                `;
            } else {
                this.editorExecuteErrors.innerHTML = `
                    <div>${ex.message}</div>
                `;
            }
        }
        if (noSyntax) {
            this.editorDom.querySelector('#qr--modal-messageHolder').classList.add('qr--noSyntax');
        }
        this.editorMessageLabel.innerHTML = '';
        this.editorMessageLabel.textContent = 'Message / Command: ';
        this.editorMessage.value = oText;
        this.editorMessage.dispatchEvent(new Event('input', { bubbles:true }));
        this.editorExecutePromise = null;
        this.editorExecuteBtn.classList.remove('qr--busy');
        this.editorDom.classList.remove('qr--isExecuting');
        this.isExecuting = false;
        this.editorPopup.onClosing = null;
    }

    updateEditorProgress(done, total) {
        this.editorExecuteProgress.style.setProperty('--prog', `${done / total * 100}`);
    }




    delete() {
        if (this.onDelete) {
            this.unrender();
            this.unrenderSettings();
            this.onDelete(this);
        }
    }

    /**
     * @param {string} value
     */
    updateMessage(value) {
        if (this.onUpdate) {
            if (this.settingsDomMessage && this.settingsDomMessage.value != value) {
                this.settingsDomMessage.value = value;
            }
            this.message = value;
            this.updateRender();
            this.onUpdate(this);
        }
    }

    /**
     * @param {string} value
     */
    updateIcon(value) {
        if (this.onUpdate) {
            if (value === null) return;
            if (this.settingsDomIcon) {
                if (this.icon != value) {
                    if (value == '') {
                        if (this.icon) {
                            this.settingsDomIcon.classList.remove(this.icon);
                        }
                        this.settingsDomIcon.textContent = '…';
                        this.settingsDomIcon.classList.remove('fa-solid');
                    } else {
                        if (this.icon) {
                            this.settingsDomIcon.classList.remove(this.icon);
                        } else {
                            this.settingsDomIcon.classList.add('fa-solid');
                        }
                        this.settingsDomIcon.classList.add(value);
                    }
                }
            }
            this.icon = value;
            this.updateRender();
            this.onUpdate(this);
        }
    }

    /**
     * @param {boolean} value
     */
    updateShowLabel(value) {
        if (this.onUpdate) {
            this.showLabel = value;
            this.updateRender();
            this.onUpdate(this);
        }
    }

    /**
     * @param {string} value
     */
    updateLabel(value) {
        if (this.onUpdate) {
            if (this.settingsDomLabel && this.settingsDomLabel.value != value) {
                this.settingsDomLabel.value = value;
            }
            this.label = value;
            this.updateRender();
            this.onUpdate(this);
        }
    }

    /**
     * @param {string} value
     */
    updateTitle(value) {
        if (this.onUpdate) {
            this.title = value;
            this.updateRender();
            this.onUpdate(this);
        }
    }

    updateContext() {
        if (this.onUpdate) {
            this.updateRender();
            this.onUpdate(this);
        }
    }
    addContextLink(cl) {
        this.contextList.push(cl);
        this.updateContext();
    }
    removeContextLink(setName) {
        const idx = this.contextList.findIndex(it=>it.set.name == setName);
        if (idx > -1) {
            this.contextList.splice(idx, 1);
            this.updateContext();
        }
    }
    clearContextLinks() {
        if (this.contextList.length) {
            this.contextList.splice(0, this.contextList.length);
            this.updateContext();
        }
    }


    async execute(args = {}, isEditor = false, isRun = false, options = {}) {
        if (this.message?.length > 0 && this.onExecute) {
            const scope = new SlashCommandScope();
            for (const key of Object.keys(args)) {
                if (key[0] == '_') continue;
                if (key == 'isAutoExecute') continue;
                scope.setMacro(`arg::${key}`, args[key]);
            }
            scope.setMacro('arg::*', '');
            if (isEditor) {
                this.abortController = new SlashCommandAbortController();
            }
            return await this.onExecute(this, {
                message: this.message,
                isAutoExecute: args.isAutoExecute ?? false,
                isEditor,
                isRun,
                scope,
                executionOptions: options,
            });
        }
    }




    toJSON() {
        return {
            id: this.id,
            icon: this.icon,
            showLabel: this.showLabel,
            label: this.label,
            title: this.title,
            message: this.message,
            contextList: this.contextList,
            preventAutoExecute: this.preventAutoExecute,
            isHidden: this.isHidden,
            executeOnStartup: this.executeOnStartup,
            executeOnUser: this.executeOnUser,
            executeOnAi: this.executeOnAi,
            executeOnChatChange: this.executeOnChatChange,
            executeOnGroupMemberDraft: this.executeOnGroupMemberDraft,
            executeOnNewChat: this.executeOnNewChat,
            executeBeforeGeneration: this.executeBeforeGeneration,
            automationId: this.automationId,
        };
    }
}
