/**
 * PersonMentionEditor — Tiptap-based component tests.
 *
 * All old tests used document.querySelector('textarea') which is dead after
 * the Tiptap migration. These tests drive the contenteditable via
 * userEvent.type() and inspect the serialized output from the test host.
 */
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { tick } from 'svelte';
import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
// Single source of truth for the debounce window — imported from the shared
// module so the test cannot drift from production. Sara on PR #629 round 3.
import { SEARCH_DEBOUNCE_MS } from './mentionConstants';

type Person = components['schemas']['PersonSummaryDTO'];
type PersonMention = components['schemas']['PersonMention'];

/**
 * Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait
 * assertions in this file. 350 ms is calibrated against CI-runner jitter
 * we observed pre-#629; dropping it below ~200 ms reintroduces flake.
 * See PR #629 round-2 review comment #10935 (Sara).
 */
const POST_DEBOUNCE_SLACK_MS = 350;

const AUGUSTE: Person = {
	id: 'p-aug',
	firstName: 'Auguste',
	lastName: 'Raddatz',
	displayName: 'Auguste Raddatz',
	personType: 'PERSON',
	familyMember: false,
	provisional: false,
	birthDate: '1882-01-01',
	birthDatePrecision: 'YEAR',
	deathDate: '1944-01-01',
	deathDatePrecision: 'YEAR'
};

const ANNA: Person = {
	id: 'p-anna',
	firstName: 'Anna',
	lastName: 'Schmidt',
	displayName: 'Anna Schmidt',
	personType: 'PERSON',
	familyMember: false,
	provisional: false,
	birthDate: '1860-01-01',
	birthDatePrecision: 'YEAR',
	deathDatePrecision: 'UNKNOWN'
};

function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
	vi.stubGlobal(
		'fetch',
		vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: persons }) })
	);
}

function mockFetchEmpty() {
	vi.stubGlobal(
		'fetch',
		vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
	);
}

type Snapshot = { value: string; mentionedPersons: PersonMention[] };

function renderHost(
	initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
) {
	let snapshot: Snapshot = {
		value: initial.value ?? '',
		mentionedPersons: initial.mentionedPersons ?? []
	};
	render(PersonMentionEditorHost, {
		initialValue: initial.value ?? '',
		initialMentions: initial.mentionedPersons ?? [],
		disabled: initial.disabled ?? false,
		onChange: (snap: Snapshot) => {
			snapshot = snap;
		}
	});
	return {
		get snapshot() {
			return snapshot;
		}
	};
}

afterEach(() => {
	cleanup();
	vi.unstubAllGlobals();
});

// ─── Rendering ────────────────────────────────────────────────────────────────

describe('PersonMentionEditor — rendering', () => {
	it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
		render(PersonMentionEditorHost, {
			initialValue: '',
			initialMentions: [],
			onChange: () => {}
		});
		await expect.element(page.getByRole('textbox')).toBeInTheDocument();
	});

	it('reflects bound initial value as visible text', async () => {
		render(PersonMentionEditorHost, {
			initialValue: 'Hallo Welt',
			initialMentions: [],
			onChange: () => {}
		});
		await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
	});
});

// ─── Typeahead opens on @ ─────────────────────────────────────────────────────

describe('PersonMentionEditor — typeahead', () => {
	it('opens the dropdown when typing @ + query and shows results', async () => {
		mockFetchWithPersons();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');

		await vi.waitFor(async () => {
			await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
		});
	});

	it('hits /api/persons?q= with the typed query', async () => {
		const fetchMock = vi
			.fn()
			.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');

		await vi.waitFor(() => {
			expect(fetchMock).toHaveBeenCalledWith(
				expect.stringContaining('/api/persons?review=true&q=Aug')
			);
		});
	});

	it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
		const fetchMock = vi
			.fn()
			.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');

		await vi.waitFor(() => {
			expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('size=5'));
		});
	});

	it('shows life dates next to the name in the dropdown', async () => {
		mockFetchWithPersons();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');

		await vi.waitFor(async () => {
			await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
		});
	});

	it('shows empty state when no persons match', async () => {
		mockFetchEmpty();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@xyz');

		// The visible empty-state <p> (text-ink-3) shows the copy. The persistent
		// sr-only aria-live region also contains the same copy, so we scope to the
		// visible element to avoid a multi-match resolution in expect.element.
		await vi.waitFor(() => {
			const visibleEmptyP = document.querySelector(
				'[role="listbox"] p.text-ink-3'
			) as HTMLElement | null;
			expect(visibleEmptyP).not.toBeNull();
			expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden');
		});
	});

	it('offers a "create new person" link in the empty state', async () => {
		mockFetchEmpty();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@xyz');

		await vi.waitFor(async () => {
			const link = page.getByRole('link', { name: /Neue Person anlegen/ });
			await expect.element(link).toBeVisible();
			await expect.element(link).toHaveAttribute('href', '/persons/new');
		});
	});
});

