/**
 * Slash command menu tests.
 *
 * Tests the "/" trigger, command filtering, keyboard navigation,
 * command execution, and menu dismissal via Escape.
 *
 * The slash menu is internal to PortableTextEditor and driven by
 * TipTap's Suggestion plugin. We test it through the full editor
 * since there's no standalone export.
 */

import type { Editor } from "@tiptap/react";
import { userEvent } from "@vitest/browser/context";
import { describe, it, expect, vi } from "vitest";
import { render } from "vitest-browser-react";

import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
import { PortableTextEditor } from "../../src/components/PortableTextEditor";

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

vi.mock("../../src/components/MediaPickerModal", () => ({
	MediaPickerModal: () => null,
}));

vi.mock("../../src/components/SectionPickerModal", () => ({
	SectionPickerModal: () => null,
}));

vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
	DragHandleWrapper: () => null,
}));

vi.mock("../../src/components/editor/ImageNode", async () => {
	const { Node } = await import("@tiptap/core");
	const ImageExtension = Node.create({
		name: "image",
		group: "block",
		atom: true,
		addAttributes() {
			return {
				src: { default: null },
				alt: { default: "" },
				title: { default: "" },
				caption: { default: "" },
				mediaId: { default: null },
				provider: { default: "local" },
				width: { default: null },
				height: { default: null },
				displayWidth: { default: null },
				displayHeight: { default: null },
			};
		},
		parseHTML() {
			return [{ tag: "img[src]" }];
		},
		renderHTML({ HTMLAttributes }) {
			return ["img", HTMLAttributes];
		},
	});
	return { ImageExtension };
});

vi.mock("../../src/components/editor/PluginBlockNode", async () => {
	const { Node } = await import("@tiptap/core");
	const PluginBlockExtension = Node.create({
		name: "pluginBlock",
		group: "block",
		atom: true,
		addAttributes() {
			return {
				blockType: { default: "embed" },
				id: { default: "" },
				data: { default: {} },
			};
		},
		parseHTML() {
			return [{ tag: "div[data-plugin-block]" }];
		},
		renderHTML({ HTMLAttributes }) {
			return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
		},
	});
	const embedMeta: Record<string, { label: string }> = {
		youtube: { label: "YouTube Video" },
		vimeo: { label: "Vimeo" },
		tweet: { label: "Tweet" },
	};
	return {
		PluginBlockExtension,
		getEmbedMeta: (type: string) => ({
			label: embedMeta[type]?.label ?? "Embed",
			Icon: () => null,
		}),
		registerPluginBlocks: () => {},
		resolveIcon: () => () => null,
	};
});

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const WHITESPACE_SPLIT_REGEX = /\s+/;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Render the editor, wait for TipTap, return editor instance + ProseMirror element */
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
	let editorInstance: Editor | null = null;

	const screen = await render(
		<PortableTextEditor
			onEditorReady={(editor) => {
				editorInstance = editor;
			}}
			{...props}
		/>,
	);

	await vi.waitFor(
		() => {
			expect(document.querySelector(".ProseMirror")).toBeTruthy();
			expect(editorInstance).toBeTruthy();
		},
		{ timeout: 3000 },
	);

	const pm = document.querySelector(".ProseMirror") as HTMLElement;
	return { screen, editor: editorInstance!, pm };
}

/** Focus the editor */
async function focusEditor(pm: HTMLElement) {
	pm.focus();
	await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
}

/** Get the slash menu portal element from document.body */
function getSlashMenu(): HTMLElement | null {
	const portals = document.querySelectorAll("body > div");
	for (const el of portals) {
		if (el.querySelector("[data-index]") || el.textContent?.includes("No results")) {
			return el as HTMLElement;
		}
	}
	return null;
}

/** Wait for the slash menu to appear */
async function waitForSlashMenu(): Promise<HTMLElement> {
	let menu: HTMLElement | null = null;
	await vi.waitFor(
		() => {
			menu = getSlashMenu();
			expect(menu).toBeTruthy();
		},
		{ timeout: 3000 },
	);
	return menu!;
}

/** Wait for the slash menu to disappear */
async function waitForSlashMenuClosed() {
	await vi.waitFor(
		() => {
			expect(getSlashMenu()).toBeNull();
		},
		{ timeout: 3000 },
	);
}

