<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 { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';

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

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

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

	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();
					},
					render() {
						let exports: DropdownExports | null = null;

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

						// Request-token guard: every onSearch invocation bumps `requestId`;
						// runSearch captures the id active when its fetch starts and discards
						// the response if a newer onSearch has fired since. Without this, a
						// late response can repopulate the dropdown after the user cleared
						// the search input. Sara on PR #629.
						let requestId = 0;
						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?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}`
								);
								if (id !== requestId) return;
								if (!res.ok) {
									dropdownState.items = [];
									return;
								}
								const data = (await res.json()) as Person[];
								if (id !== requestId) return;
								dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
							} catch {
								if (id !== requestId) return;
								dropdownState.items = [];
							}
						};
						const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
						cancelPendingSearch = () => debouncedSearch.cancel();
						const onSearch = (query: string) => {
							requestId++;
							if (query.trim() === '') {
								debouncedSearch.cancel();
								dropdownState.items = [];
								return;
							}
							debouncedSearch(query);
						};

						const updateState = (renderProps: LooseRenderProps) => {
							// Clip once here so both the inserted displayName and the
							// dropdown's editor-mirror see the same value. The dropdown
							// already clips the mirror (Nora #1 CWE-400), but without
							// clipping at the command boundary an unclipped query would
							// still flow through as the inserted displayName — visible
							// UI divergence between "what I searched" and "what was
							// inserted". Felix #3 on PR #629.
							const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
							// AC-1: pass typed query as displayName, not person.displayName
							dropdownState.command = (item: Person) =>
								renderProps.command({
									personId: item.id,
									displayName: clippedQuery
								});
							dropdownState.clientRect = renderProps.clientRect ?? null;
							dropdownState.editorQuery = clippedQuery;
						};

						return {
							onStart(renderProps) {
								const loose = renderProps as unknown as LooseRenderProps;
								updateState(loose);
								// MentionDropdown reads `editorQuery` off the shared state
								// proxy via its `editorQuery` prop binding below — this is
								// the same pattern as `model.items`. We do not pass it as a
								// separate prop because Svelte 5's mount() does not expose
								// settable prop accessors, so we route through the proxy.
								const mounted = mount(MentionDropdown, {
									target: document.body,
									props: {
										model: dropdownState,
										get editorQuery() {
											return dropdownState.editorQuery;
										},
										onSearch
									}
								});
								mountedDropdown = mounted as object;
								exports = mounted as unknown as DropdownExports;
							},
							onUpdate(renderProps) {
								updateState(renderProps as unknown as LooseRenderProps);
							},
							onKeyDown({ event }) {
								// Escape is handled by the suggestion plugin itself.
								if (event.key === 'Escape') return false;
								return exports?.onKeyDown(event) ?? false;
							},
							onExit() {
								// Cancel any pending debounce so a closed dropdown's trailing
								// runSearch cannot fire against the *next* dropdown's state.
								// The hoisted `cancelPendingSearch` would be overwritten by
								// the next render()'s onStart before the trailing call fires,
								// so we cancel locally via the closure-scoped debouncedSearch.
								// Felix #1 on PR #629.
								debouncedSearch.cancel();
								if (mountedDropdown) {
									unmount(mountedDropdown);
									mountedDropdown = null;
									exports = null;
								}
							}
						};
					}
				}
			})
		],
		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>
