<script lang="ts">
import type { components } from '$lib/generated/api';
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
// Layered defence cap on the @mention search query length (CWE-400
// amplification). The <input maxlength> attribute below caps direct
// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror
// $effect -> searchQuery) is not covered by `maxlength` since the
// contenteditable has no such enforcement. Clipping at the mirror keeps
// the cap honest from both paths. Tracked server-side separately.
// Nora #1 on PR #629. Hoisted to mentionConstants.ts so the host editor
// (PersonMentionEditor) can clip the inserted displayName to the same cap
// — see Felix #3 on PR #629.
import { MAX_QUERY_LENGTH } from './mentionConstants';

// PersonSummaryDTO, not Person: /api/persons list items are the summary projection.
// Typing them as the full entity hid a runtime bug (missing date fields, #812).
type Person = components['schemas']['PersonSummaryDTO'];

// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
// proxy reactivity propagates the change here. This is the supported way to
// update an imperatively-mounted Svelte 5 component — `mount` does not return
// settable prop accessors.
type DropdownState = {
	items: Person[];
	command: (item: Person) => void;
	clientRect: (() => DOMRect | null) | null;
};

let {
	model,
	editorQuery = '',
	onSearch = () => {},
	ondismiss = () => {},
	focusOnMount = false,
	editingDisplayName = ''
}: {
	model: DropdownState;
	/** Text typed after `@` in the host editor. Mirrors into the search input
	 *  until the user takes manual ownership by typing into the input itself. */
	editorQuery?: string;
	onSearch?: (query: string) => void;
	/** Closes the dropdown without touching the document — invoked by the visible
	 *  × dismiss control and by Escape on the re-edit path (#628 AC-4). */
	ondismiss?: () => void;
	/** Re-edit (#628) opens with the search field focused; the fresh-@ path keeps
	 *  focus in the editor so typing flows to the contenteditable. */
	focusOnMount?: boolean;
	/** When set (re-edit, #628), the persistent live region announces the editing
	 *  context for this mention. Routed through the SAME aria-live region as the
	 *  result count — never a second live region (avoids the double-announce bug). */
	editingDisplayName?: string;
} = $props();

let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
let userHasEdited = $state(false);
let searchInput: HTMLInputElement;

onMount(() => {
	if (focusOnMount) searchInput?.focus();
});

// Re-edit has no Tiptap suggestion plugin to forward keys, so the search input
// handles its own navigation: Escape dismisses (and is prevented from clearing
// the native search field), Arrow/Enter reuse the exported selection logic.
function handleSearchKeydown(event: KeyboardEvent) {
	if (event.key === 'Escape') {
		event.preventDefault();
		ondismiss();
		return;
	}
	if (onKeyDown(event)) event.preventDefault();
}

// Intent-revealing alias used by both the persistent aria-live announcer and
// the visible empty-state copy. Folding the duplicated rule into one $derived
// keeps the two branches in lockstep. Felix #3 on PR #629 round 4.
const isQueryEmpty = $derived(searchQuery.trim() === '');

// Mirror the editor's typed text until the user takes ownership.
//
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
// `bind:value` on the <input> below, so it needs to be a mutable `$state`.
// A `$derived` would be read-only and would clobber direct user edits on
// every editor keystroke. The `userHasEdited` latch pins ownership once the
// user types into the input. Felix #1 on PR #629.
$effect(() => {
	if (!userHasEdited) {
		searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
	}
});

// Fire onSearch whenever the effective query changes — covers both the
// editor mirror and direct input edits. This is the only place onSearch
// fires; when the dropdown is unmounted, the effect is disposed and no
// further fetches occur.
$effect(() => {
	onSearch(searchQuery);
});

// highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list).
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
// is the correct pattern here. The autofixer suggestion to use $derived does not
// apply: the mutation in onKeyDown is not a derivation.
let highlightedIndex = $state(0);

$effect(() => {
	// Read model.items to subscribe; reset index whenever the list is replaced.
	void model.items;
	highlightedIndex = 0;
});

// ---------------------------------------------------------------------------
// Positioning — flip strategy: open upward when there is not enough room
// below the cursor to show the dropdown without clipping the viewport.
// ---------------------------------------------------------------------------

type Position = {
	top: string | null;
	bottom: string | null;
	left: string;
};

const DROPDOWN_CLEARANCE_PX = 200;

const position = $derived.by<Position>(() => {
	const cr = model.clientRect;
	if (!cr) return { top: '0px', bottom: null, left: '0px' };
	const rect = cr();
	if (!rect) return { top: '0px', bottom: null, left: '0px' };

	// Some editors report a caret DOMRect with zero width; fall back to rect.x.
	const left = `${rect.width === 0 ? rect.x : rect.left}px`;

	if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
		// Not enough space below — anchor bottom of dropdown to top of caret.
		return {
			top: null,
			bottom: `${window.innerHeight - rect.top}px`,
			left
		};
	}

	return { top: `${rect.bottom}px`, bottom: null, left };
});

// ---------------------------------------------------------------------------
// Keyboard handler — exported so Tiptap's render() can forward events.
// Returns true when the event is consumed (prevents the editor's default).
// ---------------------------------------------------------------------------

export function onKeyDown(event: KeyboardEvent): boolean {
	const len = model.items.length;
	if (event.key === 'ArrowDown') {
		highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
		return true;
	}

	if (event.key === 'ArrowUp') {
		highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
		return true;
	}

	if (event.key === 'Enter') {
		const selected = model.items[highlightedIndex];
		if (selected) {
			model.command(selected);
		}
		return true;
	}

	// Escape: let the suggestion plugin handle it (return false = not consumed).
	return false;
}