// ─── AC-2/3: search input drives the person fetch (debounced) ───────────────

describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
	it('editing the search input fires a debounced fetch with the new query', async () => {
		const fetchMock = vi
			.fn()
			.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		// Open the dropdown so the search input is reachable.
		await userEvent.type(page.getByRole('textbox'), '@');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		const fetchesBeforeSearch = fetchMock.mock.calls.length;

		// `fill` simulates a single input event with the final value — sidesteps
		// per-keystroke timing of userEvent.type so the test can deterministically
		// assert that one input event collapses into one debounced fetch.
		await page.getByRole('searchbox').fill('Walter');

		await vi.waitFor(
			() => {
				expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter'));
			},
			{ timeout: 1000 }
		);

		const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch;
		expect(fetchesAfterSearch).toBe(1);
	});

	it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => {
		// Regression guard: a previous version of PersonMentionEditor had a
		// duplicated `items()` callback in the Tiptap suggestion config that
		// fetched per-keystroke in addition to the debounced search-input fetch
		// (Markus & Felix round-1). To catch that regression, we must NOT
		// subtract any baseline — every fetch from render onwards counts.
		// Sara on PR #629 round 3.
		const fetchMock = vi
			.fn()
			.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		// Open the dropdown, then drive the search input via fill() — sidesteps
		// per-keystroke timing of userEvent.type that Sara flagged round 2.
		await userEvent.type(page.getByRole('textbox'), '@');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});
		await page.getByRole('searchbox').fill('Walter');

		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

		// No baseline subtraction — count ALL /api/persons fetches since render.
		// If the legacy per-keystroke items() callback returns, typing `@` alone
		// would already produce one fetch and `fill('Walter')` another, breaking
		// this assertion.
		const personsFetches = fetchMock.mock.calls.filter(
			([url]) => typeof url === 'string' && url.startsWith('/api/persons')
		);
		expect(personsFetches.length).toBe(1);
	});

	it('clearing the search input clears the list without firing a fetch', async () => {
		const fetchMock = vi
			.fn()
			.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(async () => {
			await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
		});

		const fetchesBeforeClear = fetchMock.mock.calls.length;

		// `fill('')` atomically sets the input value in a single input event —
		// same rationale as the `fill('Walter')` call above. `userEvent.clear`
		// deletes per-keystroke, so intermediate values 'Au'/'A' transit through
		// the bound `searchQuery` and each schedules a debounced fetch. Under
		// CI-runner jitter, if two consecutive keystrokes land more than
		// SEARCH_DEBOUNCE_MS (150 ms) apart, an intermediate timer fires before
		// the input reaches '' and adds a phantom call — unrelated to the
		// contract under test, which is "empty query => no fetch".
		await page.getByRole('searchbox').fill('');

		// Negative assertion: wait past the debounce window to confirm no
		// trailing fetch was scheduled. Removing this wait would mask a
		// re-introduction of the keystroke-driven items() fetch.
		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

		expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear);
		await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
	});
});

// ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ───────────────

describe('PersonMentionEditor — whitespace-only query', () => {
	it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => {
		const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@   ');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

		// Scope to the visible empty-state <p> (text-ink-3) — the persistent
		// sr-only aria-live region above contains the same 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(fetchMock).not.toHaveBeenCalled();
	});
});

// ─── Stale-response race (Sara on PR #629) ───────────────────────────────────

