import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumTree from './StammbaumTree.svelte';
import type { PanZoomState } from './panZoom';
import { DIMMED_OPACITY } from './layout/highlightLineage';
import type { components } from '$lib/generated/api';

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

const ID_A = '00000000-0000-0000-0000-000000000001';
const ID_B = '00000000-0000-0000-0000-000000000002';

function parseViewBox(svg: SVGElement): [number, number, number, number] {
	const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
	return [parts[0], parts[1], parts[2], parts[3]];
}

function rectsCentroid(svg: SVGElement): { x: number; y: number } {
	const rects = Array.from(svg.querySelectorAll('rect'));
	let sx = 0;
	let sy = 0;
	let n = 0;
	for (const r of rects) {
		const x = parseFloat(r.getAttribute('x') ?? '0');
		const y = parseFloat(r.getAttribute('y') ?? '0');
		const w = parseFloat(r.getAttribute('width') ?? '0');
		const h = parseFloat(r.getAttribute('height') ?? '0');
		// Skip the narrow accent stripe.
		if (w < 10) continue;
		// Each node rect lives inside <g transform="translate(...)">.
		const g = r.closest('g[transform]');
		const transform = g?.getAttribute('transform') ?? '';
		const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
		const tx = match ? parseFloat(match[1]) : 0;
		const ty = match ? parseFloat(match[2]) : 0;
		sx += tx + x + w / 2;
		sy += ty + y + h / 2;
		n++;
	}
	return { x: sx / n, y: sy / n };
}

