<script lang="ts">
import { onMount, onDestroy, mount, unmount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Mention } from '@tiptap/extension-mention';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/shared/types';
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
import { debounce } from '$lib/shared/utils/debounce';
import MentionDropdown from './MentionDropdown.svelte';
import { createMentionNodeView } from './mentionNodeView';
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } 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'];

type Props = {
	value: string;
	mentionedPersons: PersonMention[];
	placeholder?: string;
	disabled?: boolean;
	onfocus?: () => void;
	onblur?: () => void;
	onSelectionChange?: (text: string | null) => void;
};

let {
	value = $bindable(''),
	mentionedPersons = $bindable([]),
	placeholder = '',
	disabled = false,
	onfocus,
	onblur,
	onSelectionChange
}: Props = $props();

let editorEl: HTMLDivElement;
let editor: Editor | null = null;
// Hoisted so onDestroy can guarantee the imperatively-mounted dropdown is
// torn down even if Tiptap's suggestion plugin onExit didn't fire (e.g. when
// the host component is unmounted while the dropdown is still open).
let mountedDropdown: object | null = null;
// Hoisted so onDestroy can cancel any pending fetch — otherwise a trailing
// debounced search can fire after the editor is gone and pollute later tests.
let cancelPendingSearch: (() => void) | null = null;

// Single reactive state object shared with MentionDropdown. Mutating these
// fields propagates to the mounted dropdown via Svelte's $state proxy —
// this is required because Svelte 5's `mount()` does NOT return prop
// accessors; setting `instance.items = ...` does not update the component.
let dropdownState = $state<{
	items: Person[];
	command: (item: Person) => void;
	clientRect: (() => DOMRect | null) | null;
	editorQuery: string;
}>({
	items: [],
	command: () => {},
	clientRect: null,
	editorQuery: ''
});

type DropdownExports = {
	onKeyDown: (event: KeyboardEvent) => boolean;
};

type CommitFn = (item: Person) => void;
type RectGetter = (() => DOMRect | null) | null;

// Tiptap's SuggestionProps types `command` against the default MentionNodeAttrs
// (id/label). Our custom Mention extension uses personId/displayName, so the
// render() adapter casts the renderProps to this looser shape locally.
type LooseRenderProps = {
	items: unknown;
	command: (props: { personId: string; displayName: string }) => void;
	query: string;
	clientRect?: (() => DOMRect | null) | null;
};

// Single owner of the dropdown lifecycle. Both entry points route through one
// controller — Tiptap's fresh-`@` suggestion (via the render() adapter below)
// and (#628) the pencil re-edit affordance — so at most one dropdown is ever
// mounted (the AC-6 single-dropdown invariant). open() closes any prior
// dropdown first; render() is a thin adapter over open()/update()/close().
type OpenOptions = { focusOnMount?: boolean; editingDisplayName?: string };

type MentionController = {
	open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
	update: (clientRect: RectGetter, query: string, commit: CommitFn) => void;
	close: () => void;
	onKeyDown: (event: KeyboardEvent) => boolean;
};