describe('PersonMentionEditor — stale-response race', () => {
	it('discards a stale response that resolves after the search has been cleared', async () => {
		let resolveFetch!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
		const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>(
			(r) => {
				resolveFetch = r;
			}
		);
		const fetchMock = vi.fn().mockReturnValue(pendingResponse);
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		// Open the dropdown and let the debounce fire so a fetch is in flight.
		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(() => {
			expect(fetchMock).toHaveBeenCalledWith(
				expect.stringContaining('/api/persons?review=true&q=Aug')
			);
		});

		// Clear the search input *before* the fetch resolves.
		await userEvent.clear(page.getByRole('searchbox'));
		await expect.element(page.getByRole('searchbox')).toHaveValue('');

		// The stale fetch now resolves with persons. The dropdown must stay empty.
		resolveFetch({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
		// Flush pending Svelte reactivity so any (non-)update from the stale
		// fetch resolution has landed before we assert. expect.element already
		// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.
		await tick();

		await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
	});
});

// ─── Server failure characterization (Sara #2 on PR #629) ───────────────────

describe('PersonMentionEditor — server failure', () => {
	it('on 500 response keeps the dropdown open with the empty-state copy (silent failure pinned; distinct error UX tracked separately)', async () => {
		const fetchMock = vi
			.fn()
			.mockResolvedValue({ ok: false, status: 500, json: vi.fn().mockResolvedValue({}) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

		// Pins current silent-failure behaviour. The day someone implements a
		// distinct error UX (toast / "Suche fehlgeschlagen" copy), this test
		// goes red and forces them to update the assertion. Scope to the
		// visible <p> (text-ink-3) — the persistent sr-only live region
		// above contains the same 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_popup_empty());
	});

	it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => {
		const fetchMock = vi.fn().mockRejectedValue(new TypeError('NetworkError'));
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

		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());
	});
});

// ─── onExit cancels pending debounce (Felix #1 on PR #629) ───────────────────

describe('PersonMentionEditor — onExit cancels pending debounce', () => {
	it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => {
		const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
		vi.stubGlobal('fetch', fetchMock);
		renderHost();

		// Open the dropdown by typing @ + a query in the editor.
		await userEvent.type(page.getByRole('textbox'), '@A');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		// Wait for any in-flight fetch from opening the dropdown to settle.
		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
		const fetchesBeforeEscape = fetchMock.mock.calls.length;

		// Freeze setTimeout so the 150 ms debounce cannot fire before Escape
		// triggers onExit. We install fake timers only now — after the setup
		// above — so that vi.waitFor()'s real-timer polling still worked.
		vi.useFakeTimers();
		try {
			// fill() dispatches the input event synchronously via CDP; by the
			// time the await resolves, onSearch('Walter') has run and the fake
			// debounce timer is set.
			await page.getByRole('searchbox').fill('Walter');
			// Focus the editor so the Escape lands on Tiptap's suggestion handler.
			(page.getByRole('textbox').element() as HTMLElement).focus();
			await userEvent.keyboard('{Escape}');
			// onExit has now called debouncedSearch.cancel(). Advance past the
			// debounce window — the cancelled timer must not fire.
			await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS);
		} finally {
			vi.useRealTimers();
		}

		const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
		const walterFetches = newFetches.filter(
			([url]) => typeof url === 'string' && url.includes('q=Walter')
		);
		expect(walterFetches.length).toBe(0);
	});
});

// ─── AC-1: search input prefilled with text typed after @ ───────────────────

describe('PersonMentionEditor — AC-1: search input prefill', () => {
	it('prefills the dropdown search input with the text typed after @', async () => {
		mockFetchEmpty();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@WdG');

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
		});
	});
});

// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────

describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
	it('stores the typed query as displayName, not the person DB name', async () => {
		mockFetchWithPersons();
		const host = renderHost();

		// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

		// MentionDropdown handles selection via onmousedown (not onclick) to prevent
		// blurring the editor before the selection fires.  userEvent.click() via CDP
		// does not reliably trigger Svelte 5's onmousedown handler when TipTap is
		// mounted — dispatch the MouseEvent directly from browser JS instead.
		const option = (await page
			.getByRole('option', { name: /Auguste Raddatz/ })
			.element()) as HTMLElement;
		option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toHaveLength(1);
			expect(host.snapshot.mentionedPersons[0]).toEqual({
				personId: 'p-aug',
				displayName: 'Aug' // typed text, not "Auguste Raddatz"
			});
		});
	});

	it('regression: text value contains the typed query, not the full DB name', async () => {
		mockFetchWithPersons();
		const host = renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

		await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));

		await vi.waitFor(() => {
			// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
			expect(host.snapshot.value).toContain('@Aug');
			expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
		});
	});

	it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
		mockFetchWithPersons();
		const host = renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

		const option = (await page
			.getByRole('option', { name: /Auguste Raddatz/ })
			.element()) as HTMLElement;
		option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
		});
	});

	it('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => {
		// CWE-400 amplification: the dropdown clips its search input + mirror at
		// 100 chars (Nora #1), but the host editor was passing the unclipped
		// renderProps.query straight through to displayName — so a 105-char
		// @-suffix in the editor could insert a 105-char displayName into the
		// sidecar even though the dropdown only searched the first 100.
		mockFetchWithPersons();
		const host = renderHost();

		// Type @ + 105 'A' chars in the contenteditable. The renderProps.query
		// fed into the command callback derives from the editor text after `@`,
		// not the dropdown's searchbox — so we must drive the editor.
		await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105));

		// The mocked /api/persons returns AUGUSTE for any query — wait for it.
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

		const option = (await page
			.getByRole('option', { name: /Auguste Raddatz/ })
			.element()) as HTMLElement;
		option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toHaveLength(1);
			// Tight assertion: input is 105 chars, cap is exactly 100. Using
			// `toHaveLength(100)` discriminates "clip works" from "clip works
			// AND nothing weakened it to e.g. 95". Sara on PR #629 round 4.
			expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100);
		});
	});

	it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
		mockFetchWithPersons();
		const host = renderHost({
			value: '@Aug ',
			mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
		});

		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

		await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toHaveLength(1);
		});
	});
});