describe('StammbaumTree viewBox', () => {
	it('offsets the viewBox origin by the pan state (#692)', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 100, y: 40, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});

		const svg = document.querySelector('svg')!;
		const [x, y, w, h] = parseViewBox(svg);

		// Same dimensions as the unpanned default (z=1)…
		expect(w).toBe(1200);
		expect(h).toBe(800);

		// …but the viewBox centre is the content centroid shifted by the pan
		// offset (at pan {0,0} the centre sits on the centroid — see the test
		// below). This avoids hard-coding the layout's absolute coordinates.
		const c = rectsCentroid(svg);
		expect(x + w / 2 - c.x).toBeCloseTo(100, 6);
		expect(y + h / 2 - c.y).toBeCloseTo(40, 6);
	});

	it('uses the minimum size and centers a single node', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const svg = document.querySelector('svg')!;
		const [x, y, w, h] = parseViewBox(svg);

		// Single 160x56 node fits inside the 1200x800 minimum viewBox.
		expect(w).toBe(1200);
		expect(h).toBe(800);

		// Whatever absolute coordinates the layout uses, the viewBox must
		// centre on the rendered content.
		const c = rectsCentroid(svg);
		expect(x + w / 2).toBeCloseTo(c.x, 1);
		expect(y + h / 2).toBeCloseTo(c.y, 1);
	});

	it('renders only orthogonal segments when two parents share two children', async () => {
		const PARENT_A = '00000000-0000-0000-0000-00000000000a';
		const PARENT_B = '00000000-0000-0000-0000-00000000000b';
		const CHILD_1 = '00000000-0000-0000-0000-00000000000c';
		const CHILD_2 = '00000000-0000-0000-0000-00000000000d';
		render(StammbaumTree, {
			nodes: [
				{ id: PARENT_A, displayName: 'Walter', familyMember: true },
				{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
				{ id: CHILD_1, displayName: 'Clara', familyMember: true },
				{ id: CHILD_2, displayName: 'Hans', familyMember: true }
			],
			edges: [
				{
					id: 'sp',
					personId: PARENT_A,
					relatedPersonId: PARENT_B,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Eugenie',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p1a',
					personId: PARENT_A,
					relatedPersonId: CHILD_1,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Clara',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p1b',
					personId: PARENT_B,
					relatedPersonId: CHILD_1,
					personDisplayName: 'Eugenie',
					relatedPersonDisplayName: 'Clara',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p2a',
					personId: PARENT_A,
					relatedPersonId: CHILD_2,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Hans',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p2b',
					personId: PARENT_B,
					relatedPersonId: CHILD_2,
					personDisplayName: 'Eugenie',
					relatedPersonDisplayName: 'Hans',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const lines = Array.from(document.querySelectorAll('svg line'));
		// Every parent-child segment must be either vertical (x1==x2) or
		// horizontal (y1==y2) — no slanted segments allowed.
		const slanted = lines.filter(
			(l) =>
				l.getAttribute('x1') !== l.getAttribute('x2') &&
				l.getAttribute('y1') !== l.getAttribute('y2')
		);
		expect(slanted).toHaveLength(0);
		// Sibling bar must exist and span the children: at least one
		// horizontal line whose x1 != x2 and y1 == y2.
		const horizontalBars = lines.filter(
			(l) =>
				l.getAttribute('y1') === l.getAttribute('y2') &&
				l.getAttribute('x1') !== l.getAttribute('x2')
		);
		expect(horizontalBars.length).toBeGreaterThanOrEqual(1);
	});

	it('positions a single child at the midpoint of its two parents (vertical drop)', async () => {
		const PARENT_A = '00000000-0000-0000-0000-00000000000a';
		const PARENT_B = '00000000-0000-0000-0000-00000000000b';
		const CHILD = '00000000-0000-0000-0000-00000000000c';
		render(StammbaumTree, {
			nodes: [
				{ id: PARENT_A, displayName: 'Walter', familyMember: true },
				{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
				{ id: CHILD, displayName: 'Hans', familyMember: true }
			],
			edges: [
				{
					id: 'sp',
					personId: PARENT_A,
					relatedPersonId: PARENT_B,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Eugenie',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p1',
					personId: PARENT_A,
					relatedPersonId: CHILD,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Hans',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p2',
					personId: PARENT_B,
					relatedPersonId: CHILD,
					personDisplayName: 'Eugenie',
					relatedPersonDisplayName: 'Hans',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const lines = Array.from(document.querySelectorAll('svg line'));
		// No slanted segments. With one child, no horizontal sibling bar
		// is needed because midX == child.center.x.
		const slanted = lines.filter(
			(l) =>
				l.getAttribute('x1') !== l.getAttribute('x2') &&
				l.getAttribute('y1') !== l.getAttribute('y2')
		);
		expect(slanted).toHaveLength(0);
	});

	it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
		// Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
		// Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
		// child Lili. Hilde must sit next to Hans, and Lili must be on a
		// row below Hans/Hilde — not on the same row.
		const WALTER = '00000000-0000-0000-0000-000000000001';
		const EUGENIE = '00000000-0000-0000-0000-000000000002';
		const HANS = '00000000-0000-0000-0000-000000000003';
		const CLARA = '00000000-0000-0000-0000-000000000004';
		const HILDE = '00000000-0000-0000-0000-000000000005';
		const LILI = '00000000-0000-0000-0000-000000000006';

		render(StammbaumTree, {
			nodes: [
				{ id: WALTER, displayName: 'Walter', familyMember: true },
				{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
				{ id: HANS, displayName: 'Hans', familyMember: true },
				{ id: CLARA, displayName: 'Clara', familyMember: true },
				{ id: HILDE, displayName: 'Hilde', familyMember: true },
				{ id: LILI, displayName: 'Lili', familyMember: true }
			],
			edges: [
				{
					id: 's1',
					personId: WALTER,
					relatedPersonId: EUGENIE,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Eugenie',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p1',
					personId: WALTER,
					relatedPersonId: HANS,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Hans',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p2',
					personId: EUGENIE,
					relatedPersonId: HANS,
					personDisplayName: 'Eugenie',
					relatedPersonDisplayName: 'Hans',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p3',
					personId: WALTER,
					relatedPersonId: CLARA,
					personDisplayName: 'Walter',
					relatedPersonDisplayName: 'Clara',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p4',
					personId: EUGENIE,
					relatedPersonId: CLARA,
					personDisplayName: 'Eugenie',
					relatedPersonDisplayName: 'Clara',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 's2',
					personId: HANS,
					relatedPersonId: HILDE,
					personDisplayName: 'Hans',
					relatedPersonDisplayName: 'Hilde',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p5',
					personId: HANS,
					relatedPersonId: LILI,
					personDisplayName: 'Hans',
					relatedPersonDisplayName: 'Lili',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				},
				{
					id: 'p6',
					personId: HILDE,
					relatedPersonId: LILI,
					personDisplayName: 'Hilde',
					relatedPersonDisplayName: 'Lili',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const ys = new Map<string, number>();
		for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
			const aria = g.getAttribute('aria-label') ?? '';
			const transform = g.getAttribute('transform') ?? '';
			const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
			if (!match) continue;
			ys.set(aria.split(',')[0], parseFloat(match[2]));
		}

		// Lili must be on a deeper row than Hans / Hilde.
		expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
		expect(ys.get('Hans')).toEqual(ys.get('Hilde'));

		// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
		const xs = new Map<string, number>();
		for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
			const aria = g.getAttribute('aria-label') ?? '';
			const transform = g.getAttribute('transform') ?? '';
			const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
			if (!match) continue;
			xs.set(aria.split(',')[0], parseFloat(match[1]));
		}
		expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40);

		// All parent-child segments must be orthogonal.
		const lines = Array.from(document.querySelectorAll('svg line'));
		const slanted = lines.filter(
			(l) =>
				l.getAttribute('x1') !== l.getAttribute('x2') &&
				l.getAttribute('y1') !== l.getAttribute('y2')
		);
		expect(slanted).toHaveLength(0);
	});

	it('renders the marriage-line midpoint dot at r=6 for WCAG 1.4.11 informational contrast (#361)', async () => {
		// Once the dot stacks to disambiguate multiple marriages it carries
		// meaning, so it moves from "decorative" to "informational" and the
		// 3:1 contrast rule requires the larger 12 px diameter.
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [
				{
					id: 'e1',
					personId: ID_A,
					relatedPersonId: ID_B,
					personDisplayName: 'Anna',
					relatedPersonDisplayName: 'Bertha',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const dot = document.querySelector('svg circle');
		expect(dot).not.toBeNull();
		expect(dot!.getAttribute('r')).toBe('6');
		// Cycle-2 follow-up from Sara: codify the colour-token side of the
		// WCAG 1.4.11 contrast contract at the unit level. The permanent axe-
		// core gate lives in #692; this assertion prevents an accidental
		// "neutralise the dot" diff (e.g. swap to var(--c-ink-3) or a literal
		// light token) from stripping the 3:1 contrast guarantee before #692
		// ships.
		expect(dot!.getAttribute('fill')).toBe('var(--c-primary)');
	});

	it('centers two spouse nodes within the minimum viewBox', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [
				{
					id: 'e1',
					personId: ID_A,
					relatedPersonId: ID_B,
					personDisplayName: 'Anna',
					relatedPersonDisplayName: 'Bertha',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const svg = document.querySelector('svg')!;
		const [x, y, w, h] = parseViewBox(svg);

		expect(w).toBe(1200);
		expect(h).toBe(800);

		const c = rectsCentroid(svg);
		expect(x + w / 2).toBeCloseTo(c.x, 1);
		expect(y + h / 2).toBeCloseTo(c.y, 1);
	});
});

describe('StammbaumTree node rendering branches', () => {
	it('renders the selected node with primary fill (selected branch)', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [],
			selectedId: ID_A,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const rects = Array.from(document.querySelectorAll('rect'));
		const primaryRects = rects.filter((r) => r.getAttribute('fill') === 'var(--c-primary)');
		expect(primaryRects.length).toBeGreaterThan(0);
	});

	it('renders birth/death years line when set', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true, birthYear: 1899, deathYear: 1950 }
			],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		expect(document.body.textContent).toContain('1899');
		expect(document.body.textContent).toContain('1950');
	});

	it('renders ?– for missing birthYear with deathYear set', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		expect(document.body.textContent).toMatch(/\?–/);
	});

	it('omits the years line when neither birthYear nor deathYear is set', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		expect(document.body.textContent).not.toMatch(/\?–\?/);
	});

	it('calls onSelect when a node is clicked', async () => {
		const onSelect = vi.fn();
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect
		});

		const node = document.querySelector('g[role="button"]') as SVGGElement;
		node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
		expect(onSelect).toHaveBeenCalledWith(ID_A);
	});

	it('handles Enter keypress on node like click', async () => {
		const onSelect = vi.fn();
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect
		});

		const node = document.querySelector('g[role="button"]') as SVGGElement;
		const evt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
		node.dispatchEvent(evt);
		expect(onSelect).toHaveBeenCalledWith(ID_A);
	});

	it('handles Space keypress on node like click', async () => {
		const onSelect = vi.fn();
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect
		});

		const node = document.querySelector('g[role="button"]') as SVGGElement;
		node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
		expect(onSelect).toHaveBeenCalledWith(ID_A);
	});
});

