import { describe, it, expect } from 'vitest';
import { validateFixture, findAc3Candidates } from './validateFixture';
import canonicalFixture from './stammbaum.json';

// The fixture validator is the load-bearing contract for the canonical
// Stammbaum snapshot: every gate here corresponds to an invariant that
// buildLayout.test.ts relies on. Adding or removing a gate without updating
// these tests is the failure we want to catch.

// Lets `generation: null` typecheck without an inline `as number | null`
// cast — the AC3 predicate cares specifically about the unseeded-node
// branch, so the test fixtures need to express it directly.
type TestNode = { id: string; generation: number | null };

function networkWithNodes(count: number) {
	return {
		nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}`, generation: i % 6 })),
		edges: []
	};
}

function spouseEdge(a: string, b: string) {
	return {
		id: `${a}|${b}`,
		personId: a,
		relatedPersonId: b,
		personDisplayName: '',
		relatedPersonDisplayName: '',
		relationType: 'SPOUSE_OF'
	};
}

function parentEdge(parent: string, child: string) {
	return {
		id: `${parent}>${child}`,
		personId: parent,
		relatedPersonId: child,
		personDisplayName: '',
		relatedPersonDisplayName: '',
		relationType: 'PARENT_OF'
	};
}

describe('validateFixture', () => {
	it('passes_for_the_canonical_fixture', () => {
		expect(() => validateFixture(canonicalFixture)).not.toThrow();
	});

	it('rejects_a_fixture_below_the_min_node_floor', () => {
		expect(() => validateFixture(networkWithNodes(10))).toThrow(/>= 50 nodes/);
	});

	it('rejects_a_fixture_with_no_multi_spouse_person', () => {
		// 50 nodes, 5 generations, several SPOUSE_OF edges — but every spouse
		// edge connects a different pair, so nobody has more than one partner.
		// Without the multi-spouse floor this would silently pass and the
		// canonical_fixture_assigns_a_position_to_every_node_with_multiple_spouses
		// test in buildLayout.test.ts would degrade to vacuous truth.
		const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 }));
		const edges = [spouseEdge('n0', 'n1'), spouseEdge('n2', 'n3'), spouseEdge('n4', 'n5')];
		expect(() => validateFixture({ nodes, edges })).toThrow(
			/>= 1 person with multiple SPOUSE_OF edges/
		);
	});

	it('accepts_a_fixture_where_one_person_has_multiple_spouse_edges', () => {
		const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 }));
		const edges = [spouseEdge('n0', 'n1'), spouseEdge('n0', 'n2')];
		expect(() => validateFixture({ nodes, edges })).not.toThrow();
	});

	it('counts_multi_spouse_persons_via_either_edge_direction', () => {
		const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 }));
		// n5 is the related party in both edges — still counts as multi-spouse.
		const edges = [spouseEdge('n1', 'n5'), spouseEdge('n2', 'n5')];
		expect(() => validateFixture({ nodes, edges })).not.toThrow();
	});
});

describe('findAc3Candidates — AC3 revisit-trigger predicate (#361, Elicit cycle-2)', () => {
	// AC3 = "unseeded loose spouse whose parents are in the graph". Per ADR-026
	// this is the predicate that reopens the dagre decision once it appears
	// against the canonical fixture. The capture script runs this on every
	// recapture and warns to stderr (soft, non-blocking) so the human-in-the-
	// loop notices the moment the deferral is no longer free.

	it('finds_no_candidate_in_the_canonical_fixture_today', () => {
		// Anchors ADR-026's "Last run May 2026: 0 rows" annotation against the
		// committed fixture. Fails the moment the captured graph starts to
		// contain the AC3 shape — which is exactly the revisit trigger.
		expect(findAc3Candidates(canonicalFixture)).toEqual([]);
	});

	it('flags_an_unseeded_person_whose_seeded_parent_is_in_graph_and_who_has_a_spouse', () => {
		const nodes: TestNode[] = [
			{ id: 'parent', generation: 2 },
			{ id: 'child', generation: null },
			{ id: 'spouse', generation: 3 }
		];
		const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
		expect(findAc3Candidates({ nodes, edges })).toEqual(['child']);
	});

	it('ignores_an_unseeded_person_with_no_spouse_edge', () => {
		// Unseeded + seeded parent in graph, but no SPOUSE_OF — not AC3 (the
		// layout branch that hurts is the *loose spouse* branch). The capture
		// warn must not fire here.
		const nodes: TestNode[] = [
			{ id: 'parent', generation: 2 },
			{ id: 'child', generation: null }
		];
		const edges = [parentEdge('parent', 'child')];
		expect(findAc3Candidates({ nodes, edges })).toEqual([]);
	});

	it('ignores_an_unseeded_person_whose_parent_is_also_unseeded', () => {
		// "Parents in graph" in the AC3 sense means at least one *seeded*
		// parent. If both parent and child are unseeded the heuristic
		// fallback already handles the case without AC3 ever firing.
		const nodes: TestNode[] = [
			{ id: 'parent', generation: null },
			{ id: 'child', generation: null },
			{ id: 'spouse', generation: 3 }
		];
		const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
		expect(findAc3Candidates({ nodes, edges })).toEqual([]);
	});

	it('ignores_a_seeded_person_with_seeded_parents_and_a_spouse', () => {
		const nodes = [
			{ id: 'parent', generation: 2 },
			{ id: 'child', generation: 3 },
			{ id: 'spouse', generation: 3 }
		];
		const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
		expect(findAc3Candidates({ nodes, edges })).toEqual([]);
	});
});
