<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import {
	buildLayout,
	NODE_W,
	NODE_H,
	ROW_GAP,
	type Layout
} from '$lib/person/genealogy/layout/buildLayout';

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

interface Props {
	nodes: PersonNodeDTO[];
	edges: RelationshipDTO[];
	selectedId: string | null;
	zoom: number;
	onSelect: (id: string) => void;
	/**
	 * Force-show or force-hide the generation gutter. When undefined, falls
	 * back to a `window.matchMedia('(min-width: 768px)')` detection so the
	 * gutter only appears on md+ viewports. Tests pass an explicit boolean
	 * to avoid depending on the vitest-browser iframe viewport.
	 */
	showGutter?: boolean;
}

let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props();

const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));

// Stammbaum gutter (#689). 100 px column on the left of the canvas on md+
// viewports, carrying the G{n} label per generation row. Hidden entirely on
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
// too costly on a 320 px screen).
const GUTTER_WIDTH_DESKTOP = 100;
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
// Seed synchronously so the first paint already has the right gutter state —
// otherwise the test (and a brief flash on real CSR mount) would see the
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
let isMdOrUp = $state(
	typeof window !== 'undefined' && typeof window.matchMedia === 'function'
		? window.matchMedia(GUTTER_MEDIA_QUERY).matches
		: false
);
$effect(() => {
	if (showGutter !== undefined) return;
	if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
	const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
	const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
	mq.addEventListener('change', handler);
	return () => mq.removeEventListener('change', handler);
});
const gutterVisible = $derived(showGutter ?? isMdOrUp);
const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0);

type GutterRow = { rank: number; y: number; label: number | null };
const gutterRows = $derived.by<GutterRow[]>(() => {
	if (gutterWidth === 0) return [];
	const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
	const rows: GutterRow[] = [];
	const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
	for (const rank of sortedRanks) {
		const ids = layout.generations.get(rank)!;
		const firstPos = layout.positions.get(ids[0]);
		if (!firstPos) continue;
		let label: number | null = null;
		for (const id of ids) {
			const g = byId.get(id)?.generation;
			if (g != null) {
				label = g;
				break;
			}
		}
		rows.push({ rank, y: firstPos.y, label });
	}
	return rows;
});

const viewBox = $derived.by(() => {
	const totalW = layout.viewW + gutterWidth;
	const w = totalW / zoom;
	const h = layout.viewH / zoom;
	const cx = layout.viewX - gutterWidth + totalW / 2;
	const cy = layout.viewY + layout.viewH / 2;
	return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});

function nodeCenter(id: string): { x: number; y: number } | null {
	const p = layout.positions.get(id);
	if (!p) return null;
	return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
}

let focusedId = $state<string | null>(null);

function handleNodeKey(event: KeyboardEvent, id: string) {
	if (event.key === 'Enter' || event.key === ' ') {
		event.preventDefault();
		onSelect(id);
	}
}

const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));

function pairKey(a: string, b: string): string {
	return a < b ? `${a}|${b}` : `${b}|${a}`;
}

type ParentLinks = {
	// One entry per spouse-pair-with-children: drives the drop + sibling-bar
	// + per-child vertical pattern in the SVG.
	shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
	// One entry per remaining parent → child edge (single parents, or the
	// "second" parent edge when only one parent is in the spouse pair).
	single: { key: string; parentId: string; childId: string }[];
};

