import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout';
import { buildFamilyForest, type Unit } from './familyForest';
import canonicalFixture from '../__fixtures__/stammbaum.json';
import type { components } from '$lib/generated/api';

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

const PARENT = '00000000-0000-0000-0000-000000000001';
const CHILD = '00000000-0000-0000-0000-000000000002';
const SPOUSE_A = '00000000-0000-0000-0000-000000000003';
const SPOUSE_B = '00000000-0000-0000-0000-000000000004';
const NEGATIVE_A = '00000000-0000-0000-0000-000000000005';
const NEGATIVE_B = '00000000-0000-0000-0000-000000000006';
const NEGATIVE_C = '00000000-0000-0000-0000-000000000007';

function node(id: string, displayName: string, generation: number | null = null): PersonNodeDTO {
	return generation == null
		? { id, displayName, familyMember: true }
		: { id, displayName, familyMember: true, generation };
}

// Richer factory than node(): lets ordering tests set birthYear (which the
// sibling/branch comparator sorts on) and generation independently. node()
// never sets birthYear, so every birth-year ordering assertion needs this.
function makeNode(
	id: string,
	displayName: string,
	opts: { birthYear?: number; generation?: number } = {}
): PersonNodeDTO {
	const n: PersonNodeDTO = { id, displayName, familyMember: true };
	if (opts.birthYear != null) n.birthYear = opts.birthYear;
	if (opts.generation != null) n.generation = opts.generation;
	return n;
}

function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO {
	return {
		id,
		personId: parentId,
		relatedPersonId: childId,
		personDisplayName: '',
		relatedPersonDisplayName: '',
		relationType: 'PARENT_OF',
		fromDatePrecision: 'UNKNOWN',
		toDatePrecision: 'UNKNOWN'
	};
}

function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
	return {
		id,
		personId: a,
		relatedPersonId: b,
		personDisplayName: '',
		relatedPersonDisplayName: '',
		relationType: 'SPOUSE_OF',
		fromDatePrecision: 'UNKNOWN',
		toDatePrecision: 'UNKNOWN'
	};
}

function yOf(layout: ReturnType<typeof buildLayout>, id: string): number {
	const p = layout.positions.get(id);
	if (!p) throw new Error(`No position for ${id}`);
	return p.y;
}

describe('makeNode factory', () => {
	it('sets birthYear and generation only when provided', () => {
		expect(makeNode('a', 'A')).toEqual({ id: 'a', displayName: 'A', familyMember: true });
		expect(makeNode('b', 'B', { birthYear: 1900, generation: 2 })).toEqual({
			id: 'b',
			displayName: 'B',
			familyMember: true,
			birthYear: 1900,
			generation: 2
		});
	});
});

