export class CodeEditor extends HTMLElement {
    constructor(layers) {
        super();

        this.layers = layers;
    }

    connectedCallback() {
        this.indentWidth = 2;

        this.layerGutter = this.appendChild(document.createElement("pre"));
        this.layerGutter.classList.add("layer", "layer-gutter");

        this.userLayers = [];
        for (let layer of this.layers) {
            let element = this.appendChild(document.createElement("pre"));
            element.classList.add("layer", layer.className);
            this.userLayers.push({
                def: layer,
                element,
            });
        }

        this.textArea = this.appendChild(document.createElement("textarea"));
        this.textArea.spellcheck = false;
        this.textArea.rows = 1;

        this.keyShortcuts = {
            Backspace: () => this.backspace(),

            Enter: () => this.enter(),

            Tab: () => this.tab(),
            "Shift-Tab": () => this.decreaseIndent(),
            "Ctrl-[": () => this.decreaseIndent(),
            "Ctrl-]": () => this.increaseIndent(),

            "Ctrl-z": () => this.undo(),
            "Ctrl-Shift-z": () => this.redo(),
            "Ctrl-y": () => this.redo(),
        };

        this.undoMergeTimeout = 300;
        this.undoHistory = [];
        this.undoHistoryTop = 0;

        this.#textAreaAutoSizingBehaviour();
        this.#keyShortcutBehaviours();

        this.#renderLayers();
    }

    get code() {
        return this.textArea.value;
    }

