<script lang="ts">
import type { TranscriptionBlockData } from '$lib/types';
import type { components } from '$lib/generated/api';
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
import {
	renderTranscriptionBody,
	type SafeHtml,
	PERSON_MENTION_SELECTOR
} from '$lib/utils/mention';
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
import PersonHoverCard from './PersonHoverCard.svelte';
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
import { goto } from '$app/navigation';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';

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

interface Props {
	blocks: TranscriptionBlockData[];
	onParagraphClick: (annotationId: string) => void;
	highlightBlockId?: string | null;
}

let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();

let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));

// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
// shape lets simultaneous hovers share the same in-flight fetch.
//
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
// a person is rare in this read-only view, and a per-document/global cache would
// complicate invalidation. If user reports on stale cards accumulate, revisit.
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
const deletedPersonIds = new SvelteSet<string>();

let activeCard: {
	personId: string;
	cardId: string;
	state: LoadState;
	position: { top: number; left: number };
} | null = $state(null);

// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
// as <em data-marker> tags; text segments run through HTML-escaping + mention
// substitution. The two are concatenated to preserve marker boundaries — markers
// never end up nested inside an anchor (Felix #5324 B19b).
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
	return splitByMarkers(block.text)
		.map((segment) => {
			if (segment.type === 'marker') {
				// splitByMarkers only emits the literal markers [unleserlich] and [...],
				// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
				// the brand contract.
				return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
			}
			return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
		})
		.join('') as SafeHtml;
}

/**
 * Fetches person + relationships from the backend. 404 returns null
 * (deleted person — caller marks the link as tombstoned). Any other
 * non-OK response throws so the caller can render the error state.
 */
async function loadHoverData(personId: string): Promise<HoverData | null> {
	const personRes = await fetch(`/api/persons/${personId}`);
	if (personRes.status === 404) return null;
	if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
	const person = (await personRes.json()) as Person;

	const relRes = await fetch(`/api/persons/${personId}/relationships`);
	const relationships: RelationshipDTO[] = relRes.ok
		? ((await relRes.json()) as RelationshipDTO[])
		: [];
	return { person, relationships };
}

/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
 * subsequent hovers (and concurrent in-flight ones) share the same Promise. */
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
	const cached = hoverCache.get(personId);
	if (cached) return cached;
	const promise = loadHoverData(personId);
	hoverCache.set(personId, promise);
	return promise;
}

function currentViewport() {
	return {
		viewportWidth: window.innerWidth,
		viewportHeight: window.innerHeight
	};
}

let closeTimer: ReturnType<typeof setTimeout> | null = null;

function scheduleCardClose() {
	closeTimer = setTimeout(() => {
		activeCard = null;
		closeTimer = null;
	}, 150);
}

function cancelCardClose() {
	if (closeTimer !== null) {
		clearTimeout(closeTimer);
		closeTimer = null;
	}
}

async function handleMentionEnter(event: Event) {
	cancelCardClose();
	const link = event.target as HTMLAnchorElement;
	const personId = link.dataset.personId;
	if (!personId) return;
	if (deletedPersonIds.has(personId)) return;

	const cardId = `person-hover-card-${personId}`;
	link.setAttribute('aria-describedby', cardId);

	const rect = link.getBoundingClientRect();
	const position = computeHoverCardPosition(rect, currentViewport());

	activeCard = { personId, cardId, position, state: { status: 'loading' } };

	try {
		const data = await getOrFetchHoverData(personId);
		// Bail if a different mention is now active
		if (!activeCard || activeCard.personId !== personId) return;

		if (data === null) {
			deletedPersonIds.add(personId);
			link.setAttribute('data-person-deleted', 'true');
			activeCard = null;
			return;
		}

		activeCard = {
			personId,
			cardId,
			position,
			state: { status: 'loaded', person: data.person, relationships: data.relationships }
		};
	} catch {
		if (!activeCard || activeCard.personId !== personId) return;
		activeCard = { personId, cardId, position, state: { status: 'error' } };
	}
}

function handleMentionLeave(event: Event) {
	const link = event.target as HTMLAnchorElement;
	link.removeAttribute('aria-describedby');
	if (event.type === 'mouseleave') {
		scheduleCardClose();
	} else {
		activeCard = null;
	}
}

/**
 * Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
 * the browser's default anchor behaviour so users can open the person page in
 * a new tab/window. Felix #7. Only the plain primary-button click navigates
 * via SPA goto().
 */
function isPlainPrimaryClick(event: MouseEvent): boolean {
	return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
}

async function handleMentionClick(event: MouseEvent) {
	if (!isPlainPrimaryClick(event)) return;
	const link = event.target as HTMLAnchorElement;
	const personId = link.dataset.personId;
	if (!personId) return;
	if (deletedPersonIds.has(personId)) {
		event.preventDefault();
		return;
	}
	event.preventDefault();
	await goto(`/persons/${personId}`);
}

// Attach delegated event listeners on each rendered block. Using {@html ...}
// for the body means we cannot bind events declaratively to the injected
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
//
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
// mouseenter/mouseleave so users tabbing through transcribed text get the
// same preview affordance.
function attachMentionHandlers(node: HTMLElement) {
	function onEnter(e: Event) {
		const t = e.target as HTMLElement;
		if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
	}
	function onLeave(e: Event) {
		const t = e.target as HTMLElement;
		if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e);
	}
	function onClick(e: MouseEvent) {
		const t = e.target as HTMLElement;
		if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
	}
	// mouseenter does not bubble — capture it.
	node.addEventListener('mouseenter', onEnter, true);
	node.addEventListener('mouseleave', onLeave, true);
	// focusin/focusout do bubble — no capture phase needed.
	node.addEventListener('focusin', onEnter);
	node.addEventListener('focusout', onLeave);
	node.addEventListener('click', onClick);

	return {
		destroy() {
			node.removeEventListener('mouseenter', onEnter, true);
			node.removeEventListener('mouseleave', onLeave, true);
			node.removeEventListener('focusin', onEnter);
			node.removeEventListener('focusout', onLeave);
			node.removeEventListener('click', onClick);
		}
	};
}
</script>

<article class="px-6 py-8" use:attachMentionHandlers>
	{#each sorted as block (block.id)}
		<div
			class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
			class:flash-highlight={highlightBlockId === block.id}
			data-block-id={block.id}
			onclick={() => onParagraphClick(block.annotationId)}
			role="button"
			tabindex="0"
			onkeydown={(e) => {
				if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
			}}
		>
			<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
			{@html renderBlockHtml(block)}
		</div>
	{/each}
</article>

{#if activeCard}
	<PersonHoverCard
		personId={activeCard.personId}
		cardId={activeCard.cardId}
		position={activeCard.position}
		state={activeCard.state}
		onmouseenter={cancelCardClose}
		onmouseleave={() => { activeCard = null; }}
	/>
{/if}

<style>
@keyframes flash {
	0% {
		background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
	}
	100% {
		background-color: transparent;
	}
}

.flash-highlight {
	animation: flash 1.2s ease-out;
}

@media (prefers-reduced-motion: reduce) {
	.flash-highlight {
		animation: none;
		background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
	}
}
</style>
