import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { flushSync, mount, tick, unmount } from 'svelte';
import MentionDropdown from './MentionDropdown.svelte';
import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';

// PersonSummaryDTO mirrors the real runtime shape: /api/persons list items are
// the summary projection, not the full entity (#812).
type Person = components['schemas']['PersonSummaryDTO'];

afterEach(cleanup);

const makePerson = (id: string, name: string, overrides: Partial<Person> = {}): Person => {
	const parts = name.split(' ');
	return {
		id,
		firstName: parts[0],
		lastName: parts.slice(1).join(' ') || name,
		displayName: name,
		personType: 'PERSON',
		birthDatePrecision: 'UNKNOWN',
		deathDatePrecision: 'UNKNOWN',
		familyMember: false,
		provisional: false,
		...overrides
	};
};

type DropdownState = {
	items: Person[];
	command: (item: Person) => void;
	clientRect: (() => DOMRect | null) | null;
};

const baseModel = (overrides: Partial<DropdownState> = {}): DropdownState => ({
	items: [],
	command: vi.fn(),
	clientRect: () => new DOMRect(100, 100, 0, 24),
	...overrides
});

describe('MentionDropdown', () => {
	it('renders the listbox with the mention label', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
	});

	it('shows the "enter a name" prompt when the search field is empty', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		// Scope to the visible empty-state <p> (text-ink-3) — the persistent
		// sr-only aria-live region above also contains the same prompt copy.
		const visibleEmptyP = document.querySelector(
			'[role="listbox"] p.text-ink-3'
		) as HTMLElement | null;
		expect(visibleEmptyP).not.toBeNull();
		expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
		expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_popup_empty());
	});

	it('shows "no persons found" when the search has a query but the list is empty', async () => {
		render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });

		const visibleEmptyP = document.querySelector(
			'[role="listbox"] p.text-ink-3'
		) as HTMLElement | null;
		expect(visibleEmptyP).not.toBeNull();
		expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
		expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_search_prompt());
	});

	it('shows the create-new escape hatch link in the empty state', async () => {
		render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'unknown' } });

		const link = (await page
			.getByRole('link', { name: /neue person anlegen/i })
			.element()) as HTMLAnchorElement;
		expect(link.href).toContain('/persons/new');
		expect(link.target).toBe('_blank');
		expect(link.rel).toContain('noopener');
		expect(link.rel).toContain('noreferrer');
	});

	it('renders one option per item when populated', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({
					items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
				})
			}
		});

		await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
		await expect.element(page.getByText('Bert Meier')).toBeVisible();
	});

	it('marks the first item as aria-selected by default', async () => {
		render(MentionDropdown, {
			props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) }
		});

		const option = document.querySelector('[role="option"]');
		expect(option?.getAttribute('aria-selected')).toBe('true');
	});

	it('renders the life-date range when birth or death date is present', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({
					items: [
						makePerson('p1', 'Anna', {
							birthDate: '1899-01-01',
							birthDatePrecision: 'YEAR',
							deathDate: '1972-01-01',
							deathDatePrecision: 'YEAR'
						})
					]
				})
			}
		});

		await expect.element(page.getByText(/1899/)).toBeVisible();
	});

	it('renders a DAY-precision birth date as a full date without clipping it away', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({
					items: [
						makePerson('p1', 'Anna', {
							birthDate: '1901-03-14',
							birthDatePrecision: 'DAY',
							deathDate: '1944-11-02',
							deathDatePrecision: 'DAY'
						})
					]
				})
			}
		});

		await expect.element(page.getByText(/14\. März 1901/)).toBeVisible();
	});

	it('falls back to a default position when clientRect returns null', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({ clientRect: () => null })
			}
		});

		const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
		expect(dropdown.style.left).toBe('0px');
	});

	it('positions itself based on the clientRect callback', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({
					clientRect: () => new DOMRect(123, 200, 50, 24)
				})
			}
		});

		const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
		expect(dropdown.style.left).toBe('123px');
	});
});

// ─── Search input — Issue #380 ────────────────────────────────────────────────