// ─── Keyboard navigation ──────────────────────────────────────────────────────

describe('PersonMentionEditor — keyboard navigation', () => {
	it('Enter selects the highlighted result', async () => {
		mockFetchWithPersons();
		const host = renderHost();

		await userEvent.type(page.getByRole('textbox'), '@A');

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

		await userEvent.keyboard('{Enter}');

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toHaveLength(1);
		});
	});

	it('ArrowDown moves the highlight to the next result', async () => {
		mockFetchWithPersons();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@A');

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});

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

		await vi.waitFor(async () => {
			const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
			await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
		});
	});

	it('Escape closes the dropdown without inserting', async () => {
		mockFetchWithPersons();
		const host = renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');

		await vi.waitFor(async () => {
			await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
		});

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

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
		});

		expect(host.snapshot.mentionedPersons).toEqual([]);
	});
});

// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────

describe('PersonMentionEditor — disabled state', () => {
	it('sets contenteditable=false on the editor when disabled', async () => {
		renderHost({ value: 'Bestehender Text', disabled: true });

		await vi.waitFor(() => {
			const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
			expect(textbox).not.toBeNull();
			expect(textbox!.getAttribute('contenteditable')).toBe('false');
		});
	});

	it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
		renderHost({ disabled: true });

		await vi.waitFor(() => {
			const wrapper = document.querySelector('[aria-disabled="true"]');
			expect(wrapper).not.toBeNull();
		});
	});

	it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
		renderHost({ disabled: false });

		await vi.waitFor(() => {
			const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
			expect(textbox).not.toBeNull();
			expect(textbox!.getAttribute('contenteditable')).toBe('true');
		});
	});
});

// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────

describe('PersonMentionEditor — XSS resistance', () => {
	it('renders a malicious displayName as text, not as HTML elements', async () => {
		// A historical sidecar entry whose displayName contains an HTML payload
		// that would execute if interpolated as raw HTML. Tiptap's renderHTML
		// returns the @-prefixed string as the third tuple entry, which
		// ProseMirror's DOMSerializer treats as a Text node — escaping it.
		const maliciousMention: PersonMention = {
			personId: '00000000-0000-0000-0000-000000000001',
			displayName: '<img src=x onerror=alert(1)>'
		};

		renderHost({
			value: '@<img src=x onerror=alert(1)>',
			mentionedPersons: [maliciousMention]
		});

		await vi.waitFor(() => {
			const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
			expect(textbox).not.toBeNull();
			// No element from the malicious payload should have appeared as a real
			// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
			// paragraphs — that is internal markup and never carries user attrs;
			// guard against the injection by checking the user-controlled attrs.)
			expect(textbox!.querySelector('img[onerror]')).toBeNull();
			expect(textbox!.querySelector('img[src="x"]')).toBeNull();
			expect(textbox!.querySelector('script')).toBeNull();
			// The payload should appear as visible text content instead.
			expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
		});
	});
});

// ─── Placeholder behavior ─────────────────────────────────────────────────────

describe('PersonMentionEditor — placeholder behavior', () => {
	it('sets data-placeholder on the inner element when editor is empty', async () => {
		render(PersonMentionEditorHost, {
			initialValue: '',
			initialMentions: [],
			placeholder: 'Gib Text ein...',
			onChange: () => {}
		});
		await vi.waitFor(() => {
			const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
			expect(inner).not.toBeNull();
			expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
		});
	});

	it('omits data-placeholder on the inner element when editor has content', async () => {
		render(PersonMentionEditorHost, {
			initialValue: 'Bestehender Text',
			initialMentions: [],
			placeholder: 'Gib Text ein...',
			onChange: () => {}
		});
		await vi.waitFor(() => {
			const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
			expect(inner).not.toBeNull();
			expect(inner!.hasAttribute('data-placeholder')).toBe(false);
		});
	});
});

// ─── i18n message content ─────────────────────────────────────────────────────