describe('StammbaumTree keyboard pan/zoom (#692)', () => {
	const renderTree = (onPanZoom: (state: PanZoomState) => void) =>
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onPanZoom,
			onSelect: () => {}
		});

	it('zooms in on "+" and out on "-" by the keyboard step (OQ-002)', async () => {
		const onPanZoom = vi.fn();
		renderTree(onPanZoom);
		const svg = document.querySelector('svg')!;

		svg.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true }));
		expect(onPanZoom).toHaveBeenCalledTimes(1);
		expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(1.1, 6);

		onPanZoom.mockClear();
		svg.dispatchEvent(new KeyboardEvent('keydown', { key: '-', bubbles: true }));
		expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(0.9, 6);
	});

	it('pans right/down on arrow keys (REQ-PAN-004)', async () => {
		const onPanZoom = vi.fn();
		renderTree(onPanZoom);
		const svg = document.querySelector('svg')!;

		svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
		expect(onPanZoom.mock.calls[0][0].x).toBeGreaterThan(0);

		onPanZoom.mockClear();
		svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
		expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0);
	});

	it('pans on a pointer drag and suppresses the trailing node click (US-PAN-001)', async () => {
		const onPanZoom = vi.fn();
		const onSelect = vi.fn();
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			// Zoomed in so panning is permitted (clampPan allows movement at z>1).
			panZoom: { x: 0, y: 0, z: 2 },
			onPanZoom,
			onSelect
		});
		const svg = document.querySelector('svg')! as SVGSVGElement;
		const node = document.querySelector('g[role="button"]') as SVGGElement;

		const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
		svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
		svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));

		// Assert on the move's emission *before* releasing: inertia starts on
		// pointerup and could otherwise perturb the last recorded call.
		expect(onPanZoom).toHaveBeenCalled();
		// Dragging right reveals content to the left → pan x decreases.
		expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);

		svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));

		// The synthetic click after a real drag must not select the node.
		node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
		expect(onSelect).not.toHaveBeenCalled();
	});

	it('recentres on a node when centreOnId is set, auto-zooming to legible (US-PAN-005)', async () => {
		const onPanZoom = vi.fn();
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [
				{
					id: 'sp',
					personId: ID_A,
					relatedPersonId: ID_B,
					personDisplayName: 'Anna',
					relatedPersonDisplayName: 'Bertha',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 0.5 },
			centreOnId: ID_A,
			onPanZoom,
			onSelect: () => {}
		});

		await vi.waitFor(() => expect(onPanZoom).toHaveBeenCalled());
		const view = onPanZoom.mock.calls.at(-1)![0];
		// Anna sits left of the two-node midpoint → pan x is negative.
		expect(view.x).toBeLessThan(0);
		// Zoomed out below legible → snapped up to 1.
		expect(view.z).toBe(1);
	});

	it('omits the edge-fade mask at fit (z = 1) (#692)', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});
		expect(document.querySelector('svg')!.getAttribute('style') ?? '').not.toContain('mask-image');
	});

	it('applies the edge-fade mask when zoomed past fit (#692)', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 2 },
			onSelect: () => {}
		});
		expect(document.querySelector('svg')!.getAttribute('style') ?? '').toContain('mask-image');
	});

	it('does not call onSelect for other keys', async () => {
		const onSelect = vi.fn();
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect
		});

		const node = document.querySelector('g[role="button"]') as SVGGElement;
		node.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
		expect(onSelect).not.toHaveBeenCalled();
	});

	it('renders dashed spouse line when toYear is set (divorced)', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [
				{
					id: 'e1',
					personId: ID_A,
					relatedPersonId: ID_B,
					personDisplayName: 'Anna',
					relatedPersonDisplayName: 'Bertha',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDate: '1925-01-01',
					toDatePrecision: 'YEAR'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const dashed = Array.from(document.querySelectorAll('line')).filter((l) =>
			l.hasAttribute('stroke-dasharray')
		);
		expect(dashed.length).toBeGreaterThan(0);
	});

	it('renders solid spouse line when no toYear (still married)', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [
				{
					id: 'e1',
					personId: ID_A,
					relatedPersonId: ID_B,
					personDisplayName: 'Anna',
					relatedPersonDisplayName: 'Bertha',
					relationType: 'SPOUSE_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const lines = Array.from(document.querySelectorAll('line'));
		const dashedLines = lines.filter((l) => l.getAttribute('stroke-dasharray'));
		expect(dashedLines.length).toBe(0);
	});

	it('renders single-parent connector lines when no spouse pair', async () => {
		const PARENT = '00000000-0000-0000-0000-00000000aaa1';
		const CHILD = '00000000-0000-0000-0000-00000000bbb1';
		render(StammbaumTree, {
			nodes: [
				{ id: PARENT, displayName: 'Parent', familyMember: true },
				{ id: CHILD, displayName: 'Child', familyMember: true }
			],
			edges: [
				{
					id: 'e-p',
					personId: PARENT,
					relatedPersonId: CHILD,
					personDisplayName: 'Parent',
					relatedPersonDisplayName: 'Child',
					relationType: 'PARENT_OF',
					fromDatePrecision: 'UNKNOWN',
					toDatePrecision: 'UNKNOWN'
				}
			],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const lines = document.querySelectorAll('line');
		expect(lines.length).toBeGreaterThanOrEqual(2);
	});

	it('focuses a node and renders the focus ring on focus event', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const node = document.querySelector('g[role="button"]') as SVGGElement;
		node.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
		await new Promise((r) => setTimeout(r, 30));

		const focusRing = Array.from(document.querySelectorAll('rect')).find(
			(r) => r.getAttribute('stroke') === 'var(--c-focus-ring)'
		);
		expect(focusRing).toBeDefined();
	});

	it('removes the focus ring on blur', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const node = document.querySelector('g[role="button"]') as SVGGElement;
		node.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
		await new Promise((r) => setTimeout(r, 30));
		node.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
		await new Promise((r) => setTimeout(r, 30));

		const focusRing = Array.from(document.querySelectorAll('rect')).find(
			(r) => r.getAttribute('stroke') === 'var(--c-focus-ring)'
		);
		expect(focusRing).toBeUndefined();
	});

	it('aria-label includes node displayName and life dates', async () => {
		render(StammbaumTree, {
			nodes: [
				{
					id: ID_A,
					displayName: 'Anna Schmidt',
					familyMember: true,
					birthYear: 1900,
					deathYear: 1980
				}
			],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const node = document.querySelector('g[role="button"]');
		expect(node?.getAttribute('aria-label')).toContain('Anna Schmidt');
		expect(node?.getAttribute('aria-label')).toContain('1900');
	});

	it('aria-expanded reflects selected state', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [],
			selectedId: ID_A,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const nodes = document.querySelectorAll('g[role="button"]');
		const a = nodes[0] as SVGGElement;
		const b = nodes[1] as SVGGElement;
		const aSelected = a.getAttribute('aria-expanded') === 'true';
		const bSelected = b.getAttribute('aria-expanded') === 'true';
		// Exactly one should be aria-expanded=true (the selected one)
		expect([aSelected, bSelected].filter(Boolean).length).toBe(1);
	});

	it('accent stripe rect appears only on selected node', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Anna', familyMember: true },
				{ id: ID_B, displayName: 'Bertha', familyMember: true }
			],
			edges: [],
			selectedId: ID_A,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		const accentRects = Array.from(document.querySelectorAll('rect')).filter(
			(r) => r.getAttribute('fill') === 'var(--c-accent)'
		);
		expect(accentRects.length).toBe(1);
	});
});

