import { syntaxTree } from "@codemirror/language";
import { EditorSelection, EditorState } from "@codemirror/state";
import { type Decoration, type DecorationSet, EditorView } from "@codemirror/view";
import {
    DEFAULT_MARKDOWN_MODE,
    type MarkdownEditMode,
    markdownModeField,
    setMarkdownMode,
} from "@src/lib/markdown";
import { Callout } from "@src/lib/markdown/editor/callout";
import { markdownDecorations } from "@src/lib/markdown/editor/decorations";
import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
import { CalloutHeaderWidget } from "@src/lib/markdown/editor/widgets/callout-header-widget";
import { CheckboxWidget } from "@src/lib/markdown/editor/widgets/checkbox-widget";
import {
    CodeFenceCopyWidget,
    CodeFenceLanguageMenuWidget,
} from "@src/lib/markdown/editor/widgets/code-fence-widget";
import { CustomCodeBlockWidget } from "@src/lib/markdown/editor/widgets/custom-code-block-widget";
import { HeadingFoldWidget } from "@src/lib/markdown/editor/widgets/heading-fold-widget";
import { ListBulletWidget } from "@src/lib/markdown/editor/widgets/list-bullet-widget";
import { createWidgetStore } from "@src/lib/markdown/editor/widgets/store";
import { TableWidget } from "@src/lib/markdown/editor/widgets/table-widget";
import type { CodeRenderer } from "@src/lib/markdown/types";
import { describe, expect, it } from "vitest";
import { md } from "../../_setup/markdown-classes";

interface DecorationDescriptor {
    from: number;
    to: number;
    spec: ReturnType<Decoration["spec"]> | Decoration["spec"];
}

function createView(
    doc: string,
    mode: MarkdownEditMode = DEFAULT_MARKDOWN_MODE,
    options: { codeRenderers?: Record<string, CodeRenderer> } = {},
): EditorView {
    const widgetStore = createWidgetStore();
    const state = EditorState.create({
        doc,
        extensions: [
            createMarkdownLanguage({ extensions: [Callout] }),
            markdownModeField,
            markdownDecorations({ widgetStore, codeRenderers: options.codeRenderers }),
        ],
    });

    const parent = document.createElement("div");
    document.body.appendChild(parent);

    const view = new EditorView({
        state,
        parent,
    });

    if (mode !== DEFAULT_MARKDOWN_MODE) {
        view.dispatch({ effects: setMarkdownMode.of(mode) });
    }

    view.focus();
    view.dispatch({ effects: setMarkdownEditorFocused.of(true) });

    return view;
}

function getDecorations(view: EditorView): DecorationDescriptor[] {
    const out: DecorationDescriptor[] = [];
    for (const value of (view as unknown as { docView: { decorations: DecorationSet[] } }).docView
        .decorations) {
        const cursor = value.iter();
        while (cursor.value) {
            out.push({ from: cursor.from, to: cursor.to, spec: cursor.value.spec });
            cursor.next();
        }
    }
    return out;
}

function collectWidgets(view: EditorView): unknown[] {
    return getDecorations(view)
        .map((d) => (d.spec as { widget?: unknown }).widget)
        .filter((value): value is object => value != null);
}

function getDecorationClasses(view: EditorView): string[] {
    return getDecorations(view)
        .map((d) => (d.spec as { class?: string }).class)
        .filter((value): value is string => typeof value === "string");
}

function getHiddenRanges(view: EditorView): Array<{ from: number; to: number }> {
    return getDecorations(view)
        .filter((d) => (d.spec as { class?: string }).class === md.markHidden)
        .map((d) => ({ from: d.from, to: d.to }));
}