describe('PersonMentionEditor — i18n message content', () => {
	it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
		expect(m.transcription_block_placeholder()).toContain('@');
	});
});

// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────

describe('PersonMentionEditor — touch target', () => {
	it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
		mockFetchWithPersons();
		renderHost();

		await userEvent.type(page.getByRole('textbox'), '@Aug');

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option').first()).toBeVisible();
		});

		const option = document.querySelector('[role="option"]') as HTMLElement;
		expect(option).not.toBeNull();
		expect(option.className).toContain('min-h-[44px]');
	});
});

// ─── #628: re-edit an existing @mention via the pencil affordance ─────────────

describe('PersonMentionEditor — #628 re-edit pencil', () => {
	// A saved mention seeded into the editor: text "@Aug " + sidecar. The typed
	// displayName is "Aug" (short form, #380 AC-1), distinct from the DB name.
	const SAVED = { personId: 'p-aug', displayName: 'Aug' };

	function editPencil() {
		return page.getByRole('button', { name: m.person_mention_edit_label() });
	}

	async function renderSavedMention() {
		const host = renderHost({ value: '@Aug ', mentionedPersons: [SAVED] });
		await expect.element(editPencil()).toBeInTheDocument();
		return host;
	}

	async function pencilElement(): Promise<HTMLElement> {
		return (await editPencil().element()) as HTMLElement;
	}

	function clickPencil(btn: HTMLElement) {
		// Native dispatch — CDP userEvent.click is unreliable for handlers attached
		// imperatively inside a ProseMirror NodeView (same rationale as the option
		// mousedown dispatch elsewhere in this file).
		btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
	}

	async function pickOption(name: RegExp) {
		const el = (await page.getByRole('option', { name }).element()) as HTMLElement;
		el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
	}

	// AC-1 ----------------------------------------------------------------------

	it('renders an edit pencil on a saved mention, labelled via aria-label', async () => {
		await renderSavedMention();
		await expect.element(editPencil()).toBeInTheDocument();
	});

	it('the pencil is keyboard-focusable (tabindex 0, focus lands on it)', async () => {
		await renderSavedMention();
		const btn = await pencilElement();
		expect(btn.getAttribute('tabindex')).toBe('0');
		btn.focus();
		expect(document.activeElement).toBe(btn);
	});

	it('hides the pencil by default and reveals it on hover + focus-within (opacity swap)', async () => {
		await renderSavedMention();
		const btn = await pencilElement();
		// Reveal is a class-driven instant opacity swap (no Tailwind CSS in the
		// component test env — assert the mechanism structurally, as the existing
		// touch-target test does for min-h-[44px]).
		expect(btn.className).toContain('opacity-0');
		expect(btn.className).toContain('group-hover/mention:opacity-100');
		expect(btn.className).toContain('group-focus-within/mention:opacity-100');
	});

	it('renders the pencil in an always-present fixed-width slot (no reflow)', async () => {
		await renderSavedMention();
		const slot = document.querySelector('.mention-edit-slot') as HTMLElement | null;
		expect(slot).not.toBeNull();
		expect(slot!.className).toContain('w-4');
	});

	// AC-2 ----------------------------------------------------------------------

	it('opens the dropdown pre-filled with the stored displayName when the pencil is clicked', async () => {
		mockFetchEmpty();
		await renderSavedMention();
		clickPencil(await pencilElement());

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
		});
	});

	it('opens the dropdown when the pencil is activated by keyboard (Enter)', async () => {
		mockFetchEmpty();
		await renderSavedMention();
		const btn = await pencilElement();
		btn.focus();
		btn.dispatchEvent(
			new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
		);

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
		});
	});

	// AC-3 ----------------------------------------------------------------------

	it('relinks in place: picking a different person swaps data-person-id', async () => {
		mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
		await renderSavedMention();
		clickPencil(await pencilElement());

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});
		await pickOption(/Anna Schmidt/);

		await vi.waitFor(() => {
			const token = document.querySelector('[data-type="mention"]') as HTMLElement;
			expect(token.getAttribute('data-person-id')).toBe('p-anna');
		});
	});

	it('relink preserves the displayed token text exactly (only personId changes)', async () => {
		mockFetchWithPersons();
		const host = await renderSavedMention();
		clickPencil(await pencilElement());

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});
		await pickOption(/Anna Schmidt/);

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
		});
	});

	// Serializer regression — text byte-identical, only personId swapped --------

	it('serializer regression: re-link keeps the text byte-identical, swaps only personId', async () => {
		mockFetchWithPersons();
		const host = await renderSavedMention();
		clickPencil(await pencilElement());

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});
		await pickOption(/Anna Schmidt/);

		await vi.waitFor(() => {
			expect(host.snapshot.value).toBe('@Aug ');
			expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
		});
	});

	// AC-5 — edited-then-picked (highest value): personId updates, text invariant

	it('AC-5: editing the search input then picking sets the new personId', async () => {
		mockFetchWithPersons();
		const host = await renderSavedMention();
		clickPencil(await pencilElement());
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		await page.getByRole('searchbox').fill('Anna');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});
		await pickOption(/Anna Schmidt/);

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
		});
	});

	it('AC-5: editing the search input then picking keeps the ORIGINAL displayName', async () => {
		mockFetchWithPersons();
		const host = await renderSavedMention();
		clickPencil(await pencilElement());
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		await page.getByRole('searchbox').fill('Anna');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});
		await pickOption(/Anna Schmidt/);

		await vi.waitFor(() => {
			// The #380 AC-1 invariant wins over the edited search input.
			expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
			expect(host.snapshot.value).toBe('@Aug ');
			expect(host.snapshot.value).not.toContain('@Anna');
		});
	});

	// AC-4 — dismiss leaves the node byte-identical on BOTH close paths ---------

	function dismissButton() {
		return page.getByRole('button', { name: m.person_mention_dismiss_label() });
	}

	it('AC-4: Escape closes the re-edit dropdown leaving the node byte-identical', async () => {
		mockFetchWithPersons();
		const host = await renderSavedMention();
		clickPencil(await pencilElement());
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		const search = (await page.getByRole('searchbox').element()) as HTMLElement;
		search.dispatchEvent(
			new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
		);

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
		});
		expect(host.snapshot.value).toBe('@Aug ');
		expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
	});

	it('AC-4: the visible dismiss control closes the re-edit dropdown leaving the node byte-identical', async () => {
		mockFetchWithPersons();
		const host = await renderSavedMention();
		clickPencil(await pencilElement());
		await expect.element(dismissButton()).toBeVisible();

		const dismissEl = (await dismissButton().element()) as HTMLElement;
		dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
		});
		expect(host.snapshot.value).toBe('@Aug ');
		expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
	});

	// AC-9 parity — the re-edit dropdown is keyboard-operable on its own --------

	it('re-edit open focuses the search input so keyboard users land in the field', async () => {
		mockFetchEmpty();
		await renderSavedMention();
		clickPencil(await pencilElement());
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});

		const search = (await page.getByRole('searchbox').element()) as HTMLElement;
		await vi.waitFor(() => {
			expect(document.activeElement).toBe(search);
		});
	});

	it('re-edit dropdown is keyboard-navigable: ArrowDown then Enter relinks', async () => {
		mockFetchWithPersons(); // [AUGUSTE (p-aug, highlighted), ANNA (p-anna)]
		const host = await renderSavedMention();
		clickPencil(await pencilElement());
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});

		const search = (await page.getByRole('searchbox').element()) as HTMLElement;
		search.dispatchEvent(
			new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
		);
		search.dispatchEvent(
			new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
		);

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
			expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
		});
	});

	// The × control is shared with the fresh-@ dropdown ------------------------

	it('the fresh-@ dropdown also exposes the shared dismiss control', async () => {
		mockFetchWithPersons();
		renderHost();
		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await expect.element(dismissButton()).toBeVisible();
	});
});