describe('buildLayout — generation seeding (#689)', () => {
	it('Herbert Cram regression: two parented G=3 spouses share the same row', () => {
		// Both Herbert (G 3) and Clara (G 3) are parented children of their respective
		// G 2 ancestors. They are spouses. Before #689 the iterative longest-path put
		// Herbert one row deeper than Clara via the spouse-pulldown of his loose parent.
		// With imported generation as a strict seed both render at the same y.
		const layout = buildLayout(
			[node(SPOUSE_A, 'Herbert', 3), node(SPOUSE_B, 'Clara', 3)],
			[spouseEdge(SPOUSE_A, SPOUSE_B)]
		);

		expect(yOf(layout, SPOUSE_A)).toBe(yOf(layout, SPOUSE_B));
	});

	it('strict-seed override: imported generation pins rank even when parent edges imply deeper', () => {
		// PARENT has no explicit generation → falls back to 0. CHILD is parented under
		// PARENT but has imported generation = 3. The seeded rank wins; the heuristic
		// must not push CHILD to rank 1.
		const layout = buildLayout(
			[node(PARENT, 'Parent'), node(CHILD, 'Child', 3)],
			[parentEdge(PARENT, CHILD)]
		);

		expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
	});

	it('fallback inherits seeded parent rank: G 2 parent → null-gen child lands at rank 3', () => {
		// CHILD has no imported generation. PARENT has generation = 2. The fallback
		// reads PARENT's rank from the unified rank map (2) and computes 2 + 1 = 3.
		const layout = buildLayout(
			[node(PARENT, 'Parent', 2), node(CHILD, 'Child')],
			[parentEdge(PARENT, CHILD)]
		);

		expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
	});

	it('normalise is a no-op when all ranks are non-negative', () => {
		// Seeded ranks [3, 4, 5] → y must reflect [3, 4, 5] without any shift.
		const G3 = '00000000-0000-0000-0000-000000000031';
		const G4 = '00000000-0000-0000-0000-000000000032';
		const G5 = '00000000-0000-0000-0000-000000000033';
		const layout = buildLayout(
			[node(G3, 'three', 3), node(G4, 'four', 4), node(G5, 'five', 5)],
			[]
		);

		expect(yOf(layout, G3)).toBe(3 * (NODE_H + ROW_GAP));
		expect(yOf(layout, G4)).toBe(4 * (NODE_H + ROW_GAP));
		expect(yOf(layout, G5)).toBe(5 * (NODE_H + ROW_GAP));
	});

	it('normalise shifts negative seeds so min rank becomes 0', () => {
		// Seeded ranks [-1, 0, 1] → after shift they render at [0, 1, 2] y-rows.
		const layout = buildLayout(
			[node(NEGATIVE_A, 'minus-one', -1), node(NEGATIVE_B, 'zero', 0), node(NEGATIVE_C, 'one', 1)],
			[]
		);

		expect(yOf(layout, NEGATIVE_A)).toBe(0);
		expect(yOf(layout, NEGATIVE_B)).toBe(1 * (NODE_H + ROW_GAP));
		expect(yOf(layout, NEGATIVE_C)).toBe(2 * (NODE_H + ROW_GAP));
	});
});

describe('buildLayout — multi-spouse + intra-family marriage (#361)', () => {
	const FOCAL = '00000000-0000-0000-0000-000000000010';
	const SPOUSE_X = '00000000-0000-0000-0000-000000000011';
	const SPOUSE_Y = '00000000-0000-0000-0000-000000000012';
	const UNKNOWN = '00000000-0000-0000-0000-000000000099';

	it('preserves_both_marriages_when_person_has_two_SPOUSE_OF_edges', () => {
		// Before #361 the spouse map was Map<string, string>; the second
		// .set() clobbered the first, so a person with N spouses (Albert de
		// Gruyter, 4) silently lost N-1 of them. Asserting that every spouse
		// has a layout position is the minimal presence check.
		const layout = buildLayout(
			[node(FOCAL, 'Focal', 3), node(SPOUSE_X, 'Alice'), node(SPOUSE_Y, 'Bob')],
			[spouseEdge(FOCAL, SPOUSE_X, 'fx'), spouseEdge(FOCAL, SPOUSE_Y, 'fy')]
		);

		expect(layout.positions.get(FOCAL)).toBeDefined();
		expect(layout.positions.get(SPOUSE_X)).toBeDefined();
		expect(layout.positions.get(SPOUSE_Y)).toBeDefined();
	});

	it('ignores_SPOUSE_OF_edge_with_unknown_relatedPersonId', () => {
		// Robustness gap flagged by NullX during persona review: an edge
		// pointing to a UUID not in the node list must not crash buildLayout
		// and must not introduce a phantom node into the positions map.
		const buildIt = () =>
			buildLayout([node(FOCAL, 'Focal', 3)], [spouseEdge(FOCAL, UNKNOWN, 'fu')]);
		expect(buildIt).not.toThrow();

		const layout = buildIt();
		expect(layout.positions.get(FOCAL)).toBeDefined();
		expect(layout.positions.get(UNKNOWN)).toBeUndefined();
	});

	it('canonical_fixture_assigns_a_position_to_every_node_with_multiple_spouses', () => {
		// Real-data structural assertion against the canonical Stammbaum
		// snapshot. Today the only multi-spouse case is Albert de Gruyter
		// (4 marriages); the assertion stays valid as the graph grows.
		const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
		const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];
		const layout = buildLayout(fixtureNodes, fixtureEdges);

		const partners = new Map<string, Set<string>>();
		for (const e of fixtureEdges) {
			if (e.relationType !== 'SPOUSE_OF') continue;
			addPartner(partners, e.personId, e.relatedPersonId);
			addPartner(partners, e.relatedPersonId, e.personId);
		}
		const multi = [...partners.entries()].filter(([, set]) => set.size >= 2);
		expect(multi.length).toBeGreaterThan(0);

		for (const [id, set] of multi) {
			expect(layout.positions.get(id)).toBeDefined();
			for (const partnerId of set) {
				expect(layout.positions.get(partnerId)).toBeDefined();
			}
		}
	});
});