    #codeChanged() {
        this.#resizeTextArea();
        this.#renderLayers();
        this.dispatchEvent(
            Object.assign(new Event(".codeChanged"), {
                newCode: this.code,
            }),
        );
    }

    setCode(value) {
        this.textArea.value = value;
        this.#codeChanged();
    }

    // Resizing the text area

    #textAreaAutoSizingBehaviour() {
        this.textArea.addEventListener("input", () => {
            this.#codeChanged();
        });
        this.#resizeTextArea();
        document.fonts.addEventListener("loadingdone", () => this.#resizeTextArea());
        new ResizeObserver(() => this.#resizeTextArea()).observe(this.textArea);
    }

    #resizeTextArea() {
        this.textArea.style.height = "";
        this.textArea.style.height = `${this.textArea.scrollHeight}px`;
    }

    // Layers

    rebuildLineMap() {
        this.lineMap = new LineMap(this.code);
    }

    #renderLayers() {
        this.rebuildLineMap();

        this.#renderGutter();
        for (let userLayer of this.userLayers) {
            userLayer.element.replaceChildren();
            userLayer.def.render(this.lineMap, userLayer.element);
        }
    }

    #renderGutter() {
        this.layerGutter.replaceChildren();

        for (let lineBounds of this.lineMap.lineBounds) {
            let lineElement = this.layerGutter.appendChild(document.createElement("span"));
            lineElement.classList.add("line");
            lineElement.textContent = lineBounds.substring;
        }
    }

    renderLayer(className) {
        for (let userLayer of this.userLayers) {
            if (userLayer.def.className == className) {
                userLayer.element.replaceChildren();
                userLayer.def.render(this.lineMap, userLayer.element);
            }
        }
    }

    // Text editing

    #keyShortcutBehaviours() {
        this.textArea.addEventListener("keydown", (event) => {
            let keyComponents = [];
            if (event.ctrlKey) keyComponents.push("Ctrl");
            if (event.altKey) keyComponents.push("Alt");
            if (event.shiftKey) keyComponents.push("Shift");
            keyComponents.push(event.key);

            let keyChord = keyComponents.join("-");

            let shortcut = this.keyShortcuts[keyChord];
            if (shortcut != null) {
                shortcut();
                event.preventDefault();
            }
        });

        this.textArea.addEventListener("beforeinput", () => {
            this.pushHistory({ allowMerge: true });
        });
    }

    getSelection() {
        // NOTE: We only support one selection, because multiple selections are only
        // supported by Firefox.

        if (document.activeElement != this.textArea) return null;

        if (this.textArea.selectionDirection == "forward") {
            return new Selection(this.textArea.selectionStart, this.textArea.selectionEnd);
        } else {
            return new Selection(this.textArea.selectionEnd, this.textArea.selectionStart);
        }
    }

    setSelection(selection) {
        this.textArea.selectionDirection =
            selection.anchor < selection.cursor ? "forward" : "backward";
        this.textArea.selectionStart = selection.start;
        this.textArea.selectionEnd = selection.end;
    }

    replace(selection, text) {
        let left = this.code.substring(0, selection.start);
        let right = this.code.substring(selection.end);
        this.setCode(left + text + right);
    }

    pushHistory({ allowMerge }) {
        this.undoHistory.splice(this.undoHistoryTop);

        if (allowMerge && this.undoHistory.length > 1) {
            let last = this.undoHistory[this.undoHistory.length - 1];
            let elapsed = performance.now() - last.time;
            if (elapsed < this.undoMergeTimeout) {
                last.time = performance.now();
                last.code = this.code;
                last.selection = this.getSelection();
                return;
            }
        }

        this.undoHistory.push({
            time: performance.now(),
            code: this.code,
            selection: this.getSelection(),
        });
        this.undoHistoryTop += 1;
    }

    popHistory() {
        let entry = this.undoHistory[this.undoHistoryTop - 1];
        if (entry == null) return null;

        this.undoHistoryTop -= 1;
        this.setCode(entry.code);
        this.setSelection(entry.selection);

        return entry;
    }

    insertTab(selection) {
        let positionInLine = getPositionInLine(this.code, selection.cursor);
        let positionInTab = positionInLine % this.indentWidth;
        let spaceCount = this.indentWidth - positionInTab;
        this.replace(selection, " ".repeat(spaceCount));
        selection.advance(this.code, spaceCount);
    }

    indent(selection) {
        let start = getLineStart(this.code, selection.start);
        let end = getLineEnd(this.code, selection.end);

        let indent = " ".repeat(this.indentWidth);
        let indented = this.code.substring(start, end).split(/^/m);
        for (let i = 0; i < indented.length; ++i) {
            indented[i] = indent + indented[i];
        }
        this.replace(new Selection(start, end), indented.join(""));

        if (selection.anchor < selection.cursor) {
            selection.anchor += this.indentWidth;
            selection.cursor += this.indentWidth * indented.length;
        } else {
            selection.anchor += this.indentWidth * indented.length;
            selection.cursor += this.indentWidth;
        }
    }

    unindent(selection) {
        let start = getLineStart(this.code, selection.start);
        let end = getLineEnd(this.code, selection.end);

        let indent = " ".repeat(this.indentWidth);
        let unindented = this.code.substring(start, end).split(/^/m);
        for (let i = 0; i < unindented.length; ++i) {
            if (unindented[i].startsWith(indent)) {
                unindented[i] = unindented[i].substring(this.indentWidth);
            }
        }
        this.replace(new Selection(start, end), unindented.join(""));

        if (selection.anchor < selection.cursor) {
            selection.anchor -= this.indentWidth;
            selection.cursor -= this.indentWidth * unindented.length;
        } else {
            selection.anchor -= this.indentWidth * unindented.length;
            selection.cursor -= this.indentWidth;
        }
    }

    backspace() {
        this.pushHistory({ allowMerge: true });

        let selection = this.getSelection();
        selection.collapse();

        let start = getLineStart(this.code, selection.start);
        let leading = this.code.substring(start, selection.cursor);
        let isAtIndent = /^ +$/.test(leading);
        let positionInLine = selection.cursor - start;
        let charactersToRemove = isAtIndent
            ? this.indentWidth - (positionInLine % this.indentWidth)
            : 1;

        selection.cursor -= charactersToRemove;
        selection.clampCursor(this.code);
        this.replace(selection, "");
        selection.collapse();

        this.setSelection(selection);
    }

    enter() {
        this.pushHistory({ allowMerge: false });

        let selection = this.getSelection();

        let start = getLineStart(this.code, selection.start);
        let indent = countSpaces(this.code, start);

        let newLine = "\n" + " ".repeat(indent);
        this.replace(selection, newLine);
        selection.set(this.code, selection.start + newLine.length);

        this.setSelection(selection);
    }

    tab() {
        this.pushHistory({ allowMerge: false });

        let selection = this.getSelection();
        if (selection == null) return;

        if (selection.anchor == selection.cursor) {
            this.insertTab(selection);
        } else {
            this.indent(selection);
        }

        this.setSelection(selection);
    }

    increaseIndent() {
        this.pushHistory({ allowMerge: false });

        let selection = this.getSelection();
        this.indent(selection);
        this.setSelection(selection);
    }

    decreaseIndent() {
        this.pushHistory({ allowMerge: false });

        let selection = this.getSelection();
        this.unindent(selection);
        this.setSelection(selection);
    }

    undo() {
        let code = this.code;
        let popped = this.popHistory();
        if (popped != null && popped.code == code) {
            this.popHistory();
        }
    }

    redo() {
        let entry = this.undoHistory[this.undoHistoryTop];
        if (entry == null) return;

        this.undoHistoryTop += 1;
        this.setCode(entry.code);
        this.setSelection(entry.selection);
    }
}