// ─── #628 AC-6: at most one mention dropdown open at a time ───────────────────

describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => {
	const TWO_MENTIONS = [
		{ personId: 'p-aug', displayName: 'Aug' },
		{ personId: 'p-bert', displayName: 'Bert' }
	];

	function mentionButtons(): HTMLButtonElement[] {
		return Array.from(
			document.querySelectorAll('[data-type="mention"] button')
		) as HTMLButtonElement[];
	}

	function click(el: HTMLElement) {
		el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
	}

	function listboxCount() {
		return document.querySelectorAll('[role="listbox"]').length;
	}

	it('pencil → pencil: opening a second mention pencil closes the first', async () => {
		mockFetchEmpty();
		renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
		await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
		const [pencilA, pencilB] = mentionButtons();

		click(pencilA);
		await vi.waitFor(() => expect(listboxCount()).toBe(1));

		click(pencilB);
		await vi.waitFor(async () => {
			expect(listboxCount()).toBe(1);
			await expect.element(page.getByRole('searchbox')).toHaveValue('Bert');
		});
	});

	it('fresh-@ → pencil: activating a pencil closes the open fresh-@ dropdown', async () => {
		mockFetchEmpty();
		renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
		await vi.waitFor(() => expect(mentionButtons().length).toBe(1));

		// Open a fresh-@ dropdown (prefilled with the typed query "x").
		await userEvent.type(page.getByRole('textbox'), '@x');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toHaveValue('x');
		});

		// Now activate the saved mention's pencil — the fresh dropdown must give way.
		click(mentionButtons()[0]);
		await vi.waitFor(async () => {
			expect(listboxCount()).toBe(1);
			await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
		});
	});

	it('pencil → fresh-@: typing @ in the editor closes the open re-edit dropdown', async () => {
		mockFetchEmpty();
		renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
		await vi.waitFor(() => expect(mentionButtons().length).toBe(1));

		click(mentionButtons()[0]);
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
		});

		// Typing @ in the editor starts a fresh suggestion — the re-edit dropdown
		// must give way to it (single owner, both orderings).
		await userEvent.type(page.getByRole('textbox'), '@y');
		await vi.waitFor(async () => {
			expect(listboxCount()).toBe(1);
			await expect.element(page.getByRole('searchbox')).toHaveValue('y');
		});
	});

	it('regression: fresh-@ still inserts the typed text as displayName (#380 AC-1 intact)', async () => {
		mockFetchWithPersons();
		const host = renderHost();
		await userEvent.type(page.getByRole('textbox'), '@Aug');
		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
		});
		const option = (await page
			.getByRole('option', { name: /Auguste Raddatz/ })
			.element()) as HTMLElement;
		option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));

		await vi.waitFor(() => {
			expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
		});
	});

	it('discards a stale fetch from a superseded open (open A → open B → A resolves)', async () => {
		// A's fetch hangs; B's resolves empty. When A finally resolves with a
		// person, the request-token guard must discard it so B's dropdown is not
		// repopulated. Deterministic — no sleeps.
		let resolveA!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
		const pendingA = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>((r) => {
			resolveA = r;
		});
		let call = 0;
		const fetchMock = vi.fn().mockImplementation(() => {
			call += 1;
			if (call === 1) return pendingA;
			return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) });
		});
		vi.stubGlobal('fetch', fetchMock);

		renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
		await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
		const [pencilA, pencilB] = mentionButtons();

		click(pencilA);
		await vi.waitFor(() => {
			expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Aug'));
		});

		click(pencilB);
		await vi.waitFor(() => {
			expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Bert'));
		});

		// A's stale response arrives last — it must NOT repopulate B's dropdown.
		resolveA({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
		await tick();
		await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
	});
});