const parentLinks = $derived.by<ParentLinks>(() => {
	const spousePairs = new SvelteSet<string>();
	for (const e of spouseEdges) {
		spousePairs.add(pairKey(e.personId, e.relatedPersonId));
	}

	const childToParents = new SvelteMap<string, string[]>();
	for (const e of parentEdges) {
		const list = childToParents.get(e.relatedPersonId) ?? [];
		list.push(e.personId);
		childToParents.set(e.relatedPersonId, list);
	}

	const sharedMap = new SvelteMap<
		string,
		{ parentA: string; parentB: string; childIds: string[] }
	>();
	const single: ParentLinks['single'] = [];
	for (const [childId, parents] of childToParents) {
		const consumed = new SvelteSet<string>();
		for (let i = 0; i < parents.length; i++) {
			if (consumed.has(parents[i])) continue;
			for (let j = i + 1; j < parents.length; j++) {
				if (consumed.has(parents[j])) continue;
				if (spousePairs.has(pairKey(parents[i], parents[j]))) {
					const groupKey = pairKey(parents[i], parents[j]);
					const existing = sharedMap.get(groupKey);
					if (existing) {
						existing.childIds.push(childId);
					} else {
						sharedMap.set(groupKey, {
							parentA: parents[i],
							parentB: parents[j],
							childIds: [childId]
						});
					}
					consumed.add(parents[i]);
					consumed.add(parents[j]);
					break;
				}
			}
		}
		for (const parentId of parents) {
			if (consumed.has(parentId)) continue;
			single.push({ key: `${parentId}->${childId}`, parentId, childId });
		}
	}

	const shared: ParentLinks['shared'] = [];
	for (const [key, group] of sharedMap) shared.push({ key, ...group });
	return { shared, single };
});
</script>

<svg
	viewBox={viewBox}
	preserveAspectRatio="xMidYMid meet"
	role="img"
	aria-label="Stammbaum"
	class="block h-full w-full"