function addPartner(map: Map<string, Set<string>>, key: string, value: string) {
	const s = map.get(key);
	if (s) s.add(value);
	else map.set(key, new Set([value]));
}

describe('buildLayout — multi-spouse ordering (#361)', () => {
	const PARENT = '00000000-0000-0000-0000-0000000000c0';
	const FOCAL = '00000000-0000-0000-0000-0000000000c1';
	const SPOUSE_1925 = '00000000-0000-0000-0000-0000000000c2';
	const SPOUSE_NULL = '00000000-0000-0000-0000-0000000000c3';
	const SPOUSE_1910 = '00000000-0000-0000-0000-0000000000c4';

	function spouseEdgeWithYear(
		a: string,
		b: string,
		fromYear: number | undefined,
		id = a + b
	): RelationshipDTO {
		return {
			...spouseEdge(a, b, id),
			...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {})
		};
	}

	it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
		// Synthetic year-branch exercise. Focal X is parented (under PARENT)
		// at G=1, with three loose spouses at years 1925, null, 1910. After
		// the sort, the order to the right of X is: 1910, 1925, null —
		// earliest first, NULLS LAST, displayName tiebreaker.
		const layout = buildLayout(
			[
				node(PARENT, 'P', 0),
				node(FOCAL, 'Focal', 1),
				// Names chosen so alphabetical order does NOT match the
				// year-sort order — otherwise the test couldn't tell the
				// two sort keys apart.
				node(SPOUSE_1925, 'Alpha'),
				node(SPOUSE_NULL, 'Beta'),
				node(SPOUSE_1910, 'Gamma')
			],
			[
				parentEdge(PARENT, FOCAL),
				spouseEdgeWithYear(FOCAL, SPOUSE_1925, 1925, 'ya'),
				spouseEdgeWithYear(FOCAL, SPOUSE_NULL, undefined, 'yn'),
				spouseEdgeWithYear(FOCAL, SPOUSE_1910, 1910, 'yg')
			]
		);

		const pos = (id: string) => layout.positions.get(id)!;
		const xFocal = pos(FOCAL).x;
		const x1910 = pos(SPOUSE_1910).x;
		const x1925 = pos(SPOUSE_1925).x;
		const xNull = pos(SPOUSE_NULL).x;

		// All spouses sit to the right of focal …
		expect(x1910).toBeGreaterThan(xFocal);
		expect(x1925).toBeGreaterThan(xFocal);
		expect(xNull).toBeGreaterThan(xFocal);
		// … in year-sort order.
		expect(x1910).toBeLessThan(x1925);
		expect(x1925).toBeLessThan(xNull);
	});

	it('intra_family_marriage_places_both_spouses_adjacent_across_sibling_blocks', () => {
		// AC2 (#361). Two parented persons at the same imported generation,
		// each in a separate sibling block under their own parent, marry each
		// other. Before the fix the block-packer left them split, drawing a
		// long spouse line across an intervening sibling. After the fix the
		// two blocks merge with the spouses sitting on the join boundary.
		const A1 = '00000000-0000-0000-0000-0000000000d1';
		const B1 = '00000000-0000-0000-0000-0000000000d2';
		const A2 = '00000000-0000-0000-0000-0000000000d3';
		const A3 = '00000000-0000-0000-0000-0000000000d4';
		const B2 = '00000000-0000-0000-0000-0000000000d5';

		const layout = buildLayout(
			[
				node(A1, 'A1', 0),
				node(B1, 'B1', 0),
				node(A2, 'A2', 1),
				node(A3, 'A3', 1),
				node(B2, 'B2', 1)
			],
			[
				parentEdge(A1, A2, 'p1'),
				parentEdge(A1, A3, 'p2'),
				parentEdge(B1, B2, 'p3'),
				spouseEdge(A2, B2, 'sp')
			]
		);

		const posA2 = layout.positions.get(A2)!;
		const posB2 = layout.positions.get(B2)!;
		expect(posA2.y).toBe(posB2.y);
		expect(Math.abs(posA2.x - posB2.x)).toBe(NODE_W + COL_GAP);

		// Tighter contract (Sara's cycle-2 follow-up): no third node may sit
		// at an x strictly between the two spouses on the same y. The integer-
		// slot adjacency check above (==NODE_W+COL_GAP) is correct today but
		// would silently pass if a future layout change introduced fractional
		// offsets and placed a node at a non-slot x between the spouses.
		const minX = Math.min(posA2.x, posB2.x);
		const maxX = Math.max(posA2.x, posB2.x);
		for (const [id, p] of layout.positions) {
			if (id === A2 || id === B2) continue;
			if (p.y !== posA2.y) continue;
			expect(p.x <= minX || p.x >= maxX).toBe(true);
		}

		// Same-level bond (both parents are roots): the displaced parent edge
		// can be ordered adjacent, so it stays a solid connector — NOT a dashed
		// cross-link. The cross-link set is therefore empty for this case.
		expect(layout.crossLinks).toEqual([]);
	});

	it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {
		// Real-data assertion: 0 of 28 SPOUSE_OF rows in the canonical fixture
		// have fromYear populated, so the sort collapses to alphabetical by
		// displayName for the only multi-spouse person (Albert de Gruyter).
		const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
		const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];

		// Precondition: this test asserts the *fallback* branch of the
		// multi-spouse sort (fromYear ASC NULLS LAST, displayName ASC), which
		// only collapses to alphabetical-by-displayName when every SPOUSE_OF
		// row is null on fromYear. The day any canonical row gets a year
		// backfilled, this test would silently start asserting year-order;
		// fail fast instead so the maintainer either updates the test or
		// splits into a year-branch / name-branch pair.
		const spouseEdgesWithYear = fixtureEdges.filter(
			(e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
		);
		expect(
			spouseEdgesWithYear,
			'Precondition violated: a canonical SPOUSE_OF row now carries fromYear. Update this test (or split into year-branch / name-branch).'
		).toHaveLength(0);

		const layout = buildLayout(fixtureNodes, fixtureEdges);

		const partners = new Map<string, Set<string>>();
		for (const e of fixtureEdges) {
			if (e.relationType !== 'SPOUSE_OF') continue;
			addPartner(partners, e.personId, e.relatedPersonId);
			addPartner(partners, e.relatedPersonId, e.personId);
		}
		const [multiPersonId, multiPartnerSet] =
			[...partners.entries()].find(([, set]) => set.size >= 3) ?? [];
		expect(multiPersonId).toBeDefined();
		if (!multiPersonId || !multiPartnerSet) return;

		const focalX = layout.positions.get(multiPersonId)!.x;
		const partnerNames = new Map(
			fixtureNodes.filter((n) => multiPartnerSet.has(n.id)).map((n) => [n.id, n.displayName])
		);
		// Spouses ordered alphabetically by displayName, all to the right of focal.
		const sorted = [...multiPartnerSet].sort((a, b) =>
			(partnerNames.get(a) ?? '').localeCompare(partnerNames.get(b) ?? '')
		);
		let prevX = focalX;
		for (const id of sorted) {
			const x = layout.positions.get(id)!.x;
			expect(x).toBeGreaterThan(prevX);
			prevX = x;
		}
	});
});