// ─── #628 AC-7: pencil is inert when the editor is disabled (WCAG 2.1.1) ──────

describe('PersonMentionEditor — #628 AC-7 disabled editor', () => {
	function mentionButton(): HTMLButtonElement | null {
		return document.querySelector('[data-type="mention"] button');
	}

	it('renders the pencil disabled, aria-disabled and out of tab order when disabled', async () => {
		renderHost({
			value: '@Aug ',
			mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }],
			disabled: true
		});
		await vi.waitFor(() => {
			const btn = mentionButton();
			expect(btn).not.toBeNull();
			expect(btn!.disabled).toBe(true);
			expect(btn!.getAttribute('aria-disabled')).toBe('true');
			expect(btn!.tabIndex).toBe(-1);
		});
	});

	it('activating the disabled pencil (keyboard or pointer) mounts no dropdown', async () => {
		mockFetchWithPersons();
		renderHost({
			value: '@Aug ',
			mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }],
			disabled: true
		});
		await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
		const btn = mentionButton()!;

		btn.dispatchEvent(
			new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
		);
		btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
		// Give any (incorrectly) scheduled open a chance to mount before asserting.
		await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));

		expect(document.querySelector('[role="listbox"]')).toBeNull();
	});
});

// ─── #628 AC-8: no pencil / dropdown where there is no mention under the caret ─

describe('PersonMentionEditor — #628 AC-8 no mention under caret', () => {
	it('plain text shows no edit pencil and no dropdown', async () => {
		renderHost({ value: 'Nur Text ohne Erwaehnung', mentionedPersons: [] });
		await expect.element(page.getByRole('textbox')).toBeInTheDocument();
		expect(document.querySelector('[data-type="mention"]')).toBeNull();
		expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(0);
		expect(document.querySelector('[role="listbox"]')).toBeNull();
	});

	it('two adjacent mentions each keep their own pencil and auto-open nothing', async () => {
		renderHost({
			value: '@Aug@Bert ',
			mentionedPersons: [
				{ personId: 'p-aug', displayName: 'Aug' },
				{ personId: 'p-bert', displayName: 'Bert' }
			]
		});
		await vi.waitFor(() =>
			expect(document.querySelectorAll('[data-type="mention"]').length).toBe(2)
		);
		// One pencil per token (no spurious pencil for the gap between them) ...
		expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(2);
		// ... and nothing opens just from caret position.
		expect(document.querySelector('[role="listbox"]')).toBeNull();
	});

	it('a mention at the document start still renders its pencil', async () => {
		renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
		await vi.waitFor(() =>
			expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
		);
	});
});

