import { EditorView } from "@codemirror/view";
import { page, userEvent } from "@vitest/browser/context";
import { type ComponentType, createElement, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach } from "vitest";

interface MountedStory {
    container: HTMLElement;
    unmount: () => void;
}

const activeMounts = new Set<MountedStory>();

afterEach(() => {
    for (const mount of activeMounts) {
        mount.unmount();
    }
    activeMounts.clear();
});

/**
 * Mount a composed Storybook story (or any React component) into a fresh
 * detached container appended to `document.body`. Cleanup happens after
 * each test automatically; tests can still call `unmount()` early.
 */
export function mountStory(node: ReactElement | ComponentType): MountedStory {
    const container = document.createElement("div");
    container.dataset.testRoot = "true";
    document.body.appendChild(container);

    let root: Root | null = createRoot(container);
    const element = isReactElement(node) ? node : createElement(node);
    root.render(element);

    const mount: MountedStory = {
        container,
        unmount: () => {
            if (!root) return;
            root.unmount();
            root = null;
            container.remove();
            activeMounts.delete(mount);
        },
    };
    activeMounts.add(mount);
    return mount;
}

function isReactElement(node: ReactElement | ComponentType): node is ReactElement {
    return typeof node !== "function" && typeof node === "object";
}

/**
 * Wait for the CodeMirror editor to mount and return the underlying
 * `EditorView`. Polls `EditorView.findFromDOM` so callers can read the
 * authoritative state (selection, doc, mode) directly inside assertions.
 */
export async function getEditorView(timeoutMs = 5000): Promise<EditorView> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
        const dom = document.querySelector<HTMLElement>(".cm-editor");
        if (dom) {
            const view = EditorView.findFromDOM(dom);
            if (view) return view;
        }
        await sleep(20);
    }
    throw new Error("Timed out waiting for the markdown EditorView to mount");
}

/** Returns the `.cm-content` editable element once it's in the DOM. */
export async function getContentElement(timeoutMs = 5000): Promise<HTMLElement> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
        const dom = document.querySelector<HTMLElement>(".cm-content");
        if (dom) return dom;
        await sleep(20);
    }
    throw new Error("Timed out waiting for .cm-content");
}

/**
 * Focus the underlying CodeMirror `EditorView` without moving the caret
 * (unlike `userEvent.click`, which relocates the caret to the click
 * target). Useful when the test has already positioned the selection via
 * `view.dispatch({ selection: ... })`.
 */
export async function focusEditor(): Promise<EditorView> {
    const view = await getEditorView();
    view.focus();
    await getContentElement();
    return view;
}

/** Type at the currently focused element. Caller should call `focusEditor()` first. */
export async function typeText(text: string): Promise<void> {
    await userEvent.keyboard(text);
}

/** Convenience: focus the editor and type at the current caret. */
export async function focusAndType(text: string): Promise<void> {
    await focusEditor();
    await userEvent.keyboard(text);
}

/**
 * Resolve when the next animation frame fires. Useful between dispatch
 * and DOM assertions so CodeMirror's measure cycle has time to run.
 */
export function nextFrame(): Promise<void> {
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}

/** Sleep helper for the polling loops above. */
export function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Read the current markdown source out of the live EditorView.
 */
export async function readDoc(): Promise<string> {
    const view = await getEditorView();
    return view.state.doc.toString();
}

/** Wait until the editor reports `data-markdown-mode="preview"`. */
export async function ensurePreviewMode(timeoutMs = 5000): Promise<void> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
        const mode = document.querySelector(".cm-editor")?.getAttribute("data-markdown-mode");
        if (mode === "preview") return;
        await nextFrame();
    }

    const preview = page.getByText("Preview", { exact: true });

    if (preview.elements().length > 0) {
        await preview.click();
    }

    const retryStart = Date.now();
    while (Date.now() - retryStart < timeoutMs) {
        const mode = document.querySelector(".cm-editor")?.getAttribute("data-markdown-mode");
        if (mode === "preview") return;
        await nextFrame();
    }

    throw new Error("Timed out waiting for preview mode");
}

/** Mount helper: editor view + preview mode + focus. */
export async function preparePreviewEditor(): Promise<EditorView> {
    const view = await getEditorView();
    await ensurePreviewMode();
    view.focus();
    await nextFrame();
    return view;
}

/** Scroll the editor so `pos` is centred in the viewport. */
export async function scrollEditorTo(view: EditorView, pos: number): Promise<void> {
    view.dispatch({
        effects: EditorView.scrollIntoView(pos, { y: "center" }),
    });
    await nextFrame();
}

export { page, userEvent };