customElements.define("rkgk-code-editor", CodeEditor);

export class Selection {
    constructor(anchor, cursor) {
        this.anchor = anchor;
        this.cursor = cursor;
    }

    get start() {
        return Math.min(this.anchor, this.cursor);
    }

    get end() {
        return Math.max(this.anchor, this.cursor);
    }

    clampCursor(text) {
        this.cursor = Math.max(0, Math.min(this.cursor, text.length));
    }

    collapse() {
        this.anchor = this.cursor;
    }

    set(text, n) {
        this.cursor = n;
        this.clampCursor(text);
        this.collapse();
    }

    advance(text, n) {
        this.cursor += n;
        this.clampCursor(text);
        this.collapse();
    }
}

export function getLineStart(string, position) {
    do {
        --position;
    } while (string.charAt(position) != "\n" && position > 0);
    if (string.charAt(position) == "\n") ++position;
    return position;
}

export function getLineEnd(string, position) {
    while (string.charAt(position) != "\n" && position < string.length) ++position;
    return position + 1;
}

export function getPositionInLine(string, position) {
    return position - getLineStart(string, position);
}

export function countSpaces(string, position) {
    let count = 0;
    while (string.charAt(position) == " ") {
        ++count;
        ++position;
    }
    return count;
}

export class LineMap {
    constructor(string) {
        // This simplifies the algorithm below a bit.
        string += "\n";

        this.string = string;
        this.lineBounds = [];

        let start = 0;
        for (let i = 0; i < string.length; ++i) {
            if (string.charAt(i) == "\n") {
                let substring = string.substring(start, i);
                this.lineBounds.push({ start, end: i, substring });
                start = i + 1;
            }
        }
        if (start < string.length) {
            this.lineBounds.push({ start, end: string.length, substring: string.substring(start) });
        }
    }

    get(lineIndex) {
        return this.lineBounds[lineIndex];
    }

    lineIndexAt(position) {
        // Ported from the Rust 1.81 standard library binary search.
        // I was too lazy to come up with the algorithm myself. Sorry to disappoint.

        let size = this.lineBounds.length;
        let left = 0;
        let right = size;
        while (left < right) {
            let mid = (left + size / 2) | 0;

            let isLess = this.lineBounds[mid].start < position;
            let isEqual = this.lineBounds[mid].start == position;
            left = isLess && !isEqual ? mid + 1 : left;
            right = !isLess && !isEqual ? mid : right;
            if (isEqual) {
                return mid;
            }

            size = right - left;
        }

        return left - 1;
    }
}