describe('buildLayout — cross-level marriage fallback (#724)', () => {
	// A bond is cross-level when the two spouses' parents sit at different
	// structural levels. Adjacency cannot keep both connectors short, so the
	// structural owner keeps its hierarchy edge and the other parent → spouse
	// edge becomes a distinct cross-link.
	const GP = '00000000-0000-0000-0000-0000000000e1'; // G0, deep branch ancestor
	const P = '00000000-0000-0000-0000-0000000000e2'; // G1, child of GP
	const A = '00000000-0000-0000-0000-0000000000e3'; // G2, child of P (nested deep)
	const R = '00000000-0000-0000-0000-0000000000e4'; // G1 root
	const B = '00000000-0000-0000-0000-0000000000e5'; // G2, child of R

	it('records the displaced parent edge as a cross-link and keeps the couple adjacent', () => {
		const layout = buildLayout(
			[
				node(GP, 'GP', 0),
				node(P, 'P', 1),
				{ ...node(A, 'A', 2), birthYear: 1900 }, // earlier birth → A is structural owner
				node(R, 'R', 1),
				{ ...node(B, 'B', 2), birthYear: 1910 }
			],
			[
				parentEdge(GP, P, 'g1'),
				parentEdge(P, A, 'g2'),
				parentEdge(R, B, 'g3'),
				spouseEdge(A, B, 'sp')
			]
		);

		// A owns; B is absorbed into A's run → couple is exactly adjacent.
		const posA = layout.positions.get(A)!;
		const posB = layout.positions.get(B)!;
		expect(posA.y).toBe(posB.y);
		expect(Math.abs(posA.x - posB.x)).toBe(NODE_W + COL_GAP);

		// R → B is cross-level: it surfaces as a distinct cross-link, and the
		// geometry still lands on B's real position (carried redundantly).
		expect(layout.crossLinks).toEqual([{ parentId: R, childId: B }]);
		expect(layout.positions.get(B)).toBeDefined();
	});
});