>
	<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
	     transparent / var(--c-gutter-stripe). aria-hidden because they carry
	     no meaning; the row's generation is announced by the label group below. -->
	{#each gutterRows as row, i (`stripe-${row.rank}`)}
		<rect
			aria-hidden="true"
			x={layout.viewX - gutterWidth}
			y={row.y - ROW_GAP / 2}
			width={layout.viewW + gutterWidth}
			height={NODE_H + ROW_GAP}
			fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
		/>
	{/each}

	<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
	     un-shifted source-truth value. Wrapped in <g role="text"> so screen
	     readers announce "Generation three" instead of "G three". -->
	{#each gutterRows as row (`label-${row.rank}`)}
		{#if row.label != null}
			<g role="text" aria-label={`Generation ${row.label}`}>
				<text
					x={layout.viewX - gutterWidth + 12}
					y={row.y + NODE_H / 2}
					text-anchor="start"
					dominant-baseline="middle"
					font-family="var(--font-sans)"
					font-size="12"
					font-weight="700"
					letter-spacing="0.08em"
					fill="var(--c-ink-2)"
					style:text-transform="uppercase"
				>
					G{row.label}
				</text>
			</g>
		{/if}
	{/each}

	<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
	     bar, then short verticals from the bar to each child top. -->
	{#each parentLinks.shared as group (group.key)}
		{@const aCenter = nodeCenter(group.parentA)}
		{@const bCenter = nodeCenter(group.parentB)}
		{@const childCenters = group.childIds
			.map((id) => nodeCenter(id))
			.filter((c): c is { x: number; y: number } => c !== null)}
		{#if aCenter && bCenter && childCenters.length > 0}
			{@const midX = (aCenter.x + bCenter.x) / 2}
			{@const parentBottomY = aCenter.y + NODE_H / 2}
			{@const childTopY = childCenters[0].y - NODE_H / 2}
			{@const barY = (parentBottomY + childTopY) / 2}
			{@const xs = childCenters.map((c) => c.x)}
			{@const minX = Math.min(midX, ...xs)}
			{@const maxX = Math.max(midX, ...xs)}
			<line
				x1={midX}
				y1={parentBottomY}
				x2={midX}
				y2={barY}
				stroke="var(--c-primary)"
				stroke-width="1.5"
			/>
			{#if minX !== maxX}
				<line
					x1={minX}
					y1={barY}
					x2={maxX}
					y2={barY}
					stroke="var(--c-primary)"
					stroke-width="1.5"
				/>
			{/if}
			{#each childCenters as cc, i (group.childIds[i])}
				<line
					x1={cc.x}
					y1={barY}
					x2={cc.x}
					y2={childTopY}
					stroke="var(--c-primary)"
					stroke-width="1.5"
				/>
			{/each}
		{/if}
	{/each}

	<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
	{#each parentLinks.single as link (link.key)}
		{@const parentCenter = nodeCenter(link.parentId)}
		{@const childCenter = nodeCenter(link.childId)}
		{#if parentCenter && childCenter}
			{@const parentBottomY = parentCenter.y + NODE_H / 2}
			{@const childTopY = childCenter.y - NODE_H / 2}
			{@const barY = (parentBottomY + childTopY) / 2}
			<line
				x1={parentCenter.x}
				y1={parentBottomY}
				x2={parentCenter.x}
				y2={barY}
				stroke="var(--c-primary)"
				stroke-width="1.5"
			/>
			{#if parentCenter.x !== childCenter.x}
				<line
					x1={parentCenter.x}
					y1={barY}
					x2={childCenter.x}
					y2={barY}
					stroke="var(--c-primary)"
					stroke-width="1.5"
				/>
			{/if}
			<line
				x1={childCenter.x}
				y1={barY}
				x2={childCenter.x}
				y2={childTopY}
				stroke="var(--c-primary)"
				stroke-width="1.5"
			/>
		{/if}
	{/each}

	<!-- Spouse connectors -->
	{#each spouseEdges as e (e.id)}
		{@const aCenter = nodeCenter(e.personId)}
		{@const bCenter = nodeCenter(e.relatedPersonId)}
		{#if aCenter && bCenter}
			<line
				x1={aCenter.x}
				y1={aCenter.y}
				x2={bCenter.x}
				y2={bCenter.y}
				stroke="var(--c-primary)"
				stroke-width="1.5"
				stroke-dasharray={e.toYear ? '4 4' : undefined}
			/>
			<circle
				cx={(aCenter.x + bCenter.x) / 2}
				cy={(aCenter.y + bCenter.y) / 2}
				r="6"
				fill="var(--c-primary)"
			/>
		{/if}
	{/each}

	<!-- Nodes -->
	{#each nodes as node (node.id)}
		{@const pos = layout.positions.get(node.id)}
		{#if pos}
			{@const isSelected = selectedId === node.id}
			{@const isFocused = focusedId === node.id}
			<g
				role="button"
				tabindex="0"
				aria-label="{node.displayName}{node.birthYear || node.deathYear
					? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
					: ''}"
				aria-expanded={isSelected}
				transform="translate({pos.x}, {pos.y})"
				onclick={() => onSelect(node.id)}
				onkeydown={(e) => handleNodeKey(e, node.id)}
				onfocus={() => (focusedId = node.id)}
				onblur={() => (focusedId = null)}
				class="cursor-pointer focus:outline-none"
			>
				{#if isFocused}
					<rect
						x="-3"
						y="-3"
						width={NODE_W + 6}
						height={NODE_H + 6}
						rx="6"
						fill="none"
						stroke="var(--c-focus-ring)"
						stroke-width="2"
					/>
				{/if}
				<rect
					width={NODE_W}
					height={NODE_H}
					rx="4"
					fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
					stroke="var(--c-primary)"
					stroke-width="1.5"
				/>
				{#if isSelected}
					<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
				{/if}
				<text
					x={NODE_W / 2}
					y={NODE_H / 2 - 6}
					text-anchor="middle"
					font-family="serif"
					font-size="16"
					fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
				>
					{node.displayName}
				</text>
				{#if node.birthYear || node.deathYear}
					<text
						x={NODE_W / 2}
						y={NODE_H / 2 + 12}
						text-anchor="middle"
						font-family="sans-serif"
						font-size="12"
						fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
						opacity={isSelected ? 0.75 : 1}
					>
						{node.birthYear ?? '?'}–{node.deathYear ?? ''}
					</text>
				{/if}
			</g>
		{/if}
	{/each}
</svg>
