import { EditorSelection } from "@codemirror/state";
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
import { MANTINE_THEME } from "@src/theme/mantine";
import { composeStories } from "@storybook/react-vite";
import { type ReactElement, useState } from "react";
import { describe, expect, it } from "vitest";
import {
    mountStory,
    nextFrame,
    preparePreviewEditor,
    scrollEditorTo,
    userEvent,
} from "../../_setup/e2e-helpers";

const Stories = composeStories(MarkdownStories);

function getCheckboxes(): HTMLElement[] {
    return Array.from(document.querySelectorAll<HTMLElement>("[data-md-task-marker]"));
}

const SCROLL_FIXTURE = `# Scroll fixture

${Array.from({ length: 80 }, (_, i) => `Line ${i + 1} of fixture content.`).join("\n")}
`;

function ScrollFixture(): ReactElement {
    const [doc, setDoc] = useState(SCROLL_FIXTURE);
    // Mounting the primitive directly here means we have to provide our own
    // MantineProvider; portable-stories decorators do not apply to ad-hoc JSX.
    return (
        <MantineProvider
            theme={MANTINE_THEME}
            cssVariablesResolver={v8CssVariablesResolver}
            forceColorScheme="dark"
        >
            <div style={{ height: 240, overflow: "hidden" }}>
                <MarkdownEditor
                    mode="preview"
                    document={doc}
                    onChangeDocument={setDoc}
                    style={{ height: "100%" }}
                />
            </div>
        </MantineProvider>
    );
}

describe("MarkdownEditor / regressions", () => {
    it("task checkbox toggles between checked and unchecked in the source document", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();
        const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");

        // Park caret outside the task list so checkbox widgets render for every item
        // (preview mode only swaps to raw `[…]` when the selection hits the marker).
        view.dispatch({ selection: EditorSelection.cursor(0) });
        await scrollEditorTo(view, taskPos);
        await nextFrame();

        const initialDoc = view.state.doc.toString();
        expect(initialDoc).toContain("- [ ] Read the docs");
        expect(initialDoc).toContain("- [x] Star the repo");

        let first: HTMLElement | undefined;
        for (let i = 0; i < 40; i++) {
            [first] = getCheckboxes();
            if (first) break;
            await nextFrame();
        }
        expect(first).toBeDefined();
        if (!first) throw new Error("no task marker rendered");

        await userEvent.click(first);
        await nextFrame();
        await nextFrame();

        expect(view.state.doc.toString()).toContain("- [x] Read the docs");
        expect(view.state.doc.toString()).not.toContain("- [ ] Read the docs");

        // Toggle again — single mousedown handler, no double-toggle on click.
        const [firstAgain] = getCheckboxes();
        if (!firstAgain) throw new Error("task marker disappeared after toggle");
        await userEvent.click(firstAgain);
        await nextFrame();
        await nextFrame();
        expect(view.state.doc.toString()).toContain("- [ ] Read the docs");
    });

    it("repeated checkbox toggles do not duplicate or drift task markers", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();
        const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");

        view.dispatch({ selection: EditorSelection.cursor(0) });
        await scrollEditorTo(view, taskPos);
        await nextFrame();

        const baselineCount = (view.state.doc.toString().match(/- \[[ x]\] /g) ?? []).length;
        expect(baselineCount).toBeGreaterThanOrEqual(3);

        let markers = getCheckboxes();
        for (let i = 0; i < 40 && markers.length < 2; i++) {
            await nextFrame();
            markers = getCheckboxes();
        }

        for (let i = 0; i < 6; i++) {
            markers = getCheckboxes();
            const target = markers[1];
            if (!target) throw new Error("task marker missing during loop");
            await userEvent.click(target);
            await nextFrame();
            await nextFrame();
        }

        const finalDoc = view.state.doc.toString();
        const finalCount = (finalDoc.match(/- \[[ x]\] /g) ?? []).length;
        expect(finalCount).toBe(baselineCount);
        // Star the repo started as `[x]`, so 6 toggles → still `[x]`.
        expect(finalDoc).toContain("- [x] Star the repo");
    });

    it("toggle via checkbox preserves task marker widgets visible from the previous caret position", async () => {
        mountStory(<Stories.Editor />);
        const view = await preparePreviewEditor();
        const taskPos = view.state.doc.toString().indexOf("- [ ] Read the docs");

        view.dispatch({ selection: EditorSelection.cursor(0) });
        await scrollEditorTo(view, taskPos);
        view.contentDOM.blur();
        await nextFrame();

        let initialMarkers = 0;
        for (let i = 0; i < 40; i++) {
            initialMarkers = getCheckboxes().length;
            if (initialMarkers >= 3) break;
            await nextFrame();
        }
        expect(initialMarkers).toBeGreaterThanOrEqual(3);

        const [marker] = getCheckboxes();
        if (!marker) throw new Error("no task marker");
        await userEvent.click(marker);
        await nextFrame();
        await nextFrame();

        // If the click had dropped the checkbox widget for that line, the count
        // would fall (caret would need to sit on the task marker for that).
        const afterMarkers = getCheckboxes().length;
        expect(afterMarkers).toBe(initialMarkers);
    });

    it("scroll position is preserved when an out-of-view edit is dispatched", async () => {
        mountStory(<ScrollFixture />);
        const view = await preparePreviewEditor();
        const scroller = document.querySelector<HTMLElement>(".cm-scroller");
        if (!scroller) throw new Error("no .cm-scroller in mounted story");

        // Wait for layout to settle before measuring scroll height.
        await nextFrame();
        await nextFrame();
        expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);

        scroller.scrollTop = 200;
        await nextFrame();
        const scrollBefore = scroller.scrollTop;
        expect(scrollBefore).toBeGreaterThan(0);

        // Insert at offset 5 (well above the current viewport) without
        // moving the selection. The regression was: ANY content change
        // reset `.cm-scroller` scrollTop to 0. The fix preserves scroll
        // unless the dispatch explicitly requests a scrollIntoView.
        view.dispatch({ changes: { from: 5, insert: "X" } });
        await nextFrame();
        await nextFrame();

        expect(Math.abs(scroller.scrollTop - scrollBefore)).toBeLessThan(20);
        expect(scroller.scrollTop).toBeGreaterThan(0);
    });
});
