import { EditorSelection } from "@codemirror/state";
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
import { composeStories } from "@storybook/react-vite";
import { describe, expect, it } from "vitest";
import {
    mountStory,
    nextFrame,
    preparePreviewEditor,
    scrollEditorTo,
    userEvent,
} from "../../_setup/e2e-helpers";
import { md } from "../../_setup/markdown-classes";

const Stories = composeStories(MarkdownStories);

function findLineStart(doc: string, prefix: string): number {
    const idx = doc.indexOf(prefix);
    if (idx < 0) throw new Error(`prefix not found in doc: ${prefix}`);
    return idx;
}

describe("MarkdownEditor / preview widgets", () => {
    it("reveals heading marks when the caret enters the heading line and hides them when it leaves", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();

        // Caret far away from the H1 → marks should be hidden.
        view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
        await nextFrame();
        expect(document.querySelectorAll(`.${md.markHidden}`).length).toBeGreaterThan(0);

        // Move caret onto the H1 line → marks revealed for that block.
        const headingStart = findLineStart(view.state.doc.toString(), "# Welcome");
        await scrollEditorTo(view, headingStart);
        view.dispatch({ selection: EditorSelection.cursor(headingStart + 4) });
        await nextFrame();
        expect(document.querySelectorAll(`.${md.markShown}`).length).toBeGreaterThan(0);
    });

    it("keeps list bullets consistent after hopping the caret across items several times", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();
        const docStr = view.state.doc.toString();

        const items = ["- First item", "- Second item", "- Third item"];
        const positions = items.map((line) => findLineStart(docStr, line) + 2);
        const listPos = findLineStart(docStr, "- First item");

        // Park the caret away from the list so all three bullets render as widgets.
        view.dispatch({ selection: EditorSelection.cursor(0) });
        await scrollEditorTo(view, listPos);
        await nextFrame();

        let baselineBullets = 0;
        for (let i = 0; i < 40; i++) {
            baselineBullets = document.querySelectorAll(`.${md.listBullet}`).length;
            if (baselineBullets >= 3) break;
            await nextFrame();
        }
        expect(baselineBullets).toBeGreaterThanOrEqual(3);

        // Hop between list items several times. With prefix-scoped hybrid reveal,
        // the bullet widgets stay visible while the caret is in the item text;
        // the pre-fix regression was: bullets stuck behind or disappeared once you
        // moved the caret around a few times.
        for (let pass = 0; pass < 4; pass++) {
            for (let i = 0; i < positions.length; i++) {
                view.dispatch({ selection: EditorSelection.cursor(positions[i] ?? 0) });
                await nextFrame();

                const bullets = document.querySelectorAll(`.${md.listBullet}`).length;
                expect(bullets).toBe(baselineBullets);
            }
        }

        // Caret on the list mark (the `-`) drops that item's bullet widget only.
        const dashPos = findLineStart(docStr, "- First item");
        view.dispatch({ selection: EditorSelection.cursor(dashPos) });
        await nextFrame();
        expect(document.querySelectorAll(`.${md.listBullet}`).length).toBe(baselineBullets - 1);

        // After leaving the list entirely all bullets must be back.
        view.dispatch({ selection: EditorSelection.cursor(0) });
        await nextFrame();
        expect(document.querySelectorAll(`.${md.listBullet}`).length).toBe(baselineBullets);
    });

    it("shows the right callout title when moving focus between consecutive callouts", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();
        const docStr = view.state.doc.toString();

        const noteBody = findLineStart(docStr, "> Callouts use the GFM");
        const warningBody = findLineStart(docStr, "> Legacy");
        const tipBody = findLineStart(docStr, "> All systems go");
        const calloutPos = findLineStart(docStr, "> [!note]");

        // Park caret outside any callout first so all three headers render.
        view.dispatch({ selection: EditorSelection.cursor(0) });
        await scrollEditorTo(view, calloutPos);
        await nextFrame();

        const allHeaders = () =>
            Array.from(document.querySelectorAll<HTMLElement>(`.${md.calloutHeader}`));
        const headerForKind = (kind: string) =>
            allHeaders().find((el) => el.getAttribute("data-callout-kind") === kind) ?? null;

        let initial = allHeaders();
        for (let i = 0; i < 40 && initial.length < 3; i++) {
            await nextFrame();
            initial = allHeaders();
        }
        expect(initial.length).toBe(3);
        expect(headerForKind("note")?.textContent).toContain("Heads up");
        expect(headerForKind("warning")?.textContent).toContain("Be careful");
        expect(headerForKind("tip")?.textContent).toContain("Tip");

        // `[!tip]` has no title, falls back to the visual label.

        // Hop between the three callouts repeatedly. After each hop the
        // header for the active callout disappears (caret on its block); the
        // other two keep their correct titles. Bug regression: titles would
        // swap or vanish.
        const positions = [
            { pos: noteBody + 2, hidden: "note" as const },
            { pos: warningBody + 2, hidden: "warning" as const },
            { pos: tipBody + 2, hidden: "tip" as const },
        ];

        for (let pass = 0; pass < 4; pass++) {
            for (const { pos, hidden } of positions) {
                view.dispatch({ selection: EditorSelection.cursor(pos) });
                await nextFrame();

                const visible = allHeaders();
                expect(visible.length).toBe(2);
                expect(headerForKind(hidden)).toBeNull();

                if (hidden !== "note") {
                    expect(headerForKind("note")?.textContent).toContain("Heads up");
                }
                if (hidden !== "warning") {
                    expect(headerForKind("warning")?.textContent).toContain("Be careful");
                }
                if (hidden !== "tip") {
                    expect(headerForKind("tip")?.textContent).toContain("Tip");
                }
            }
        }
    });

    it("clicking a callout header focuses the editor with the caret in that callout", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();
        const docStr = view.state.doc.toString();
        const calloutPos = findLineStart(docStr, "> [!warning]");

        // Caret far away so all callout headers render as widgets.
        view.dispatch({ selection: EditorSelection.cursor(0) });
        await scrollEditorTo(view, calloutPos);
        view.contentDOM.blur();
        await nextFrame();

        let warningHeader: HTMLElement | undefined;
        for (let i = 0; i < 40; i++) {
            warningHeader = Array.from(
                document.querySelectorAll<HTMLElement>(`.${md.calloutHeader}`),
            ).find((el) => el.getAttribute("data-callout-kind") === "warning");
            if (warningHeader) break;
            await nextFrame();
        }
        expect(warningHeader).toBeDefined();
        if (!warningHeader) throw new Error("warning callout header not rendered");

        // Use a real pointer gesture so CodeMirror's input layer sees it
        // (synthetic MouseEvent dispatch does not always reach CM6's
        // contentDOM listeners through the widget's atomic range).
        await userEvent.click(warningHeader);
        await nextFrame();
        await nextFrame();

        const warningHeaderRange = {
            from: docStr.indexOf("> [!warning]"),
            to: docStr.indexOf("Be careful") + "Be careful".length,
        };
        expect(warningHeaderRange.from).toBeGreaterThan(-1);

        const sel = view.state.selection.main;
        expect(sel.from).toBeGreaterThanOrEqual(warningHeaderRange.from);
        expect(sel.to).toBeLessThanOrEqual(warningHeaderRange.to + 1);
    });
});