function createMentionController(): MentionController {
	// Request-token guard: every search AND every open bumps `requestId`;
	// runSearch captures the id active when its fetch starts and discards the
	// response if a newer search/open has happened since. Without this, a late
	// response can repopulate a dropdown the user already moved on from (e.g.
	// open A → open B → A's stale response). Sara on PR #629.
	let requestId = 0;
	let exports: DropdownExports | null = null;

	const runSearch = async (query: string) => {
		const id = requestId;
		try {
			// Defensive client-side cap — server-side enforcement is tracked
			// separately. Markus on PR #629.
			const res = await fetch(
				`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
			);
			if (id !== requestId) return;
			if (!res.ok) {
				dropdownState.items = [];
				return;
			}
			const body = (await res.json()) as { items?: Person[] };
			if (id !== requestId) return;
			dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
		} catch {
			if (id !== requestId) return;
			dropdownState.items = [];
		}
	};

	const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
	// Hoisted so onDestroy can cancel any pending fetch even after the editor is
	// gone — a trailing debounced search would otherwise pollute later tests.
	cancelPendingSearch = () => debouncedSearch.cancel();

	const onSearch = (query: string) => {
		requestId++;
		if (query.trim() === '') {
			debouncedSearch.cancel();
			dropdownState.items = [];
			return;
		}
		debouncedSearch(query);
	};

	const close = () => {
		debouncedSearch.cancel();
		if (mountedDropdown) {
			unmount(mountedDropdown);
			mountedDropdown = null;
			exports = null;
		}
	};

	// Clip the query once so the dropdown's editor-mirror sees the capped value
	// (CWE-400 amplification — Nora #1 / Felix #3 on PR #629). The commit closure
	// is supplied by the caller, so the fresh-insert path keeps clipping the
	// inserted displayName while the relink path (#628) preserves the stored
	// displayName by construction.
	const writeState = (clientRect: RectGetter, query: string, commit: CommitFn) => {
		dropdownState.command = commit;
		dropdownState.clientRect = clientRect;
		dropdownState.editorQuery = query.slice(0, MAX_QUERY_LENGTH);
	};

	// Close the dropdown without touching the document and return focus to the
	// editor — wired to the dropdown's × control and the re-edit Escape path.
	const dismiss = () => {
		close();
		editor?.commands.focus();
	};

	const open = (
		clientRect: RectGetter,
		query: string,
		commit: CommitFn,
		opts: OpenOptions = {}
	) => {
		// Single-dropdown invariant: tear down any open dropdown before mounting a
		// new one, and bump the request token so a previous open's in-flight fetch
		// cannot repopulate this dropdown.
		close();
		requestId++;
		dropdownState.items = [];
		writeState(clientRect, query, commit);
		const mounted = mount(MentionDropdown, {
			target: document.body,
			props: {
				model: dropdownState,
				ondismiss: dismiss,
				focusOnMount: opts.focusOnMount ?? false,
				editingDisplayName: opts.editingDisplayName ?? '',
				// MentionDropdown reads `editorQuery` off the shared state proxy via
				// this getter — Svelte 5's mount() does not expose settable prop
				// accessors, so we route through the proxy (same pattern as items).
				get editorQuery() {
					return dropdownState.editorQuery;
				},
				onSearch
			}
		});
		mountedDropdown = mounted as object;
		exports = mounted as unknown as DropdownExports;
	};

	const update = (clientRect: RectGetter, query: string, commit: CommitFn) => {
		writeState(clientRect, query, commit);
	};

	const onKeyDown = (event: KeyboardEvent) => exports?.onKeyDown(event) ?? false;

	return { open, update, close, onKeyDown };
}

const controller = createMentionController();

// Re-link an existing mention in place (#628 AC-3/AC-5). Captures the node's
// pos when the pencil opens and swaps ONLY personId via setNodeMarkup, so the
// stored displayName is preserved by construction — never round-tripped through
// insertContentAt (which would rebind displayName to the search query). The new
// personId comes solely from the selected Person (anti-spoof — never from the
// reflected data-person-id or the search input).
function commitRelink(pos: number): CommitFn {
	return (item: Person) => {
		if (!editor || !item.id) return;
		const personId = item.id;
		editor
			.chain()
			.focus()
			.command(({ tr, state }) => {
				const node = state.doc.nodeAt(pos);
				if (!node || node.type.name !== 'mention') return false;
				tr.setNodeMarkup(pos, undefined, { ...node.attrs, personId });
				return true;
			})
			.run();
		controller.close();
	};
}

// Entry point handed to the mention NodeView's pencil. Opens the one dropdown
// (closing any other first — AC-6) anchored at the mention and pre-filled with
// the stored displayName.
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
	if (!editor || !editor.isEditable) return;
	controller.open(getRect, displayName, commitRelink(pos), {
		focusOnMount: true,
		editingDisplayName: displayName
	});
}

onMount(() => {
	// Custom Mention node: uses personId / displayName instead of the
	// default id / label attribute names so the mentionSerializer can
	// round-trip correctly without attribute remapping.
	const CustomMention = Mention.extend({
		addAttributes() {
			return {
				personId: {
					default: null,
					parseHTML: (el) => el.getAttribute('data-person-id'),
					renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
				},
				displayName: {
					default: null,
					parseHTML: (el) => el.getAttribute('data-display-name'),
					renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
				},
				mentionSuggestionChar: {
					default: '@',
					parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
					renderHTML: (attrs) => ({
						'data-mention-suggestion-char': attrs.mentionSuggestionChar
					})
				}
			};
		},
		// #628: host the mention as a NodeView so each token carries a pencil
		// re-edit affordance. renderHTML/renderText below stay for serialization
		// and clipboard; this only governs the live in-editor DOM.
		addNodeView() {
			return createMentionNodeView(requestRelink);
		}
	});

	editor = new Editor({
		element: editorEl,
		// Initial editable state honors the `disabled` prop. The reactive
		// $effect below keeps it in sync if the prop flips after mount —
		// without this, a keyboard user can tab into the contenteditable
		// even when the wrapper has pointer-events-none (WCAG 2.1.1).
		editable: !disabled,
		extensions: [
			StarterKit.configure({
				heading: false,
				bold: false,
				italic: false,
				strike: false,
				code: false,
				blockquote: false,
				codeBlock: false,
				bulletList: false,
				orderedList: false,
				hardBreak: false,
				horizontalRule: false
			}),
			CustomMention.configure({
				renderHTML({ node }) {
					// Underline color matches the read-mode .person-mention rule
					// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
					// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
					return [
						'span',
						{
							'data-type': 'mention',
							'data-person-id': node.attrs.personId,
							'data-display-name': node.attrs.displayName,
							class:
								'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
						},
						`@${node.attrs.displayName}`
					];
				},
				renderText({ node }) {
					return `@${node.attrs.displayName}`;
				},
				suggestion: {
					char: '@',
					allowSpaces: true,
					// ─────────────────────────────────────────────────────────────
					// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
					// Tiptap's suggestion plugin lives entirely on the client and
					// fires on every keystroke after `@`. Routing each query through
					// a SvelteKit form action would round-trip through SSR for a
					// dropdown that needs to feel instantaneous, and a +server.ts
					// endpoint would only proxy the same call. Auth flows through
					// the Vite proxy in dev and Caddy in prod (cookie-based), so the
					// network surface is identical to a server-driven call.
					// Markus #5616: an ADR will formalise this. Open follow-up:
					// "ADR: client-side fetch exception for editor suggestion plugins."
					// Nora #5618 #3 — separate issue tracks the GET /api/persons
					// response-shape audit (PersonSummaryDTO leaks `notes`).
					// ─────────────────────────────────────────────────────────────
					// Tiptap's suggestion plugin requires an `items()` callback to keep
					// the dropdown alive, but the actual fetch is owned by `runSearch`
					// below — routed through the dropdown's search input via the
					// debounced `onSearch` channel. Returning `[]` here keeps Tiptap
					// happy without firing a duplicate per-keystroke fetch.
					// Markus #5616 / Felix / Nora / Sara on PR #629.
					items: async () => [],
					// AC-1 fix: insert the typed query as displayName, not person.displayName.
					command({ editor: ed, range, props }) {
						const p = props as unknown as { personId: string; displayName: string };
						const nodeAfter = ed.view.state.selection.$to.nodeAfter;
						if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
						ed.chain()
							.focus()
							.insertContentAt(range, [
								{
									type: 'mention',
									attrs: { personId: p.personId, displayName: p.displayName }
								},
								{ type: 'text', text: ' ' }
							])
							.run();
					},
					// Thin adapter over the single mention controller. The fresh-`@`
					// commit clips the typed query into the inserted displayName
					// (AC-1, #380); the controller owns the dropdown lifecycle and the
					// debounced/request-token search.
					render() {
						const buildFreshCommit = (loose: LooseRenderProps): CommitFn => {
							const clippedQuery = loose.query.slice(0, MAX_QUERY_LENGTH);
							return (item: Person) => {
								if (!item.id) return;
								loose.command({ personId: item.id, displayName: clippedQuery });
							};
						};
						return {
							onStart(renderProps) {
								const loose = renderProps as unknown as LooseRenderProps;
								controller.open(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
							},
							onUpdate(renderProps) {
								const loose = renderProps as unknown as LooseRenderProps;
								controller.update(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
							},
							onKeyDown({ event }) {
								// Escape is handled by the suggestion plugin itself.
								if (event.key === 'Escape') return false;
								return controller.onKeyDown(event);
							},
							onExit() {
								controller.close();
							}
						};
					}
				}
			})
		],
		content: deserialize(value, mentionedPersons),
		editorProps: {
			attributes: {
				role: 'textbox',
				'aria-multiline': 'true',
				'aria-label': m.transcription_editor_aria_label(),
				'data-editor-inner': '',
				class: [
					'min-h-[120px] px-1 py-2.5',
					'font-serif text-base leading-relaxed text-ink',
					'focus:outline-none',
					'tiptap-editor-inner'
				].join(' ')
			}
		},
		onUpdate({ editor: ed }) {
			const { text, mentionedPersons: mp } = serialize(ed.getJSON());
			value = text;
			mentionedPersons = mp;
		},
		onFocus() {
			onfocus?.();
		},
		onBlur() {
			onblur?.();
		},
		onSelectionUpdate({ editor: ed }) {
			const { from, to } = ed.state.selection;
			onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
		}
	});
});

onDestroy(() => {
	cancelPendingSearch?.();
	editor?.destroy();
	// Tiptap suggestion onExit usually unmounts the dropdown, but if the host
	// component is destroyed while a suggestion is active the dropdown can
	// outlive the editor — clean it up explicitly.
	if (mountedDropdown) {
		unmount(mountedDropdown);
		mountedDropdown = null;
	}
});

// Keep the data-placeholder attribute in sync with actual emptiness so the
// placeholder CSS only fires when there is no content (not just on blur).
$effect(() => {
	if (!editor || !placeholder) return;
	void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
	// reactive read to re-run — void value registers value as a dependency without using it.
	const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
	if (!inner) return;
	if (editor.isEmpty) {
		inner.setAttribute('data-placeholder', placeholder);
	} else {
		inner.removeAttribute('data-placeholder');
	}
});

// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
// flips contenteditable on the inner DOM and stops accepting input — matches
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
//
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
// idempotence check, the effect would loop on every prop pass-through.
$effect(() => {
	const shouldBeEditable = !disabled;
	if (editor && editor.isEditable !== shouldBeEditable) {
		editor.setEditable(shouldBeEditable);
	}
});
</script>

<div
	class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
	class:opacity-50={disabled}
	class:pointer-events-none={disabled}
	aria-disabled={disabled ? 'true' : undefined}
	bind:this={editorEl}
></div>