function centerX(layout: ReturnType<typeof buildLayout>, id: string): number {
	return layout.positions.get(id)!.x + NODE_W / 2;
}

describe('buildLayout — named-bug guard: deep bloodline (#724)', () => {
	// A 5-generation single bloodline whose deepest generation fans out wide.
	// The OLD per-generation packer (now removed) stranded the apex ancestor at
	// the LEFT edge of its descendants — the Albert/Martin symptom. The bottom-up
	// tidy-tree centres every ancestor over the span of its descendants.
	const gg = '00000000-0000-0000-0000-0000000000f0'; // G0 great-great-grandparent
	const g = '00000000-0000-0000-0000-0000000000f1'; // G1
	const p = '00000000-0000-0000-0000-0000000000f2'; // G2
	const d = '00000000-0000-0000-0000-0000000000f3'; // G3
	const leaves = ['a', 'b', 'c', 'd', 'e'].map(
		(s, i) => `00000000-0000-0000-0000-0000000000${(0xf4 + i).toString(16)}`
	);

	function buildBloodline() {
		return buildLayout(
			[
				node(gg, 'gg', 0),
				node(g, 'g', 1),
				node(p, 'p', 2),
				node(d, 'd', 3),
				...leaves.map((id, i) => node(id, `leaf-${i}`, 4))
			],
			[
				parentEdge(gg, g, 'e1'),
				parentEdge(g, p, 'e2'),
				parentEdge(p, d, 'e3'),
				...leaves.map((id, i) => parentEdge(d, id, `el${i}`))
			]
		);
	}

	it('great_great_grandparent_is_not_stranded_left_of_descendants', () => {
		const layout = buildBloodline();
		const leafCenters = leaves.map((id) => centerX(layout, id));
		const minLeaf = Math.min(...leafCenters);
		const maxLeaf = Math.max(...leafCenters);
		const ggX = centerX(layout, gg);

		// The apex ancestor sits strictly inside its descendant span — not pinned
		// to the left edge as the old packer left it.
		expect(ggX).toBeGreaterThan(minLeaf);
		expect(ggX).toBeLessThan(maxLeaf);
		// In fact it sits exactly at the centre of the descendant fan-out, and so
		// does every ancestor in the chain (single-child chains inherit the centre).
		const mid = (minLeaf + maxLeaf) / 2;
		expect(ggX).toBe(mid);
		expect(centerX(layout, g)).toBe(mid);
		expect(centerX(layout, p)).toBe(mid);
		expect(centerX(layout, d)).toBe(mid);
	});
});