describe("CheckboxWidget", () => {
    it("compare is referential so TileCache must not skip updateDOM for a fresh instance", () => {
        const store = createWidgetStore();
        const a = new CheckboxWidget(
            { id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
            store,
        );
        const b = new CheckboxWidget(
            { id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
            store,
        );
        expect(a.compare(b)).toBe(false);
        expect(a.compare(a)).toBe(true);
    });
});

describe("markdownDecorations", () => {
    it("emits heading line classes", () => {
        const view = createView("# Hello world\n\nbody");
        const classes = getDecorationClasses(view);
        expect(classes.some((c) => c.includes(md.h1))).toBe(true);
        view.destroy();
    });

    it("hides heading marks when block is inactive in preview mode", () => {
        const view = createView("# Title\n\nparagraph after the cursor");
        view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.markHidden);
        view.destroy();
    });

    it("shows heading marks when cursor is on the heading line in preview mode", () => {
        const view = createView("# Title\n");
        view.dispatch({ selection: EditorSelection.cursor(3) });
        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.markShown);
        expect(classes).not.toContain(md.markHidden);
        view.destroy();
    });

    it("hides heading hashes in preview when unfocused even if the caret is on the heading line", () => {
        const view = createView("# Title\n");
        view.dispatch({
            selection: EditorSelection.cursor(3),
            effects: setMarkdownEditorFocused.of(false),
        });
        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.markHidden);
        expect(classes).not.toContain(md.markShown);
        view.destroy();
    });

    it("hides custom {#id} heading suffix when the heading line is inactive in preview mode", () => {
        const doc = "## Playground {#playground-anchor}\n\nbody";
        const view = createView(doc);
        view.dispatch({ selection: EditorSelection.cursor(doc.length) });
        const hidden = getHiddenRanges(view);
        const suffixFrom = doc.indexOf("{#playground-anchor}");
        const suffixTo = suffixFrom + "{#playground-anchor}".length;
        expect(hidden.some((r) => r.from <= suffixFrom && r.to >= suffixTo)).toBe(true);
        view.destroy();
    });

    it("shows custom {#id} heading suffix when the heading line is active in preview mode", () => {
        const doc = "## Playground {#playground-anchor}\n";
        const view = createView(doc);
        view.dispatch({ selection: EditorSelection.cursor(doc.indexOf("Playground") + 2) });
        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.markShown);
        view.destroy();
    });

    it("does not emit hidden marks in source mode", () => {
        const view = createView("# Title\n\n**bold**", "source");
        const classes = getDecorationClasses(view);
        expect(classes).not.toContain(md.markHidden);
        view.destroy();
    });

    it("reveals inline strong markers only when caret intersects the node", () => {
        // Document: "before **bold** after"
        //           positions:        0   4   8 10 14 16 ...
        const view = createView("before **bold** after");
        // Caret on the word "bold" (inside the StrongEmphasis node).
        view.dispatch({ selection: EditorSelection.cursor(11) });
        let classes = getDecorationClasses(view);
        expect(classes).toContain(md.markShown);
        expect(classes).not.toContain(md.markHidden);

        // Caret elsewhere on the same line but outside the **bold** node.
        view.dispatch({ selection: EditorSelection.cursor(2) });
        classes = getDecorationClasses(view);
        expect(classes).toContain(md.markHidden);
        expect(classes).not.toContain(md.markShown);

        view.destroy();
    });

    it("keeps inline source visible when caret sits at the node boundary", () => {
        const view = createView("before **bold** after");
        // The StrongEmphasis node spans positions 7..15 (inclusive of `**`).
        // Caret immediately before the opening `**` should still reveal it.
        view.dispatch({ selection: EditorSelection.cursor(7) });
        let classes = getDecorationClasses(view);
        expect(classes).toContain(md.markShown);
        expect(classes).not.toContain(md.markHidden);

        // And likewise immediately after the closing `**`.
        view.dispatch({ selection: EditorSelection.cursor(15) });
        classes = getDecorationClasses(view);
        expect(classes).toContain(md.markShown);
        expect(classes).not.toContain(md.markHidden);

        view.destroy();
    });

    it("does not emit a ListBulletWidget for task list items in preview mode", () => {
        // Cursor on line 1 keeps the list line (line 3) inactive so that
        // both the bullet and checkbox widgets would normally render.
        const view = createView("body\n\n- [ ] item");
        view.dispatch({ selection: EditorSelection.cursor(0) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(true);
        expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(false);

        view.destroy();
    });

    it("still emits a ListBulletWidget for plain bullet list items", () => {
        const view = createView("body\n\n- item");
        view.dispatch({ selection: EditorSelection.cursor(0) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(true);
        expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(false);

        view.destroy();
    });

    it("keeps ListBulletWidget when the caret is in the list item body in preview mode", () => {
        const view = createView("- item\n");
        const listLine = view.state.doc.line(1);
        const caretInBody = listLine.from + "- item".indexOf("i");
        view.dispatch({ selection: EditorSelection.cursor(caretInBody) });

        expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(true);

        view.destroy();
    });

    it("omits ListBulletWidget when the caret is inside the list mark in preview mode", () => {
        const view = createView("- item\n");
        let listMarkFrom = -1;
        syntaxTree(view.state).iterate({
            from: 0,
            to: view.state.doc.length,
            enter(n) {
                if (n.name === "ListMark") listMarkFrom = n.from;
            },
        });
        expect(listMarkFrom).toBeGreaterThanOrEqual(0);
        view.dispatch({ selection: EditorSelection.cursor(listMarkFrom + 1) });
        view.dispatch({ selection: EditorSelection.cursor(listMarkFrom) });

        expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(false);

        view.destroy();
    });

    it("keeps CheckboxWidget when the caret is in the task item body in preview mode", () => {
        const view = createView("- [ ] task\n");
        const listLine = view.state.doc.line(1);
        const caretInBody = listLine.from + "- [ ] task".indexOf("t");
        view.dispatch({ selection: EditorSelection.cursor(caretInBody) });

        expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(true);

        view.destroy();
    });

    it("omits CheckboxWidget when the caret is inside the task marker in preview mode", () => {
        const view = createView("- [ ] task\n");
        const bracketPos = view.state.doc.toString().indexOf("[");
        view.dispatch({ selection: EditorSelection.cursor(bracketPos) });

        expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(false);

        view.destroy();
    });

    it("does not emit HeadingFoldWidget in preview or source mode", () => {
        const preview = createView("# Heading\n\nbody", "preview");
        expect(collectWidgets(preview).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
        preview.destroy();

        const source = createView("# Heading\n\nbody", "source");
        expect(collectWidgets(source).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
        source.destroy();
    });

    it("does not emit a CalloutHeaderWidget when the caret is on the callout title row", () => {
        // `> [!warning] Heads up\n> body`
        // Position 2 is `[`; position 13 is somewhere inside `Heads up`.
        const view = createView("> [!warning] Heads up\n> body");
        view.dispatch({ selection: EditorSelection.cursor(13) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);

        view.destroy();
    });

    it("emits a CalloutHeaderWidget when the caret is far from the callout", () => {
        const view = createView("body line\n\n> [!warning] Heads up\n> inside");
        view.dispatch({ selection: EditorSelection.cursor(0) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(true);

        view.destroy();
    });

    it("does not emit the CalloutHeaderWidget when the caret sits at the title's end-of-line", () => {
        // Caret at the end of the first line (right after `Heads up`)
        // should still count as touching the header range.
        const view = createView("> [!warning] Heads up\n> body");
        const eol = view.state.doc.line(1).to;
        view.dispatch({ selection: EditorSelection.cursor(eol) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);

        view.destroy();
    });

    it("does not emit a CalloutHeaderWidget when the caret is anywhere inside the callout block", () => {
        const view = createView("> [!warning] Heads up\n> body");
        const bodyStart = view.state.doc.line(2).from + 2;
        view.dispatch({ selection: EditorSelection.cursor(bodyStart) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);

        view.destroy();
    });

    it("emits blockquote / callout line decorations", () => {
        const view = createView("> [!warning] Heads up\n> body");
        const decorations = getDecorations(view);
        const callout = decorations.find(
            (d) => (d.spec as { class?: string }).class === md.callout,
        );
        expect(callout).toBeDefined();
        expect(
            (callout?.spec as { attributes?: Record<string, string> }).attributes?.[
                "data-callout-kind"
            ],
        ).toBe("warning");
        view.destroy();
    });
});

describe("markdownDecorations / GFM tables", () => {
    const TABLE = `| Feature | Status |
| --- | --- |
| Headers | Live |`;

    it("emits TableWidget when the table block is inactive in preview mode", () => {
        const view = createView(`intro\n\n${TABLE}\n\nafter`);
        view.dispatch({ selection: EditorSelection.cursor(0) });

        expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(true);

        view.destroy();
    });

    it("omits TableWidget when the caret is inside the table block in preview mode", () => {
        const view = createView(TABLE);
        const inside = view.state.doc.toString().indexOf("Feature") + 2;
        view.dispatch({ selection: EditorSelection.cursor(inside) });

        expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);

        view.destroy();
    });

    it("does not emit TableWidget in source mode", () => {
        const view = createView(TABLE, "source");
        expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
        view.destroy();
    });

    it("applies monospace to the table and colours delimiters when the block is active", () => {
        const view = createView(TABLE);
        const inside = view.state.doc.toString().indexOf("Feature") + 2;
        view.dispatch({ selection: EditorSelection.cursor(inside) });

        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.tableSource);
        expect(classes).toContain(md.tableDelimiter);

        view.destroy();
    });

    it("applies monospace but not delimiter colour when the block is inactive in preview mode", () => {
        const view = createView(`intro\n\n${TABLE}\n\nafter`);
        view.dispatch({ selection: EditorSelection.cursor(0) });

        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.tableSource);
        expect(classes).not.toContain(md.tableDelimiter);

        view.destroy();
    });
});

describe("markdownDecorations / fenced code", () => {
    const FENCED = "```rust\nfn main() {}\n```";

    it("hides fence and language marks when the block is inactive in preview mode", () => {
        const view = createView(`paragraph\n\n${FENCED}\n`, "preview");
        view.dispatch({ selection: EditorSelection.cursor(0) });

        const classes = getDecorationClasses(view);
        expect(classes).toContain(md.markHidden);

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(true);
        expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);

        view.destroy();
    });

    it("renders CodeFenceLanguageMenuWidget when the block is active in preview focused mode", () => {
        const view = createView(FENCED, "preview");
        const codeBody = view.state.doc.toString().indexOf("fn ");
        view.dispatch({ selection: EditorSelection.cursor(codeBody) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
        expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);

        view.destroy();
    });

    it("does not emit fenced-code chrome widgets in source mode", () => {
        const view = createView(FENCED, "source");
        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
        expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
        view.destroy();
    });

    it("invokes a registered code renderer for matching languages when inactive", () => {
        const renderer: CodeRenderer = () => null;
        const view = createView(`paragraph\n\n\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`, "preview", {
            codeRenderers: { mermaid: renderer },
        });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(true);

        view.destroy();
    });

    it("falls back to fence widgets when an active block has a custom renderer", () => {
        const renderer: CodeRenderer = () => null;
        const doc = `\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`;
        const view = createView(doc, "preview", { codeRenderers: { mermaid: renderer } });
        const codeBody = view.state.doc.toString().indexOf("graph");
        view.dispatch({ selection: EditorSelection.cursor(codeBody) });

        const widgets = collectWidgets(view);
        expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(false);
        expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);

        view.destroy();
    });

    it("emits code-block line classes regardless of mode", () => {
        for (const mode of ["source", "preview"] as const) {
            const view = createView(FENCED, mode);
            const classes = getDecorationClasses(view);
            expect(classes).toContain(md.codeBlock);
            view.destroy();
        }
    });
});
