<script lang="ts">
import { untrack, onMount } from 'svelte';
import { SvelteMap } 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';
import {
	type PanZoomState,
	clampZoom,
	clampPan,
	recentreAbove,
	cornerView,
	ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
import { buildLineageIndex, highlightLineage } from '$lib/person/genealogy/layout/highlightLineage';
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte';
import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte';

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

interface Props {
	nodes: PersonNodeDTO[];
	edges: RelationshipDTO[];
	selectedId: string | null;
	panZoom: PanZoomState;
	/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
	onPanZoom?: (state: PanZoomState) => void;
	/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
	centreOnId?: string | null;
	/**
	 * Fraction of the viewBox height to lift a recentred node above the centre,
	 * so on a phone the anchor clears the bottom sheet (#703 AC8). 0 centres it.
	 */
	centreBiasFraction?: number;
	/** Fired on the first pointer interaction with the canvas (affordance dismiss). */
	onActivity?: () => void;
	/** When true, the initial view is anchored to the tree's top-left corner. */
	anchorTopLeft?: boolean;
	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,
	panZoom,
	onPanZoom = () => {},
	centreOnId = null,
	centreBiasFraction = 0,
	onActivity,
	anchorTopLeft = false,
	onSelect,
	showGutter
}: Props = $props();

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

// Keyboard tab order follows DOM source order (#718). Render the nodes in
// reading order — top generation first (by layout y), left-to-right within a
// row (by layout x) — so focus sweeps the visual grid instead of the backend
// row order in which `nodes` arrives. Nodes without a layout position sort last
// (mirroring the `{#if pos}` render guard); node.id is the final tie-break for a
// total, deterministic comparator.
//
// LOAD-BEARING: relies on every node in a generation sharing the same y
// (buildLayout assigns y = g * (NODE_H + ROW_GAP)). If the layout is ever
// changed to stagger nodes vertically within a generation, this y-then-x sort
// would silently regress tab order — revisit here.
const nodesInReadingOrder = $derived.by(() =>
	[...nodes].sort((a, b) => {
		const pa = layout.positions.get(a.id);
		const pb = layout.positions.get(b.id);
		const ay = pa ? pa.y : Infinity;
		const by = pb ? pb.y : Infinity;
		if (ay !== by) return ay - by;
		const ax = pa ? pa.x : Infinity;
		const bx = pb ? pb.x : Infinity;
		if (ax !== bx) return ax - bx;
		return a.id.localeCompare(b.id);
	})
);

// Lineage highlight (#703). The adjacency index is rebuilt only when the edges
// change; the cheap walk re-runs whenever the selection changes. A null
// highlight (no selection) means full strength everywhere — nothing dims.
const lineageIndex = $derived(buildLineageIndex(edges));
const highlight = $derived.by(() =>
	selectedId ? highlightLineage(lineageIndex, selectedId) : null
);
const isNodeActive = (id: string) => highlight === null || highlight.active.has(id);
const isConnectorActive = (aId: string, bId: string) =>
	highlight === null || highlight.isConnectorActive(aId, bId);

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

// Reduced-motion preference disables pan inertia and animated transitions
// (REQ-PAN-005). Seeded synchronously like the gutter state above.
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
let reducedMotion = $state(
	typeof window !== 'undefined' && typeof window.matchMedia === 'function'
		? window.matchMedia(REDUCED_MOTION_QUERY).matches
		: false
);
$effect(() => {
	if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
	const mq = window.matchMedia(REDUCED_MOTION_QUERY);
	const handler = (e: MediaQueryListEvent) => (reducedMotion = e.matches);
	mq.addEventListener('change', handler);
	return () => mq.removeEventListener('change', handler);
});

type GutterRow = { rank: number; y: number; label: number | null };
// Computed on all viewports (not gated on the desktop gutter) so the pinned
// generation rail can show labels on phones too (#692).
const gutterRows = $derived.by<GutterRow[]>(() => {
	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;
});

// Base viewBox geometry at z=1, no pan — the whole tree framed (#692). Pan
// offsets shift the centre; zoom scales width/height inversely. The default
// {x:0,y:0,z:1} therefore fits the tree to the element (fit-to-screen).
const baseDims = $derived({ w: layout.viewW + gutterWidth, h: layout.viewH });
const baseCentre = $derived({
	x: layout.viewX - gutterWidth + baseDims.w / 2,
	y: layout.viewY + layout.viewH / 2
});

// Labelled generation rows for the pinned rail, with each row's centre in SVG
// coordinates (the rail maps these through the live screen transform).
let svgEl = $state<SVGSVGElement | null>(null);
const railRows = $derived(
	gutterRows
		.filter((r): r is GutterRow & { label: number } => r.label != null)
		.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
);

// A fresh visit (no shared URL state) lands on the tree's content top-left
// rather than its centre (#692). Anchors to the first row / leftmost node (not
// the padded frame corner, which would leave empty space above row 1), with a
// small margin. Runs once after layout is available.
const ANCHOR_MARGIN = 24;
onMount(() => {
	if (!anchorTopLeft) return;
	let minX = Infinity;
	let minY = Infinity;
	for (const pos of layout.positions.values()) {
		minX = Math.min(minX, pos.x);
		minY = Math.min(minY, pos.y);
	}
	if (!Number.isFinite(minX)) return; // no nodes
	const target = cornerView(
		minX - ANCHOR_MARGIN,
		minY - ANCHOR_MARGIN,
		baseCentre.x,
		baseCentre.y,
		baseDims.w,
		baseDims.h,
		panZoom.z
	);
	onPanZoom(clampPan(target, baseDims.w, baseDims.h));
});

const viewBox = $derived.by(() => {
	const w = baseDims.w / panZoom.z;
	const h = baseDims.h / panZoom.z;
	const cx = baseCentre.x + panZoom.x;
	const cy = baseCentre.y + panZoom.y;
	return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});

// Permanent edge-fade affordance (#692, replaces US-PAN-006 AC3). When the tree
// is zoomed past fit, content is clipped at the viewport edges, so a 24px fade
// on all four edges cues that more tree exists off-screen. Zero JS beyond this
// reactive style; nothing fades at fit (z <= 1, whole tree visible).
const EDGE_FADE = 24;
const maskStyle = $derived(
	panZoom.z > 1
		? `-webkit-mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
				`mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
				`-webkit-mask-composite:source-in;mask-composite:intersect;`
		: ''
);

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

// Recentre when the parent sets centreOnId (US-PAN-005). Only centreOnId is a
// tracked dependency — the current view is read untracked so a normal pan does
// not retrigger a recentre.
$effect(() => {
	const id = centreOnId;
	if (!id) return;
	untrack(() => {
		const c = nodeCenter(id);
		if (c) onPanZoom(recentreAbove(c, baseCentre, panZoom, baseDims.h, centreBiasFraction));
	});
});

// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
function handleCanvasKey(event: KeyboardEvent) {
	const stepX = (baseDims.w / panZoom.z) * 0.1;
	const stepY = (baseDims.h / panZoom.z) * 0.1;
	switch (event.key) {
		case '+':
		case '=':
			onPanZoom({ ...panZoom, z: clampZoom(panZoom.z + ZOOM_STEP_KB) });
			break;
		case '-':
		case '_':
			onPanZoom({ ...panZoom, z: clampZoom(panZoom.z - ZOOM_STEP_KB) });
			break;
		case 'ArrowLeft':
			onPanZoom({ ...panZoom, x: panZoom.x - stepX });
			break;
		case 'ArrowRight':
			onPanZoom({ ...panZoom, x: panZoom.x + stepX });
			break;
		case 'ArrowUp':
			onPanZoom({ ...panZoom, y: panZoom.y - stepY });
			break;
		case 'ArrowDown':
			onPanZoom({ ...panZoom, y: panZoom.y + stepY });
			break;
		default:
			return;
	}
	event.preventDefault();
}
</script>

<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
<div class="relative h-full w-full">
	<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
	     users focus it and the keydown handler is the keyboard-only alternative to
	     touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
	<svg
		bind:this={svgEl}
		viewBox={viewBox}
		preserveAspectRatio="xMinYMin meet"
		role="img"
		aria-label="Stammbaum"
		tabindex="0"
		style={maskStyle}
		onkeydown={handleCanvasKey}
		use:panZoomGestures={{
			state: panZoom,
			baseW: baseDims.w,
			baseH: baseDims.h,
			baseCentreX: baseCentre.x,
			baseCentreY: baseCentre.y,
			reducedMotion,
			onPanZoom,
			onGestureStart: onActivity
		}}
		class="block h-full w-full"
	>
		<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
		     transparent / var(--c-gutter-stripe), desktop only. Generation labels
		     are no longer drawn in-SVG; the pinned rail below carries them. -->
		{#if gutterVisible}
			{#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}
		{/if}

		<StammbaumConnectors
			edges={edges}
			positions={layout.positions}
			crossLinks={layout.crossLinks}
			isConnectorActive={isConnectorActive}
		/>

		<!-- Nodes (reading order so keyboard Tab follows the visual grid, #718) -->
		{#each nodesInReadingOrder as node (node.id)}
			{@const pos = layout.positions.get(node.id)}
			{#if pos}
				<StammbaumNode
					node={node}
					pos={pos}
					selected={selectedId === node.id}
					dimmed={!isNodeActive(node.id)}
					onSelect={onSelect}
				/>
			{/if}
		{/each}
	</svg>

	<StammbaumGenerationRail svg={svgEl} rows={railRows} panZoom={panZoom} />
</div>