// Centre-x of a unit's run, derived from its primary's left edge + run width.
function unitCenter(layout: ReturnType<typeof buildLayout>, u: Unit): number {
	const left = layout.positions.get(u.id)!.x;
	const width = u.members.length * NODE_W + (u.members.length - 1) * COL_GAP;
	return left + width / 2;
}

describe('buildLayout — ancestor centring invariant (#724)', () => {
	const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
	const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];

	it('every unit centre sits within its child units span (canonical + synthetic)', () => {
		const cases: [string, PersonNodeDTO[], RelationshipDTO[]][] = [
			['canonical', fixtureNodes, fixtureEdges],
			[
				'synthetic',
				[node('R', 'R', 0), node('c1', 'c1', 1), node('c2', 'c2', 1), node('c3', 'c3', 1)],
				[parentEdge('R', 'c1'), parentEdge('R', 'c2'), parentEdge('R', 'c3')]
			]
		];

		for (const [label, nodes, edges] of cases) {
			const layout = buildLayout(nodes, edges);
			const forest = buildFamilyForest(nodes, edges);
			const walk = (u: Unit) => {
				if (u.children.length > 0) {
					const childCenters = u.children.map((c) => unitCenter(layout, c));
					const lo = Math.min(...childCenters);
					const hi = Math.max(...childCenters);
					const c = unitCenter(layout, u);
					expect(c, `${label}: unit ${u.id} centred in child span`).toBeGreaterThanOrEqual(lo);
					expect(c, `${label}: unit ${u.id} centred in child span`).toBeLessThanOrEqual(hi);
				}
				u.children.forEach(walk);
			};
			forest.roots.forEach(walk);
		}
	});

	it('no two nodes on the same row overlap (canonical + synthetic)', () => {
		const cases: [string, PersonNodeDTO[], RelationshipDTO[]][] = [
			['canonical', fixtureNodes, fixtureEdges],
			[
				'synthetic deep',
				[
					node('R', 'R', 0),
					node('p1', 'p1', 1),
					node('p2', 'p2', 1),
					node('g1', 'g1', 2),
					node('g2', 'g2', 2)
				],
				[
					parentEdge('R', 'p1'),
					parentEdge('R', 'p2'),
					parentEdge('p1', 'g1'),
					parentEdge('p1', 'g2')
				]
			]
		];

		for (const [label, nodes, edges] of cases) {
			const layout = buildLayout(nodes, edges);
			const entries = [...layout.positions.entries()];
			for (let i = 0; i < entries.length; i++) {
				for (let j = i + 1; j < entries.length; j++) {
					const [, a] = entries[i];
					const [, b] = entries[j];
					if (a.y !== b.y) continue;
					expect(
						Math.abs(a.x - b.x),
						`${label}: ${entries[i][0]} vs ${entries[j][0]} overlap on y=${a.y}`
					).toBeGreaterThanOrEqual(NODE_W + COL_GAP);
				}
			}
		}
	});

	it('a bloodline occupies one contiguous band — no foreign node interleaved', () => {
		// Two roots, each its own bloodline. The first fans out two generations
		// deep; the contour pack must keep it as one band with nothing from the
		// other bloodline wedged inside its horizontal span.
		const nodes = [
			node('R1', 'R1', 0),
			node('a', 'a', 1),
			node('b', 'b', 1),
			node('a1', 'a1', 2),
			node('R2', 'R2', 0),
			node('c', 'c', 1)
		];
		const edges = [
			parentEdge('R1', 'a'),
			parentEdge('R1', 'b'),
			parentEdge('a', 'a1'),
			parentEdge('R2', 'c')
		];
		const layout = buildLayout(nodes, edges);
		const forest = buildFamilyForest(nodes, edges);

		// Collect the R1 bloodline: every member of its unit subtree.
		const r1 = forest.roots.find((u) => u.id === 'R1')!;
		const bloodline = new Set<string>();
		const collect = (u: Unit) => {
			u.members.forEach((m) => bloodline.add(m));
			u.children.forEach(collect);
		};
		collect(r1);

		const bandXs = [...bloodline].map((id) => layout.positions.get(id)!.x);
		const minX = Math.min(...bandXs);
		const maxX = Math.max(...bandXs) + NODE_W;

		// No node outside the bloodline may intrude into its [minX, maxX] band.
		for (const [id, p] of layout.positions) {
			if (bloodline.has(id)) continue;
			const intrudes = p.x < maxX && p.x + NODE_W > minX;
			expect(intrudes, `foreign node ${id} interleaved into the R1 band`).toBe(false);
		}
	});

	it('no bloodline is smeared across the canvas (per-bloodline span regression)', () => {
		// Golden constant: measured 2026-06-04, the OLD per-generation block
		// packer laid the canonical fixture out 4860px wide and smeared a single
		// bloodline (Albert de Gruyter G0 … a far-right descendant) across that
		// whole span — the bug. Total canvas width is NOT the right metric for the
		// fix (centring every ancestor makes a 24-root forest wider overall, and
		// pan/zoom from #692 handles navigation); per-bloodline compactness is.
		// Each contiguous bloodline must occupy far less than the old full-canvas
		// smear. Today the widest is Albert's at ~960px.
		const OLD_FULL_CANVAS = 4860;

		const layout = buildLayout(fixtureNodes, fixtureEdges);
		const forest = buildFamilyForest(fixtureNodes, fixtureEdges);
		const bloodlineSpan = (root: Unit): number => {
			const ids: string[] = [];
			const collect = (u: Unit) => {
				ids.push(...u.members);
				u.children.forEach(collect);
			};
			collect(root);
			const xs = ids.map((id) => layout.positions.get(id)!.x);
			return Math.max(...xs) + NODE_W - Math.min(...xs);
		};

		const spans = forest.roots.map(bloodlineSpan);
		const widest = Math.max(...spans);
		// Every bloodline is dramatically more compact than the old smear …
		expect(widest).toBeLessThan(OLD_FULL_CANVAS);
		// … and in fact comfortably under a quarter of it (no smear creeps back).
		expect(widest).toBeLessThanOrEqual(OLD_FULL_CANVAS / 4);
	});
});

