<script lang="ts">
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
   to a single $derived.by computation; never mutated after layout. */
import type { components } from '$lib/generated/api';

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

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

const NODE_W = 160;
const NODE_H = 56;
const COL_GAP = 40;
const ROW_GAP = 80;
const VIEWBOX_PAD = 80;
// Minimum viewBox dimensions — keeps a single node from being scaled up
// to fill the entire canvas. Roughly matches a typical desktop content area.
const MIN_VIEWBOX_W = 1200;
const MIN_VIEWBOX_H = 800;

type Layout = {
	positions: Map<string, { x: number; y: number }>;
	generations: Map<number, string[]>;
	viewX: number;
	viewY: number;
	viewW: number;
	viewH: number;
};

const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
const viewBox = $derived.by(() => {
	const w = layout.viewW / zoom;
	const h = layout.viewH / zoom;
	const cx = layout.viewX + layout.viewW / 2;
	const cy = layout.viewY + layout.viewH / 2;
	return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});

function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
	const parentToChildren = new Map<string, string[]>();
	const childToParents = new Map<string, string[]>();
	const spousePairs = new Map<string, string>();

	for (const e of allEdges) {
		switch (e.relationType) {
			case 'PARENT_OF':
				mapPush(parentToChildren, e.personId, e.relatedPersonId);
				mapPush(childToParents, e.relatedPersonId, e.personId);
				break;
			case 'SPOUSE_OF':
				spousePairs.set(e.personId, e.relatedPersonId);
				spousePairs.set(e.relatedPersonId, e.personId);
				break;
		}
	}

	// Iterative longest-path generation assignment.
	//
	// Each node's generation = max(parent generations) + 1 (roots stay at 0).
	// Then spouses are pulled to share the deeper generation. Pulling a spouse
	// down can shift their own descendants, so we iterate until stable rather
	// than running BFS once like the previous implementation (which left
	// e.g. a child of a "later-pulled" spouse stranded one row too high).
	const generation = new Map<string, number>();
	for (const n of allNodes) generation.set(n.id, 0);
	const maxIters = allNodes.length + 4;
	for (let it = 0; it < maxIters; it++) {
		let changed = false;
		for (const n of allNodes) {
			const parents = childToParents.get(n.id) ?? [];
			if (parents.length === 0) continue;
			let maxParentGen = -1;
			for (const pid of parents) {
				maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
			}
			const newGen = maxParentGen + 1;
			if ((generation.get(n.id) ?? 0) < newGen) {
				generation.set(n.id, newGen);
				changed = true;
			}
		}
		for (const [a, b] of spousePairs) {
			const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
			if ((generation.get(a) ?? 0) < m) {
				generation.set(a, m);
				changed = true;
			}
			if ((generation.get(b) ?? 0) < m) {
				generation.set(b, m);
				changed = true;
			}
		}
		if (!changed) break;
	}

	// Group by generation, then sort within generation by display name.
	const generations = new Map<number, string[]>();
	for (const n of allNodes) {
		const g = generation.get(n.id) ?? 0;
		if (!generations.has(g)) generations.set(g, []);
		generations.get(g)!.push(n.id);
	}
	const byId = new Map(allNodes.map((n) => [n.id, n]));
	for (const ids of generations.values()) {
		ids.sort((a, b) => {
			const an = byId.get(a)?.displayName ?? '';
			const bn = byId.get(b)?.displayName ?? '';
			return an.localeCompare(bn);
		});
	}

	// Per-generation layout:
	//
	// 1. Build sibling-groups (children of the same parent set) — these become
	//    the layout "blocks" that are centred under their parents' midpoint.
	// 2. Attach loose spouses (people with no parents in the graph but a
	//    spouse who *is* in a sibling group) on the outside of their partner,
	//    so the spouse line stays short and adjacent.
	// 3. Merge dual-loose spouse pairs into a single 2-person block.
	// 4. Centre each block such that its *parented* members average sits
	//    exactly under the parent midpoint (keeping all connectors at 90°),
	//    then pack blocks left-to-right.
	type Block = {
		members: { id: string; parented: boolean }[];
		center: number;
	};

	const positions = new Map<string, { x: number; y: number }>();
	const sortedGens = [...generations.keys()].sort((a, b) => a - b);

	for (let gi = 0; gi < sortedGens.length; gi++) {
		const g = sortedGens[gi];
		const ids = generations.get(g)!;
		const y = g * (NODE_H + ROW_GAP);

		const blocksByKey = new Map<string, Block>();
		const memberLookup = new Map<string, { key: string; parented: boolean }>();

		// Step 1: place every node with parents-in-graph into a sibling block.
		for (const id of ids) {
			const parents = childToParents.get(id) ?? [];
			if (parents.length === 0) continue;
			const blockKey = [...parents].sort().join('|');
			let block = blocksByKey.get(blockKey);
			if (!block) {
				const parentCenters: number[] = [];
				for (const pid of parents) {
					const p = positions.get(pid);
					if (p) parentCenters.push(p.x + NODE_W / 2);
				}
				const center =
					parentCenters.length > 0
						? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
						: 0;
				block = { members: [], center };
				blocksByKey.set(blockKey, block);
			}
			block.members.push({ id, parented: true });
			memberLookup.set(id, { key: blockKey, parented: true });
		}

		// Sort members within each sibling block alphabetically.
		for (const block of blocksByKey.values()) {
			block.members.sort((a, b) =>
				(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
			);
		}

		// Step 2 + 3: handle loose nodes.
		for (const id of ids) {
			if (memberLookup.has(id)) continue;
			const spouse = spousePairs.get(id);
			const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;

			if (spouseLookup && spouseLookup.parented) {
				// Spouse is parented — attach this loose node next to them on
				// the outer edge of their sibling block so the marriage line
				// is short and the sibling order is preserved.
				const block = blocksByKey.get(spouseLookup.key)!;
				const spouseIdx = block.members.findIndex((m) => m.id === spouse);
				const insertOnRight = spouseIdx >= block.members.length / 2;
				const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
				block.members.splice(insertAt, 0, { id, parented: false });
				memberLookup.set(id, { key: spouseLookup.key, parented: false });
			} else {
				// No usable parented spouse: put in its own loose block. We
				// merge dual-loose spouse pairs in the next pass.
				const blockKey = `__loose__${id}`;
				blocksByKey.set(blockKey, {
					members: [{ id, parented: false }],
					center: 0
				});
				memberLookup.set(id, { key: blockKey, parented: false });
			}
		}

		// Merge dual-loose spouse blocks into a single 2-person block.
		const removed = new Set<string>();
		for (const [key, block] of blocksByKey) {
			if (!key.startsWith('__loose__')) continue;
			if (removed.has(key)) continue;
			const member = block.members[0];
			const spouse = spousePairs.get(member.id);
			if (!spouse) continue;
			const spouseLookup = memberLookup.get(spouse);
			if (!spouseLookup || removed.has(spouseLookup.key)) continue;
			if (spouseLookup.key === key) continue;
			if (!spouseLookup.key.startsWith('__loose__')) continue;
			const otherBlock = blocksByKey.get(spouseLookup.key)!;
			block.members.push(...otherBlock.members);
			removed.add(spouseLookup.key);
		}
		for (const key of removed) blocksByKey.delete(key);

		// Step 4: centre each block on its anchor (parented members) and pack.
		const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
		let cursorRight = -Infinity;
		for (const block of ordered) {
			const n = block.members.length;
			const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
			const anchorIndices: number[] = [];
			for (let i = 0; i < n; i++) {
				if (block.members[i].parented) anchorIndices.push(i);
			}
			const avgAnchorIdx =
				anchorIndices.length > 0
					? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
					: (n - 1) / 2;
			let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
			if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
			for (let i = 0; i < n; i++) {
				positions.set(block.members[i].id, {
					x: groupLeft + i * (NODE_W + COL_GAP),
					y
				});
			}
			cursorRight = groupLeft + groupWidth;
		}
	}

	// Bounding box around the actual content, then expanded to MIN dimensions
	// (so a single node doesn't get scaled up to fill the canvas). The viewBox
	// is centered on the content.
	let minX = Infinity;
	let minY = Infinity;
	let maxX = -Infinity;
	let maxY = -Infinity;
	for (const p of positions.values()) {
		minX = Math.min(minX, p.x);
		minY = Math.min(minY, p.y);
		maxX = Math.max(maxX, p.x + NODE_W);
		maxY = Math.max(maxY, p.y + NODE_H);
	}
	if (positions.size === 0) {
		minX = 0;
		minY = 0;
		maxX = 0;
		maxY = 0;
	}
	const contentW = maxX - minX;
	const contentH = maxY - minY;
	const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
	const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
	const viewX = minX + contentW / 2 - viewW / 2;
	const viewY = minY + contentH / 2 - viewH / 2;
	return { positions, generations, viewX, viewY, viewW, viewH };
}

function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
	const arr = map.get(key);
	if (arr) arr.push(value);
	else map.set(key, [value]);
}

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 Set<string>();
	for (const e of spouseEdges) {
		spousePairs.add(pairKey(e.personId, e.relatedPersonId));
	}

	const childToParents = new Map<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 Map<string, { parentA: string; parentB: string; childIds: string[] }>();
	const single: ParentLinks['single'] = [];
	for (const [childId, parents] of childToParents) {
		const consumed = new Set<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"
>
	<!-- 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="4.5"
				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>