function selectItem(item: Person) {
	model.command(item);
}
</script>

<!--
	Mounted imperatively to document.body by the Tiptap suggestion plugin.
	Positioned absolutely relative to the viewport using inline styles derived
	from the Tiptap clientRect() callback.

	SECURITY: This component receives pre-filtered Person[] items from the
	parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
	proxy injecting the auth_token cookie as the Authorization header.
	Mounted in transcribe mode behind WRITE_ALL — never reachable to
	unauthenticated users.
-->
<div
	class="fixed z-50 w-72 max-w-[calc(100vw-1rem)] overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
	role="listbox"
	aria-label={m.person_mention_btn_label()}
	style:top={position.top}
	style:bottom={position.bottom}
	style:left={position.left}
>
	<div class="border-b border-line px-3 py-2">
		<label class="sr-only" for="mention-search">{m.person_mention_search_label()}</label>
		<div class="flex items-center gap-2">
			<svg
				aria-hidden="true"
				viewBox="0 0 24 24"
				fill="none"
				stroke="currentColor"
				stroke-width="2"
				class="h-5 w-5 shrink-0 text-ink-2"
			>
				<circle cx="11" cy="11" r="7" />
				<path d="m20 20-3.5-3.5" stroke-linecap="round" />
			</svg>
			<input
				bind:this={searchInput}
				id="mention-search"
				type="search"
				data-test-search-input
				maxlength={MAX_QUERY_LENGTH}
				class="min-h-[44px] w-full bg-transparent font-sans text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
				placeholder={m.person_mention_search_prompt()}
				bind:value={searchQuery}
				oninput={() => {
					userHasEdited = true;
				}}
				onkeydown={handleSearchKeydown}
				onmousedown={(e) => e.stopPropagation()}
			/>
			<!--
				Visible dismiss control (#628 AC-4) — shared by the fresh-@ and the
				re-edit paths. onmousedown preventDefault keeps the editor selection
				from blurring before onclick fires; onclick handles both pointer and
				keyboard (Enter/Space) activation.
			-->
			<button
				type="button"
				data-test-dismiss
				aria-label={m.person_mention_dismiss_label()}
				class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm text-ink-2 hover:bg-canvas focus:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
				onmousedown={(e) => e.preventDefault()}
				onclick={() => ondismiss()}
			>
				<svg
					aria-hidden="true"
					viewBox="0 0 24 24"
					fill="none"
					stroke="currentColor"
					stroke-width="2"
					class="h-5 w-5"
				>
					<path d="M6 6l12 12M18 6 6 18" stroke-linecap="round" />
				</svg>
			</button>
		</div>
	</div>
	<!--
		Persistent aria-live region — lives ABOVE the conditional branches so the
		element never unmounts when items transition between empty and populated.
		VoiceOver in particular swallows announcements from freshly-mounted live
		regions, and the previous (conditional-inside) markup silently dropped
		the "N persons found" announcement when results populated. Leonie #3 on
		PR #629 round 3.
	-->
	<p class="sr-only" aria-live="polite">
		{#if model.items.length === 0}
			{#if editingDisplayName && !userHasEdited}
				{m.person_mention_editing_announce({ displayName: editingDisplayName })}
			{:else}
				{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
			{/if}
		{:else if model.items.length === 1}
			{m.person_mention_results_count_singular()}
		{:else}
			{m.person_mention_results_count_plural({ count: model.items.length })}
		{/if}
	</p>
	{#if model.items.length === 0}
		<!--
			Visible empty-state copy — visual-only. The persistent sr-only <p>
			above is the sole AT announcer; this one is hidden from screen readers
			via aria-hidden="true" so VoiceOver does not double-announce
			(NVDA de-dups, VoiceOver does not). Leonie S-2 on PR #629 round 4.
			Do NOT add an aria-live attribute here — that would re-introduce
			the duplicate announcement.
		-->
		<p aria-hidden="true" class="px-3 py-2.5 font-sans text-sm text-ink-3">
			{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
		</p>
		<!--
			Empty-state escape hatch — without it the transcriber has to close
			the dropdown, navigate to /persons/new, come back, and re-type the
			query. target=_blank keeps the document and editor state intact;
			rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
		-->
		<a
			href="/persons/new"
			target="_blank"
			rel="noopener noreferrer"
			class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
			onmousedown={(e) => e.preventDefault()}
		>
			{m.person_mention_create_new()}
			<span aria-hidden="true">→</span>
		</a>
	{:else}
		{#each model.items as person, i (person.id)}
			{@const lifeDates = formatLifeDateRange(
				person.birthDate,
				person.birthDatePrecision,
				person.deathDate,
				person.deathDatePrecision
			)}
			<div
				class={[
					'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
					// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
					// Contrast for a meaningful keyboard-highlight indicator. brand-navy
					// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
					i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
				]}
				role="option"
				aria-selected={i === highlightedIndex}
				data-test-person-id={person.id}
				tabindex="-1"
				onmousedown={(e) => {
					// Prevent blur on the editor before the selection fires.
					e.preventDefault();
					selectItem(person);
				}}
			>
				<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
				{#if lifeDates}
					<!-- whitespace-normal: a DAY-precision range (~37 chars) wraps to a second
					     line at 320px instead of being clipped by truncate. -->
					<span class="font-sans text-xs whitespace-normal text-ink-3">
						{lifeDates}
					</span>
				{/if}
			</div>
		{/each}
	{/if}
</div>