// Deterministic Fisher–Yates using a seeded mulberry32 PRNG (never Math.random,
// which would make the determinism test itself non-reproducible).
function seededShuffle<T>(input: T[], seed: number): T[] {
	let s = seed >>> 0;
	const rand = () => {
		s = (s + 0x6d2b79f5) >>> 0;
		let t = s;
		t = Math.imul(t ^ (t >>> 15), t | 1);
		t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
		return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
	};
	const out = input.slice();
	for (let i = out.length - 1; i > 0; i--) {
		const j = Math.floor(rand() * (i + 1));
		[out[i], out[j]] = [out[j], out[i]];
	}
	return out;
}

describe('buildLayout — determinism (#724)', () => {
	const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
	const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];

	it('produces identical positions regardless of node/edge input order', () => {
		const base = buildLayout(fixtureNodes, fixtureEdges);
		const shuffled = buildLayout(
			seededShuffle(fixtureNodes, 1337),
			seededShuffle(fixtureEdges, 4242)
		);

		expect(shuffled.positions.size).toBe(base.positions.size);
		for (const [id, p] of base.positions) {
			expect(shuffled.positions.get(id), `position for ${id} is order-independent`).toEqual(p);
		}
	});
});

describe('buildLayout — termination + once-only on cyclic input (#724)', () => {
	// buildLayout runs inside a $derived, so non-termination = a frozen tab.
	// User-entered data can form parent cycles; the layout must fail closed —
	// produce *a* finite layout, every node placed exactly once.
	const A = '00000000-0000-0000-0000-0000000000a1';
	const B = '00000000-0000-0000-0000-0000000000a2';
	const C = '00000000-0000-0000-0000-0000000000a3';

	it('A↔B parent cycle returns a finite layout with one position per node', () => {
		let layout!: ReturnType<typeof buildLayout>;
		expect(() => {
			layout = buildLayout(
				[node(A, 'A'), node(B, 'B')],
				[parentEdge(A, B, 'ab'), parentEdge(B, A, 'ba')]
			);
		}).not.toThrow();

		expect(layout.positions.size).toBe(2);
		expect(layout.positions.get(A)).toBeDefined();
		expect(layout.positions.get(B)).toBeDefined();
	});

	it('two founders both reaching a re-entrant 3-cycle terminate with every node placed', () => {
		// F is a founder parenting A; A→B→C→A is a parent cycle, and C also
		// re-enters via F. The visited/once-only guarantees keep it finite.
		const F = '00000000-0000-0000-0000-0000000000a0';
		let layout!: ReturnType<typeof buildLayout>;
		expect(() => {
			layout = buildLayout(
				[node(F, 'F'), node(A, 'A'), node(B, 'B'), node(C, 'C')],
				[
					parentEdge(F, A, 'fa'),
					parentEdge(A, B, 'ab'),
					parentEdge(B, C, 'bc'),
					parentEdge(C, A, 'ca')
				]
			);
		}).not.toThrow();

		// Every node is placed exactly once (Map keys are unique by construction;
		// the count equality proves none were dropped or duplicated).
		expect(layout.positions.size).toBe(4);
		for (const id of [F, A, B, C]) expect(layout.positions.get(id)).toBeDefined();
	});
});

describe('buildLayout — degenerate graphs (#724)', () => {
	it('empty graph yields no positions and the MIN viewBox (no NaN)', () => {
		// Runs on a fresh /stammbaum visit before any data loads — must not throw
		// or emit NaN into the SVG viewBox.
		const layout = buildLayout([], []);
		expect(layout.positions.size).toBe(0);
		expect(layout.crossLinks).toEqual([]);
		expect(layout.generations.size).toBe(0);
		for (const v of [layout.viewX, layout.viewY, layout.viewW, layout.viewH]) {
			expect(Number.isFinite(v)).toBe(true);
		}
		// computeViewBox falls back to MIN dimensions when there is no content.
		expect(layout.viewW).toBe(1200);
		expect(layout.viewH).toBe(800);
	});

	it('single isolated node is placed once at rank 0', () => {
		const layout = buildLayout([node(PARENT, 'Solo', 0)], []);
		expect(layout.positions.size).toBe(1);
		expect(layout.positions.get(PARENT)).toEqual({ x: 0, y: 0 });
		expect(layout.crossLinks).toEqual([]);
	});
});