describe('MentionDropdown — search input', () => {
	it('renders a search input pre-filled with the editorQuery prop', async () => {
		render(MentionDropdown, {
			props: { model: baseModel(), editorQuery: 'WdG' }
		});

		await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
	});

	it('exposes a data-test-search-input attribute for E2E selectors', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const input = document.querySelector('[data-test-search-input]');
		expect(input).not.toBeNull();
		expect((input as HTMLInputElement).type).toBe('search');
	});

	it('search input wrapper meets the 44px touch target (WCAG 2.2 AA)', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const input = document.querySelector('[data-test-search-input]') as HTMLElement;
		expect(input).not.toBeNull();
		expect(input.className).toContain('min-h-[44px]');
	});

	it('renders a persistent aria-live="polite" region (does not remount on items transition; Leonie #3 on PR #629)', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const listbox = document.querySelector('[role="listbox"]');
		expect(listbox).not.toBeNull();
		const live = listbox!.querySelector('p[aria-live="polite"]');
		expect(live).not.toBeNull();
		// Empty + empty-query → "Namen eingeben…" prompt
		expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt());
	});

	it('announces the result count in the persistent live region when items populate (Leonie #3 on PR #629)', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({
					items: [
						makePerson('p1', 'Anna Schmidt'),
						makePerson('p2', 'Bert Meier'),
						makePerson('p3', 'Carl Vogel')
					]
				})
			}
		});

		const listbox = document.querySelector('[role="listbox"]');
		expect(listbox).not.toBeNull();
		const live = listbox!.querySelector('p[aria-live="polite"]');
		expect(live).not.toBeNull();
		// Populated → "3 Personen gefunden" (plural)
		expect(live!.textContent ?? '').toContain('3');
	});

	it('announces the singular form when exactly one item is present (Sara #4 on PR #629)', async () => {
		render(MentionDropdown, {
			props: {
				model: baseModel({
					items: [makePerson('p1', 'Anna Schmidt')]
				})
			}
		});

		const listbox = document.querySelector('[role="listbox"]');
		expect(listbox).not.toBeNull();
		const live = listbox!.querySelector('p[aria-live="polite"]');
		expect(live).not.toBeNull();
		// Singular branch — "1 Person gefunden" / "1 person found" / "1 persona encontrada"
		// (locale-dependent; resolved via the Paraglide message helper).
		expect(live!.textContent ?? '').toContain(m.person_mention_results_count_singular());
	});

	it('keeps the visible empty-state copy without its own aria-live and hides it from AT (Leonie #3 on PR #629 round 3; Leonie S-2 round 4)', async () => {
		render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });

		// Visible empty-state <p> exists with the empty-result copy ...
		const empty = document.querySelector('p.text-ink-3') as HTMLElement | null;
		expect(empty).not.toBeNull();
		expect(empty!.textContent ?? '').toContain(m.person_mention_popup_empty());
		// ... but it must NOT carry its own aria-live (the persistent sr-only
		// region above the conditional is the announcer now).
		expect(empty!.hasAttribute('aria-live')).toBe(false);
		// ... and it MUST be hidden from screen readers via aria-hidden="true"
		// so VoiceOver does not double-announce (the persistent sr-only region
		// is the sole AT source of truth). Leonie S-2 on PR #629 round 4.
		expect(empty!.getAttribute('aria-hidden')).toBe('true');
	});

	it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const icon = document.querySelector('[data-test-search-input]')
			?.previousElementSibling as SVGElement | null;
		expect(icon).not.toBeNull();
		expect(icon!.tagName.toLowerCase()).toBe('svg');
		expect(icon!.getAttribute('class') ?? '').toContain('h-5');
		expect(icon!.getAttribute('class') ?? '').toContain('w-5');
		expect(icon!.getAttribute('class') ?? '').toContain('text-ink-2');
	});

	it('caps the search input at maxlength=100 (CWE-400 amplification — Nora on PR #629)', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
		expect(input).not.toBeNull();
		expect(input.maxLength).toBe(100);
	});

	it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => {
		const longQuery = 'A'.repeat(200);
		render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } });

		const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
		expect(input).not.toBeNull();
		expect(input.value.length).toBe(100);
		expect(input.value).toBe('A'.repeat(100));
	});

	it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
		expect(listbox).not.toBeNull();
		expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]');
	});

	it('renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)', async () => {
		render(MentionDropdown, { props: { model: baseModel() } });

		const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
		expect(input).not.toBeNull();
		expect(input.className).toContain('text-base');
		expect(input.className).not.toContain('text-sm');
	});

	it('invokes onSearch with the current value whenever the user types', async () => {
		const onSearch = vi.fn();
		render(MentionDropdown, { props: { model: baseModel(), onSearch } });

		await userEvent.type(page.getByRole('searchbox'), 'Walter');

		await vi.waitFor(() => {
			expect(onSearch).toHaveBeenCalled();
			expect(onSearch).toHaveBeenLastCalledWith('Walter');
		});
	});

	it('keeps the user-edited search value when editorQuery changes after the takeover (Felix on PR #629)', async () => {
		let setEditorQuery!: (q: string) => void;
		render(MentionDropdownFixture, {
			model: baseModel(),
			initialEditorQuery: 'WdG',
			onReady: (s: (q: string) => void) => {
				setEditorQuery = s;
			}
		});

		await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');

		await page.getByRole('searchbox').fill('Walter');
		await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');

		setEditorQuery('WdGruyter');
		// Flush pending Svelte reactivity so any (non-)update from the mirror
		// $effect has landed before we assert. expect.element already polls, so
		// no fixed-timeout fallback is needed. Sara on PR #629 round 3.
		await tick();

		await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
	});
});

// ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ──────────────────
//
// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level
// and forwards them to the dropdown via its exported onKeyDown(event) function
// — the dropdown itself has no DOM keydown listener. This test exercises the
// same export so a regression in highlightedIndex/selection logic is caught
// at the unit level. The full E2E focus-chain test is deferred to a separate
// issue (Playwright).
//
// These unit tests directly invoke the exported `onKeyDown` to pin its
// behaviour in isolation. They do NOT exercise the Tiptap forwarding
// chain (PersonMentionEditor.suggestion.render() returning { onKeyDown })
// — that integration is covered by the 'ArrowDown moves the highlight'
// test in PersonMentionEditor.svelte.spec.ts. Sara on PR #629 round 3.

describe('MentionDropdown — onKeyDown forwarding', () => {
	// flushSync ensures Svelte reactivity propagation completes before
	// asserting (uniform across all four key tests so the next reader
	// doesn't have to figure out why some are wrapped and others aren't).
	// Felix #1 suggestion on PR #629 round 3.

	it('ArrowDown advances aria-selected to the next option in the listbox', async () => {
		const container = document.createElement('div');
		document.body.appendChild(container);
		const instance = mount(MentionDropdown, {
			target: container,
			props: {
				model: baseModel({
					items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
				})
			}
		});
		try {
			const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };

			// First option starts highlighted.
			const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement;
			const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
			expect(first.getAttribute('aria-selected')).toBe('true');
			expect(second.getAttribute('aria-selected')).toBe('false');

			let consumed = false;
			flushSync(() => {
				consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
			});
			expect(consumed).toBe(true);

			expect(first.getAttribute('aria-selected')).toBe('false');
			expect(second.getAttribute('aria-selected')).toBe('true');
		} finally {
			unmount(instance);
			container.remove();
		}
	});

	it('ArrowUp wraps from the first option to the last', async () => {
		const container = document.createElement('div');
		document.body.appendChild(container);
		const instance = mount(MentionDropdown, {
			target: container,
			props: {
				model: baseModel({
					items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
				})
			}
		});
		try {
			const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };

			let consumed = false;
			flushSync(() => {
				consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
			});
			expect(consumed).toBe(true);

			const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
			expect(second.getAttribute('aria-selected')).toBe('true');
		} finally {
			unmount(instance);
			container.remove();
		}
	});

	it('Enter invokes model.command with the currently highlighted item', async () => {
		const command = vi.fn();
		const container = document.createElement('div');
		document.body.appendChild(container);
		const instance = mount(MentionDropdown, {
			target: container,
			props: {
				model: baseModel({
					items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')],
					command
				})
			}
		});
		try {
			const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };

			let consumed = false;
			flushSync(() => {
				consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
			});
			expect(consumed).toBe(true);
			expect(command).toHaveBeenCalledTimes(1);
			expect(command.mock.calls[0][0].id).toBe('p1');
		} finally {
			unmount(instance);
			container.remove();
		}
	});

	it('Escape returns false so the suggestion plugin can handle it', async () => {
		const container = document.createElement('div');
		document.body.appendChild(container);
		const instance = mount(MentionDropdown, {
			target: container,
			props: {
				model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] })
			}
		});
		try {
			const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
			let consumed = true;
			flushSync(() => {
				consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
			});
			expect(consumed).toBe(false);
		} finally {
			unmount(instance);
			container.remove();
		}
	});
});