// ─── #628 security: query clip + personId provenance ──────────────────────────

describe('PersonMentionEditor — #628 security', () => {
	const OVERSIZED = 'A'.repeat(150);

	function mentionButton(): HTMLButtonElement {
		return document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
	}

	it('clips an oversized stored displayName to MAX_QUERY_LENGTH in the search input, node text untouched', async () => {
		mockFetchWithPersons();
		const host = renderHost({
			value: `@${OVERSIZED} `,
			mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }]
		});
		await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
		mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('searchbox')).toBeVisible();
		});
		const search = (await page.getByRole('searchbox').element()) as HTMLInputElement;
		expect(search.value.length).toBe(100);
		// The preserved node text must NOT be truncated — only the search query is.
		expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150);
	});

	it('re-link derives personId solely from the selected Person, never the DOM/search text', async () => {
		mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
		const host = renderHost({
			value: `@${OVERSIZED} `,
			mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }]
		});
		await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
		mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));

		await vi.waitFor(async () => {
			await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
		});
		const anna = (await page
			.getByRole('option', { name: /Anna Schmidt/ })
			.element()) as HTMLElement;
		anna.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));

		await vi.waitFor(() => {
			// id comes from the picked Person (p-anna), not the reflected p-aug
			// nor the clipped search text; the long displayName stays untouched.
			expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
			expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150);
		});
	});
});

// ─── #628 NFR a11y: editing context announced via the existing live region ───

describe('PersonMentionEditor — #628 editing announce', () => {
	it('re-edit open announces the editing context through the single persistent live region', async () => {
		mockFetchEmpty();
		renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
		await vi.waitFor(() =>
			expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
		);
		(document.querySelector('[data-type="mention"] button') as HTMLElement).dispatchEvent(
			new MouseEvent('click', { bubbles: true, cancelable: true })
		);

		await vi.waitFor(() => {
			const liveRegions = document.querySelectorAll('[role="listbox"] [aria-live="polite"]');
			// Exactly ONE live region (no second announcer — avoids double-announce).
			expect(liveRegions.length).toBe(1);
			expect(liveRegions[0].textContent ?? '').toContain(
				m.person_mention_editing_announce({ displayName: 'Aug' })
			);
		});
	});
});

// ─── #628 review fixes: hidden-pencil hit-testing + mid-session disable ───────

describe('PersonMentionEditor — #628 review fixes', () => {
	it('keeps the hidden pencil out of hit-testing (pointer-events-none until revealed)', async () => {
		// opacity-0 alone still hit-tests, and the 44px button overhangs adjacent
		// text — so a click in the gap between mentions would otherwise land on the
		// invisible pencil and open the dropdown (AC-8). It must be pointer-events-none
		// while hidden and only become clickable together with the opacity reveal.
		renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
		await vi.waitFor(() =>
			expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
		);
		const btn = document.querySelector('[data-type="mention"] button') as HTMLElement;
		expect(btn.className).toContain('pointer-events-none');
		expect(btn.className).toContain('group-hover/mention:pointer-events-auto');
		expect(btn.className).toContain('group-focus-within/mention:pointer-events-auto');
	});

	it('re-syncs the pencil to inert when the editor is disabled mid-session', async () => {
		// editor.setEditable() emits "update", not a transaction — the NodeView must
		// listen on "update" or a runtime disable flip leaves the pencil stale.
		let setDisabled!: (value: boolean) => void;
		render(PersonMentionEditorHost, {
			initialValue: '@Aug ',
			initialMentions: [{ personId: 'p-aug', displayName: 'Aug' }],
			disabled: false,
			onChange: () => {},
			onReady: (api: { setDisabled: (value: boolean) => void }) => {
				setDisabled = api.setDisabled;
			}
		});

		await vi.waitFor(() => {
			const btn = document.querySelector(
				'[data-type="mention"] button'
			) as HTMLButtonElement | null;
			expect(btn).not.toBeNull();
			expect(btn!.disabled).toBe(false);
		});

		setDisabled(true);

		await vi.waitFor(() => {
			const btn = document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
			expect(btn.disabled).toBe(true);
			expect(btn.getAttribute('aria-disabled')).toBe('true');
			expect(btn.tabIndex).toBe(-1);
		});
	});
});
