import { CodeEditor, Selection } from "rkgk/code-editor.js";
import { SaveData } from "rkgk/framework.js";
import { builtInPresets } from "rkgk/brush-box.js";

export class BrushEditor extends HTMLElement {
    saveData = new SaveData("brushEditor");

    constructor() {
        super();
    }

    connectedCallback() {
        this.classList.add("rkgk-panel");

        this.saveData.attachToElement(this);

        const defaultBrush = builtInPresets[0];

        this.nameEditor = this.appendChild(document.createElement("input"));
        this.nameEditor.value = this.saveData.get("name", defaultBrush.name);
        this.nameEditor.classList.add("name");
        this.nameEditor.addEventListener("input", () => {
            this.saveData.set("name", this.nameEditor.value);

            this.dispatchEvent(
                Object.assign(
                    new Event(".nameChanged", {
                        newName: this.nameEditor.value,
                    }),
                ),
            );
        });

        this.codeEditor = this.appendChild(
            new CodeEditor([
                {
                    className: "layer-syntax",
                    render: (code, element) => this.#renderSyntax(code, element),
                },
                {
                    className: "layer-error-squiggles",
                    render: (code, element) => this.#renderErrorSquiggles(code, element),
                },
            ]),
        );
        this.codeEditor.setCode(
            // NOTE(localStorage): Migration from old storage key.
            this.saveData.get("code") ??
                localStorage.getItem("rkgk.brushEditor.code") ??
                defaultBrush.code,
        );
        this.codeEditor.addEventListener(".codeChanged", (event) => {
            this.saveData.set("code", event.newCode);

            this.dispatchEvent(
                Object.assign(new Event(".codeChanged"), {
                    newCode: event.newCode,
                }),
            );
        });

        this.errorHeader = this.appendChild(document.createElement("h1"));
        this.errorHeader.classList.add("error-header");

        this.errorArea = this.appendChild(document.createElement("pre"));
        this.errorArea.classList.add("errors");

        // NOTE(localStorage): Migration from old storage key.
        localStorage.removeItem("rkgk.brushEditor.code");
    }

    get name() {
        return this.nameEditor.value;
    }

    setName(newName) {
        this.nameEditor.value = newName;
        this.saveData.set("name", this.nameEditor.value);
    }

    get code() {
        return this.codeEditor.code;
    }

    setCode(newCode) {
        this.codeEditor.setCode(newCode);
    }

    resetErrors() {
        this.errorHeader.textContent = "";
        this.errorArea.textContent = "";
    }