/** Get visible items in the slash menu */
function getSlashMenuItems(menu: HTMLElement): HTMLButtonElement[] {
	return [...menu.querySelectorAll("button[data-index]")];
}

/**
 * Check if an item is the selected/highlighted item.
 * Selected items use "bg-kumo-tint text-kumo-default" (space-separated).
 * Non-selected items use "hover:bg-kumo-tint/50".
 */
function isItemSelected(el: HTMLElement): boolean {
	// Split className by spaces and check for exact "bg-kumo-tint" token
	return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-tint");
}

// =============================================================================
// Slash Command Menu
// =============================================================================

describe("Slash Command Menu", () => {
	it("opens when typing / at the start of an empty line", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);

		// Default commands: heading1-3, bullet/numbered list, quote, code block, divider, image, section
		expect(items.length).toBeGreaterThanOrEqual(8);
	});

	it("shows default block type commands", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");

		expect(titles).toContain("Heading 1");
		expect(titles).toContain("Heading 2");
		expect(titles).toContain("Heading 3");
		expect(titles).toContain("Bullet List");
		expect(titles).toContain("Numbered List");
		expect(titles).toContain("Quote");
		expect(titles).toContain("Code Block");
		expect(titles).toContain("Divider");
	});

	it("shows descriptions for each command", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);

		for (const item of items) {
			const description = item.querySelector(".text-xs");
			expect(description).toBeTruthy();
			expect(description!.textContent!.length).toBeGreaterThan(0);
		}
	});

	it("filters commands by query text", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		// Type filter text — Suggestion plugin watches text after "/"
		await userEvent.keyboard("head");

		await vi.waitFor(
			() => {
				const menu = getSlashMenu();
				expect(menu).toBeTruthy();
				const items = getSlashMenuItems(menu!);
				const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
				expect(titles.length).toBeGreaterThanOrEqual(1);
				expect(titles.every((t) => t.toLowerCase().includes("heading"))).toBe(true);
			},
			{ timeout: 3000 },
		);
	});

	it("shows No results when no commands match", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		await userEvent.keyboard("xyznonexistent");

		await vi.waitFor(
			() => {
				const menu = getSlashMenu();
				expect(menu).toBeTruthy();
				expect(menu!.textContent).toContain("No results");
			},
			{ timeout: 3000 },
		);
	});

	it("highlights the first item by default", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);

		expect(isItemSelected(items[0]!)).toBe(true);
	});

	it("moves selection down with ArrowDown", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		await userEvent.keyboard("{ArrowDown}");

		await vi.waitFor(() => {
			const menu = getSlashMenu()!;
			const items = getSlashMenuItems(menu);
			expect(isItemSelected(items[1]!)).toBe(true);
			expect(isItemSelected(items[0]!)).toBe(false);
		});
	});

	it("moves selection up with ArrowUp from second item", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		// Move down, then back up
		await userEvent.keyboard("{ArrowDown}");
		await vi.waitFor(() => {
			const items = getSlashMenuItems(getSlashMenu()!);
			expect(isItemSelected(items[1]!)).toBe(true);
		});

		await userEvent.keyboard("{ArrowUp}");
		await vi.waitFor(() => {
			const items = getSlashMenuItems(getSlashMenu()!);
			expect(isItemSelected(items[0]!)).toBe(true);
		});
	});

	it("wraps selection around when pressing ArrowUp from first item", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		await userEvent.keyboard("{ArrowUp}");

		await vi.waitFor(() => {
			const menu = getSlashMenu()!;
			const items = getSlashMenuItems(menu);
			const lastItem = items.at(-1)!;
			expect(isItemSelected(lastItem)).toBe(true);
		});
	});

	it("executes selected command on Enter and converts to heading", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		// First item is "Heading 1"
		await userEvent.keyboard("{Enter}");

		await waitForSlashMenuClosed();

		await vi.waitFor(() => {
			expect(pm.querySelector("h1")).toBeTruthy();
		});
	});

	it("closes menu on Escape without executing", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		await userEvent.keyboard("{Escape}");

		await waitForSlashMenuClosed();

		// Should still be a paragraph
		expect(pm.querySelector("h1")).toBeNull();
	});

	it("executes command when clicking an item", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const quoteBtn = items.find(
			(btn) => btn.querySelector(".font-medium")?.textContent === "Quote",
		);
		expect(quoteBtn).toBeTruthy();
		quoteBtn!.click();

		await waitForSlashMenuClosed();

		await vi.waitFor(() => {
			expect(pm.querySelector("blockquote")).toBeTruthy();
		});
	});

	it("inserts a code block via slash command", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const codeBlockBtn = items.find(
			(btn) => btn.querySelector(".font-medium")?.textContent === "Code Block",
		);
		expect(codeBlockBtn).toBeTruthy();
		codeBlockBtn!.click();

		await waitForSlashMenuClosed();

		await vi.waitFor(() => {
			expect(pm.querySelector("pre")).toBeTruthy();
		});
	});

	it("inserts a horizontal rule via slash command", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const dividerBtn = items.find(
			(btn) => btn.querySelector(".font-medium")?.textContent === "Divider",
		);
		expect(dividerBtn).toBeTruthy();
		dividerBtn!.click();

		await waitForSlashMenuClosed();

		await vi.waitFor(() => {
			expect(pm.querySelector("hr")).toBeTruthy();
		});
	});

	it("inserts bullet list via slash command", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const bulletBtn = items.find(
			(btn) => btn.querySelector(".font-medium")?.textContent === "Bullet List",
		);
		expect(bulletBtn).toBeTruthy();
		bulletBtn!.click();

		await waitForSlashMenuClosed();

		await vi.waitFor(() => {
			expect(pm.querySelector("ul")).toBeTruthy();
		});
	});

	it("inserts numbered list via slash command", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const numberedBtn = items.find(
			(btn) => btn.querySelector(".font-medium")?.textContent === "Numbered List",
		);
		expect(numberedBtn).toBeTruthy();
		numberedBtn!.click();

		await waitForSlashMenuClosed();

		await vi.waitFor(() => {
			expect(pm.querySelector("ol")).toBeTruthy();
		});
	});

	it("highlights item on mouse hover", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);

		// React listens for pointerenter/mouseenter on the element.
		// Use userEvent.hover which properly dispatches pointer + mouse events.
		await userEvent.hover(items[2]!);

		await vi.waitFor(() => {
			const freshItems = getSlashMenuItems(menu);
			expect(isItemSelected(freshItems[2]!)).toBe(true);
		});
	});

	it("filters by alias (typing /h1 shows Heading 1)", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		await userEvent.keyboard("h1");

		await vi.waitFor(
			() => {
				const menu = getSlashMenu();
				expect(menu).toBeTruthy();
				const items = getSlashMenuItems(menu!);
				expect(items.length).toBeGreaterThanOrEqual(1);
				const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
				expect(titles).toContain("Heading 1");
			},
			{ timeout: 3000 },
		);
	});

	it("includes Image and Section commands", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");

		expect(titles).toContain("Image");
		expect(titles).toContain("Section");
	});

	it("prioritises title matches over description matches when filtering", async () => {
		const { editor, pm } = await renderEditor();
		await focusEditor(pm);
		editor.commands.insertContent("/");
		await waitForSlashMenu();

		// "sec" matches "Section" by title and headings by description ("section heading")
		await userEvent.keyboard("sec");

		await vi.waitFor(
			() => {
				const menu = getSlashMenu();
				expect(menu).toBeTruthy();
				const items = getSlashMenuItems(menu!);
				const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
				expect(titles.length).toBeGreaterThan(1);
				expect(titles[0]).toBe("Section");
			},
			{ timeout: 3000 },
		);
	});

	it("includes plugin block commands when provided", async () => {
		const { editor, pm } = await renderEditor({
			pluginBlocks: [
				{
					pluginId: "test-plugin",
					type: "youtube",
					label: "YouTube Video",
				},
			],
		});
		await focusEditor(pm);
		editor.commands.insertContent("/");

		const menu = await waitForSlashMenu();
		const items = getSlashMenuItems(menu);
		const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");

		expect(titles).toContain("YouTube Video");
	});
});