describe('StammbaumTree generation rail (#689, #692)', () => {
	const railLabels = () =>
		Array.from(document.querySelectorAll('[role="text"]')).map((el) =>
			el.getAttribute('aria-label')
		);

	it('renders a G{n} label per occupied generation row on the pinned rail', async () => {
		render(StammbaumTree, {
			nodes: [
				{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
				{ id: ID_B, displayName: 'Herbert', familyMember: true, generation: 3 }
			],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		await vi.waitFor(() => {
			const labels = railLabels();
			expect(labels).toContain('Generation 2');
			expect(labels).toContain('Generation 3');
		});
	});

	it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => {
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {}
		});

		await vi.waitFor(() => {
			const g3 = Array.from(document.querySelectorAll('[role="text"]')).find(
				(el) => el.getAttribute('aria-label') === 'Generation 3'
			);
			expect(g3).toBeDefined();
			expect(g3!.textContent).toMatch(/G\s*3/);
		});
	});

	it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => {
		// The rail is viewport-independent (the #692 point); only the desktop
		// stripe underlay is gated on the gutter breakpoint.
		render(StammbaumTree, {
			nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
			edges: [],
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			onSelect: () => {},
			showGutter: false
		});

		await vi.waitFor(() => expect(railLabels()).toContain('Generation 3'));
	});
});