    renderHakuResult(phase, result) {
        this.resetErrors();
        this.errorSquiggles = null;

        if (result.status != "error") {
            // We need to request a rebuild if there's no error to remove any squiggles that may be
            // left over from the error state.
            this.codeEditor.renderLayer("layer-error-squiggles");
            return;
        }

        this.errorHeader.textContent = `${phase} failed`;

        if (result.errorKind == "diagnostics") {
            this.codeEditor.rebuildLineMap();
            this.errorSquiggles = this.#computeErrorSquiggles(
                this.codeEditor.lineMap,
                result.diagnostics,
            );
            this.codeEditor.renderLayer("layer-error-squiggles");

            this.errorArea.textContent = result.diagnostics
                .map(
                    (diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`,
                )
                .join("\n");
        } else if (result.errorKind == "plain") {
            this.errorHeader.textContent = result.message;
        } else if (result.errorKind == "exception") {
            let renderer = new ErrorException(result);
            let squiggles = renderer.prepareSquiggles();

            this.codeEditor.rebuildLineMap();
            this.errorSquiggles = this.#computeErrorSquiggles(
                this.codeEditor.lineMap,
                squiggles.diagnostics,
            );
            this.codeEditor.renderLayer("layer-error-squiggles");

            renderer.addEventListener(".functionNameMouseEnter", (event) => {
                squiggles.highlightSegment(event.frameIndex);
            });

            renderer.addEventListener(".functionNameMouseLeave", () => {
                squiggles.highlightSegment(null);
            });

            renderer.addEventListener(".functionNameClick", (event) => {
                let span = event.frame.span;
                this.codeEditor.textArea.focus();
                this.codeEditor.setSelection(new Selection(span.start, span.end));
            });

            this.errorArea.replaceChildren();
            this.errorArea.appendChild(renderer);
        } else {
            console.warn(`unknown error kind: ${result.errorKind}`);
            this.errorHeader.textContent = "(unknown error kind)";
        }
    }

    #computeErrorSquiggles(lineMap, diagnostics) {
        // This is an extremely inefficient algorithm.
        // If we had better control of drawing (I'm talking: letter per letter, with a shader!)
        // this could be done lot more efficiently. But alas, HTML giveth, HTML taketh away.

        // The first step is to determine per-line spans.
        // Since we render squiggles by line, it makes the most sense to split the work up to a
        // line by line basis.

        let rawLineSpans = new Map();

        for (let diagnostic of diagnostics) {
            let firstLine = lineMap.lineIndexAt(diagnostic.start);
            let lastLine = lineMap.lineIndexAt(diagnostic.end);
            for (let i = firstLine; i <= lastLine; ++i) {
                let bounds = lineMap.get(i);
                let start = i == firstLine ? diagnostic.start - bounds.start : 0;
                let end = i == lastLine ? diagnostic.end - bounds.start : bounds.end;

                if (!rawLineSpans.has(i)) {
                    rawLineSpans.set(i, []);
                }
                let onThisLine = rawLineSpans.get(i);
                onThisLine.push({ start, end, diagnostic });
            }
        }

        // Once we have the diagnostics subdivided per line, it's time to determine the _boundaries_
        // where diagnostics need to appear.
        // Later we will turn those boundaries into spans, and assign them appropriate classes.

        let segmentedLines = new Map();
        for (let [line, spans] of rawLineSpans) {
            let lineBounds = lineMap.get(line);

            let spanBorderSet = new Set([0]);
            for (let { start, end } of spans) {
                spanBorderSet.add(start);
                spanBorderSet.add(end);
            }
            spanBorderSet.add(lineBounds.end - lineBounds.start);
            let spanBorders = Array.from(spanBorderSet).sort((a, b) => a - b);

            let segments = [];
            let previous = 0;
            for (let i = 1; i < spanBorders.length; ++i) {
                segments.push({ start: previous, end: spanBorders[i], diagnostics: [] });
                previous = spanBorders[i];
            }

            for (let span of spans) {
                for (let segment of segments) {
                    if (segment.start >= span.start && segment.end <= span.end) {
                        segment.diagnostics.push(span.diagnostic);
                    }
                }
            }

            segmentedLines.set(line, segments);
        }

        return segmentedLines;
    }

    #renderErrorSquiggles(lines, element) {
        if (this.errorSquiggles == null) return;

        for (let i = 0; i < lines.lineBounds.length; ++i) {
            let lineBounds = lines.lineBounds[i];
            let lineElement = element.appendChild(document.createElement("span"));
            lineElement.classList.add("line");

            let segments = this.errorSquiggles.get(i);
            if (segments != null) {
                for (let segment of segments) {
                    let text = (lineBounds.substring + " ").substring(segment.start, segment.end);
                    if (segment.diagnostics.length == 0) {
                        lineElement.append(text);
                    } else {
                        let spanElement = lineElement.appendChild(document.createElement("span"));
                        spanElement.classList.add("squiggle", "squiggle-error");
                        spanElement.textContent = text;

                        for (let diagnostic of segment.diagnostics) {
                            diagnostic.customizeSegment?.(diagnostic, spanElement);
                        }
                    }
                }
            } else {
                lineElement.textContent = lineBounds.substring;
            }
        }
    }

    #renderSyntax(lines, element) {
        // TODO: Syntax highlighting.
        // Right now we just render a layer of black text to have the text visible in the code editor.
        element.textContent = lines.string;
    }
}

customElements.define("rkgk-brush-editor", BrushEditor);

class ErrorException extends HTMLElement {
    constructor(result) {
        super();
        this.result = result;
    }

    prepareSquiggles() {
        let diagnostics = [];
        let segments = [];

        const customizeSegment = (diagnostic, segment) => {
            // The control flow here is kind of spaghetti, but basically this fills out `segments`
            // as the squiggles are being rendered.
            // Once squiggles are rendered, you're free to call highlightSegment(i).
            segments.push({
                frameIndex: diagnostic.frameIndex,
                colorIndex: diagnostic.colorIndex,
                segment,
            });
            if (diagnostic.colorIndex != null) {
                segment.classList.add("squiggle-colored");
                segment.style.setProperty("--color-index", diagnostic.colorIndex);
            }
        };

        // Iterate in reverse to let uppermost stack frames "win" and overwrite bottommost stack
        // frames' colouring.
        for (let i = this.result.stackTrace.length - 1; i >= 0; i--) {
            let frame = this.result.stackTrace[i];
            if (frame.span != null) {
                diagnostics.push({
                    start: frame.span.start,
                    end: frame.span.end,

                    customizeSegment,
                    frameIndex: i,
                    colorIndex: i / this.result.stackTrace.length,
                });
            }
        }

        return {
            diagnostics,
            highlightSegment(frameIndex) {
                let justHighlighted = new Set();
                for (let entry of segments.values()) {
                    let segment = entry.segment;
                    if (entry.frameIndex == frameIndex) {
                        segment.classList.add("squiggle-highlighted");
                        // This logic exists so that a specific segment can display on top of
                        // another one if it's highlighted.
                        segment.style.setProperty("--highlight-color-index", entry.colorIndex);
                        justHighlighted.add(segment);
                    } else if (!justHighlighted.has(segment)) {
                        segment.classList.remove("squiggle-highlighted");
                    }
                }
            },
        };
    }

    connectedCallback() {
        let message = this.appendChild(document.createElement("p"));
        message.classList.add("message");
        message.textContent = this.result.message;

        let stackTrace = this.appendChild(document.createElement("ol"));
        stackTrace.classList.add("stack-trace");
        for (let i = 0; i < this.result.stackTrace.length; ++i) {
            let frame = this.result.stackTrace[i];
            let line = stackTrace.appendChild(document.createElement("li"));

            let [_, name, lambdasString] = frame.functionName.match(/([^λ]+)(λ*)/);

            let inCaption = line.appendChild(document.createElement("span"));
            inCaption.classList.add("in");
            inCaption.innerText = "in ";

            let functionName = line.appendChild(document.createElement("button"));
            functionName.classList.add("function-name");
            functionName.disabled = true;
            functionName.innerText = name;
            if (name == "(brush)") {
                functionName.classList.add("function-name-brush");
                functionName.title = "Top-level brush code";
            }
            let lambdas = functionName.appendChild(document.createElement("abbr"));
            lambdas.classList.add("lambdas");
            lambdas.textContent = lambdasString;
            lambdas.title =
                "λ - unnamed function\n" +
                "Symbolizes functions that were not given a name.\n" +
                "Each additional λ is one level of nesting inside another function.";

            if (frame.isSystem) {
                line.append(" ");
                let system = line.appendChild(document.createElement("abbr"));
                system.classList.add("system");
                system.innerText = "(built-in)";
                system.title =
                    "This function is built into rakugaki and does not exist in your brush's source code, so you can't see it in the editor.";
            }

            if (frame.span != null) {
                functionName.disabled = false;
                functionName.classList.add("source-link");
                functionName.style.setProperty("--color-index", i / this.result.stackTrace.length);

                functionName.addEventListener("mouseenter", () => {
                    this.dispatchEvent(
                        Object.assign(new Event(".functionNameMouseEnter"), {
                            frameIndex: i,
                            frame,
                        }),
                    );
                });

                functionName.addEventListener("mouseleave", () => {
                    this.dispatchEvent(
                        Object.assign(new Event(".functionNameMouseLeave"), {
                            frameIndex: i,
                            frame,
                        }),
                    );
                });

                functionName.addEventListener("click", () => {
                    this.dispatchEvent(
                        Object.assign(new Event(".functionNameClick"), {
                            frameIndex: i,
                            frame,
                        }),
                    );
                });
            }
        }
    }
}

customElements.define("rkgk-brush-editor-error-exception", ErrorException);