describe('StammbaumTree lineage highlight (#703)', () => {
	// A three-generation family. Selecting "Vater" highlights his pedigree
	// (Grossvater, Grossmutter), his descendant (Kind), and the spouses of those
	// blood people (Mutter, his married-in wife). "Tante" is a collateral sibling
	// of Vater and must dim.
	type Edge = {
		id: string;
		personId: string;
		relatedPersonId: string;
		personDisplayName: string;
		relatedPersonDisplayName: string;
		relationType: 'PARENT_OF' | 'SPOUSE_OF';
		fromDatePrecision: 'UNKNOWN';
		toDatePrecision: 'UNKNOWN';
	};
	const edge = (
		personId: string,
		relatedPersonId: string,
		relationType: 'PARENT_OF' | 'SPOUSE_OF'
	): Edge => ({
		id: `${personId}-${relationType}-${relatedPersonId}`,
		personId,
		relatedPersonId,
		personDisplayName: '',
		relatedPersonDisplayName: '',
		relationType,
		fromDatePrecision: 'UNKNOWN',
		toDatePrecision: 'UNKNOWN'
	});

	const NODES = [
		{ id: 'gf', displayName: 'Grossvater', familyMember: true },
		{ id: 'gm', displayName: 'Grossmutter', familyMember: true },
		{ id: 'vater', displayName: 'Vater', familyMember: true },
		{ id: 'mutter', displayName: 'Mutter', familyMember: true },
		{ id: 'kind', displayName: 'Kind', familyMember: true },
		{ id: 'tante', displayName: 'Tante', familyMember: true }
	];
	const EDGES = [
		edge('gf', 'gm', 'SPOUSE_OF'),
		edge('gf', 'vater', 'PARENT_OF'),
		edge('gm', 'vater', 'PARENT_OF'),
		edge('gf', 'tante', 'PARENT_OF'),
		edge('gm', 'tante', 'PARENT_OF'),
		edge('vater', 'mutter', 'SPOUSE_OF'),
		edge('vater', 'kind', 'PARENT_OF'),
		edge('mutter', 'kind', 'PARENT_OF')
	];

	// The lineage dim lives on the inner content group (outline + labels); the
	// card fill renders outside it at full strength so connectors beneath never
	// bleed through a dimmed node.
	function nodeOpacity(displayName: string): string | null {
		const content = nodeContentGroup(displayName);
		return content.getAttribute('opacity');
	}
	function nodeContentGroup(displayName: string): Element {
		const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
		if (!g) throw new Error(`No node group rendered for ${displayName}`);
		const content = g.querySelector('g.lineage-fade');
		if (!content) throw new Error(`No content group rendered for ${displayName}`);
		return content;
	}
	/** The opaque card fill is a direct-child rect of the node, outside the dim group. */
	function cardFillIsOpaque(displayName: string): boolean {
		const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
		if (!g) throw new Error(`No node group rendered for ${displayName}`);
		const fill = g.querySelector(':scope > rect');
		if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`);
		return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null;
	}
	const DIM = String(DIMMED_OPACITY);

	// Connector groups render as direct <svg> children; the node content groups
	// (also .lineage-fade) are nested inside g[role="button"], so the child
	// combinator scopes cleanly to connectors.
	function dimmedConnectorCount(): number {
		return Array.from(document.querySelectorAll('svg > g.lineage-fade')).filter(
			(g) => g.getAttribute('opacity') === DIM
		).length;
	}

	it('renders every node at full strength when nothing is selected (AC1)', () => {
		render(StammbaumTree, {
			nodes: NODES,
			edges: EDGES,
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});

		for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull();
	});

	it('keeps the bloodline + spouses full and dims collaterals when a person is selected (AC2)', () => {
		render(StammbaumTree, {
			nodes: NODES,
			edges: EDGES,
			selectedId: 'vater',
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});

		// Anchor, ancestors, descendant, and the spouses of blood people stay full.
		for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
			expect(nodeOpacity(name)).toBeNull();
		}
		// The collateral sibling dims — but its card fill stays opaque so the
		// connectors drawn beneath it do not show through (the dim is on the
		// outline + labels only).
		expect(nodeOpacity('Tante')).toBe(DIM);
		expect(cardFillIsOpaque('Tante')).toBe(true);
	});

	it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => {
		const { rerender } = render(StammbaumTree, {
			nodes: NODES,
			edges: EDGES,
			selectedId: 'vater',
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});
		expect(nodeOpacity('Tante')).toBe(DIM);
		expect(nodeOpacity('Vater')).toBeNull();

		// Select Tante: her lineage is now active (her parents stay full), while
		// Vater's descendant branch (Kind, Mutter) drops out of the active set.
		await rerender({
			nodes: NODES,
			edges: EDGES,
			selectedId: 'tante',
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});
		expect(nodeOpacity('Tante')).toBeNull();
		expect(nodeOpacity('Kind')).toBe(DIM);
		expect(nodeOpacity('Mutter')).toBe(DIM);
	});

	it('returns the whole tree to full strength when the selection is cleared (AC7)', async () => {
		const { rerender } = render(StammbaumTree, {
			nodes: NODES,
			edges: EDGES,
			selectedId: 'vater',
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});
		expect(nodeOpacity('Tante')).toBe(DIM);

		await rerender({
			nodes: NODES,
			edges: EDGES,
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});
		for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull();
	});

	it('leaves every connector at full strength when nothing is selected (AC5)', () => {
		render(StammbaumTree, {
			nodes: NODES,
			edges: EDGES,
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});
		expect(dimmedConnectorCount()).toBe(0);
	});

	it('dims exactly the connector feeding the collateral child at the render layer (AC5)', () => {
		render(StammbaumTree, {
			nodes: NODES,
			edges: EDGES,
			selectedId: 'vater',
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});
		// Every connector among the bloodline + spouses stays full strength; only
		// the vertical joining the active parent pair (Grossvater+Grossmutter) to
		// the dimmed collateral child (Tante) renders at DIMMED_OPACITY. This proves
		// the <g opacity> render wiring — not just the isConnectorActive predicate —
		// and exercises the shared parent-pair per-child path.
		expect(dimmedConnectorCount()).toBe(1);
	});
});

describe('StammbaumTree keyboard tab order (#718)', () => {
	// Tab order follows DOM source order, so the node <g role="button"> elements
	// must render in reading order — top generation first, left-to-right within a
	// row — regardless of the order nodes arrive in the `nodes` array (which is
	// backend/DB order). Scope to in-SVG role="button" elements: the canvas and
	// the controls/side panel are also focusable, so a whole-page Tab count would
	// be brittle (see the issue's testing note).
	const WALTER = '00000000-0000-0000-0000-0000000000a1';
	const EUGENIE = '00000000-0000-0000-0000-0000000000a2';
	const CLARA = '00000000-0000-0000-0000-0000000000a3';
	const HANS = '00000000-0000-0000-0000-0000000000a4';

	// Walter ↔ Eugenie (gen 0); their children Clara + Hans (gen 1). The tidy-tree
	// layout (#724) orders a couple's run by structural ownership (earliest birth
	// year, then a deterministic id tie-break), not alphabetically — with no birth
	// years here Walter (id …a1) owns the run and Eugenie sits to his right. So the
	// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
	const FAMILY_EDGES: RelationshipDTO[] = [
		{
			id: 'sp',
			personId: WALTER,
			relatedPersonId: EUGENIE,
			personDisplayName: 'Walter',
			relatedPersonDisplayName: 'Eugenie',
			relationType: 'SPOUSE_OF',
			fromDatePrecision: 'UNKNOWN',
			toDatePrecision: 'UNKNOWN'
		},
		{
			id: 'p1',
			personId: WALTER,
			relatedPersonId: CLARA,
			personDisplayName: 'Walter',
			relatedPersonDisplayName: 'Clara',
			relationType: 'PARENT_OF',
			fromDatePrecision: 'UNKNOWN',
			toDatePrecision: 'UNKNOWN'
		},
		{
			id: 'p2',
			personId: EUGENIE,
			relatedPersonId: CLARA,
			personDisplayName: 'Eugenie',
			relatedPersonDisplayName: 'Clara',
			relationType: 'PARENT_OF',
			fromDatePrecision: 'UNKNOWN',
			toDatePrecision: 'UNKNOWN'
		},
		{
			id: 'p3',
			personId: WALTER,
			relatedPersonId: HANS,
			personDisplayName: 'Walter',
			relatedPersonDisplayName: 'Hans',
			relationType: 'PARENT_OF',
			fromDatePrecision: 'UNKNOWN',
			toDatePrecision: 'UNKNOWN'
		},
		{
			id: 'p4',
			personId: EUGENIE,
			relatedPersonId: HANS,
			personDisplayName: 'Eugenie',
			relatedPersonDisplayName: 'Hans',
			relationType: 'PARENT_OF',
			fromDatePrecision: 'UNKNOWN',
			toDatePrecision: 'UNKNOWN'
		}
	];

	function nodeLabelsInDomOrder(): string[] {
		const svg = document.querySelector('svg')!;
		return Array.from(svg.querySelectorAll('g[role="button"]')).map(
			(g) => (g.getAttribute('aria-label') ?? '').split(',')[0]
		);
	}

	it('renders node tab stops in reading order, not backend array order', () => {
		// Deliberately scrambled (not reading order): Hans (gen 1), Eugenie
		// (gen 0), Clara (gen 1), Walter (gen 0). Source order alone would tab
		// gen1 → gen0 → gen1 → gen0.
		render(StammbaumTree, {
			nodes: [
				{ id: HANS, displayName: 'Hans', familyMember: true },
				{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
				{ id: CLARA, displayName: 'Clara', familyMember: true },
				{ id: WALTER, displayName: 'Walter', familyMember: true }
			],
			edges: FAMILY_EDGES,
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});

		// Top generation left-to-right, then next generation left-to-right.
		expect(nodeLabelsInDomOrder()).toEqual(['Walter', 'Eugenie', 'Clara', 'Hans']);
	});

	it('orders tab stops by rendered position regardless of input order', () => {
		// Self-validating against the actual layout: DOM order of the node groups
		// must equal the same groups sorted by (y, then x). Independent of the
		// alphabetical assumption above, so it survives layout-internal changes.
		render(StammbaumTree, {
			nodes: [
				{ id: CLARA, displayName: 'Clara', familyMember: true },
				{ id: WALTER, displayName: 'Walter', familyMember: true },
				{ id: HANS, displayName: 'Hans', familyMember: true },
				{ id: EUGENIE, displayName: 'Eugenie', familyMember: true }
			],
			edges: FAMILY_EDGES,
			selectedId: null,
			panZoom: { x: 0, y: 0, z: 1 },
			showGutter: false,
			onSelect: () => {}
		});

		const svg = document.querySelector('svg')!;
		const placed = Array.from(svg.querySelectorAll('g[role="button"]')).map((g) => {
			const t = g.getAttribute('transform') ?? '';
			const m = t.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
			return {
				label: (g.getAttribute('aria-label') ?? '').split(',')[0],
				x: m ? parseFloat(m[1]) : Infinity,
				y: m ? parseFloat(m[2]) : Infinity
			};
		});
		const readingOrder = [...placed].sort((a, b) => a.y - b.y || a.x - b.x);
		expect(placed.map((n) => n.label)).toEqual(readingOrder.map((n) => n.label));
	});
});
