import type { components } from '$lib/generated/api';
import { buildFamilyForest, type Unit } from './familyForest';
import { layoutForest, type TidyNode } from './tidyTree';

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

export const NODE_W = 160;
export const NODE_H = 56;
export const COL_GAP = 40;
export const ROW_GAP = 80;
export const VIEWBOX_PAD = 80;
export const MIN_VIEWBOX_W = 1200;
export const MIN_VIEWBOX_H = 800;

export type Layout = {
	positions: Map<string, { x: number; y: number }>;
	generations: Map<number, string[]>;
	// Displaced parent→child edges that span structural levels: the connector
	// renders these with a distinct dash (never the ended-marriage 4 4 cadence).
	crossLinks: { parentId: string; childId: string }[];
	viewX: number;
	viewY: number;
	viewW: number;
	viewH: number;
};

export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
	const childToParents = new Map<string, string[]>();
	// spousePairs is a Set per person so multi-spouse cases (#361) preserve all
	// marriages instead of having later edges silently clobber earlier ones.
	const spousePairs = new Map<string, Set<string>>();
	const allNodeIds = new Set(allNodes.map((n) => n.id));

	for (const e of allEdges) {
		// Defensive guard against edges referencing IDs outside the node list
		// (stale or partial graph snapshots) — applied to BOTH edge types so an
		// unknown id is never dereferenced into an undefined position.
		if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) continue;
		switch (e.relationType) {
			case 'PARENT_OF':
				mapPush(childToParents, e.relatedPersonId, e.personId);
				break;
			case 'SPOUSE_OF':
				mapAddToSet(spousePairs, e.personId, e.relatedPersonId);
				mapAddToSet(spousePairs, e.relatedPersonId, e.personId);
				break;
		}
	}

	// Vertical (y) is still driven entirely by generation rank — #689 seeding
	// and spouse pull-down are untouched.
	const rank = assignRanks(allNodes, childToParents, spousePairs);

	// Group by rank, then sort within rank by display name. Consumed by
	// StammbaumTree's gutter rows / generation rail — kept with the same
	// rank-keyed semantics and deliberately NOT folded into the x rewrite.
	const generations = new Map<number, string[]>();
	for (const n of allNodes) {
		const g = rank.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);
		});
	}

	// Horizontal (x): bottom-up tidy-tree over the family forest. Each unit's
	// run width is its member count laid out card+gap; tidyTree assigns the
	// run's left edge, and we spread the run's members rightward from there.
	// y comes from rank (never tree depth), so a child seeded more than one
	// generation below its parent simply gets a taller connector.
	const forest = buildFamilyForest(allNodes, allEdges);
	const toTidy = (u: Unit): TidyNode => ({
		id: u.id,
		width: u.members.length * NODE_W + (u.members.length - 1) * COL_GAP,
		// Pass the unit's rank as the contour level so unrelated roots that sit
		// at different generations can share x-columns instead of smearing wide.
		level: rank.get(u.id) ?? 0,
		children: u.children.map(toTidy)
	});
	const runX = layoutForest(forest.roots.map(toTidy), COL_GAP);

	const positions = new Map<string, { x: number; y: number }>();
	const place = (u: Unit) => {
		const left = runX.get(u.id) ?? 0;
		u.members.forEach((mid, i) => {
			positions.set(mid, {
				x: left + i * (NODE_W + COL_GAP),
				y: (rank.get(mid) ?? 0) * (NODE_H + ROW_GAP)
			});
		});
		u.children.forEach(place);
	};
	forest.roots.forEach(place);

	// Safety net: any node the forest did not place (it shouldn't leave any)
	// still gets a deterministic slot rather than vanishing from the canvas.
	let spare = runX.size;
	for (const n of allNodes) {
		if (positions.has(n.id)) continue;
		positions.set(n.id, {
			x: spare++ * (NODE_W + COL_GAP),
			y: (rank.get(n.id) ?? 0) * (NODE_H + ROW_GAP)
		});
	}

	const crossLinks = forest.crossLinks
		.filter((c) => !c.sameLevel)
		.map(({ parentId, childId }) => ({ parentId, childId }));

	const viewBox = computeViewBox(positions);
	return { positions, generations, crossLinks, ...viewBox };
}

// Bounding box around the actual content, expanded to MIN dimensions (so a
// single node doesn't get scaled up to fill the canvas) and centered on the
// content's midpoint.
function computeViewBox(positions: Map<string, { x: number; y: number }>): {
	viewX: number;
	viewY: number;
	viewW: number;
	viewH: number;
} {
	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 { viewX, viewY, viewW, viewH };
}

// Two-stage rank assignment (#689):
//
// 1. Seed: every node with imported generation is locked at that rank. The
//    fallback heuristic never moves a locked rank, and spouse-pulldown never
//    pulls a locked rank.
// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank) + 1,
//    reading parent rank from the same unified map so an unseeded child of a
//    seeded G 2 parent correctly inherits rank 3. Spouse-pulldown ties
//    unseeded spouses to their deeper partner.
// 3. Normalise: if any seeded rank is negative (a future G −1 ancestor), shift
//    the entire map so min(rank) == 0. No-op fast path covers today's data.
function assignRanks(
	allNodes: PersonNodeDTO[],
	childToParents: Map<string, string[]>,
	spousePairs: Map<string, Set<string>>
): Map<string, number> {
	const rank = new Map<string, number>();
	const locked = new Set<string>();
	for (const n of allNodes) {
		if (n.generation != null) {
			rank.set(n.id, n.generation);
			locked.add(n.id);
		} else {
			rank.set(n.id, 0);
		}
	}
	const maxIters = allNodes.length + 4;
	for (let it = 0; it < maxIters; it++) {
		let changed = false;
		for (const n of allNodes) {
			if (locked.has(n.id)) continue;
			const parents = childToParents.get(n.id) ?? [];
			if (parents.length === 0) continue;
			let maxParentRank = -Infinity;
			for (const pid of parents) {
				maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
			}
			const newRank = maxParentRank + 1;
			if ((rank.get(n.id) ?? 0) < newRank) {
				rank.set(n.id, newRank);
				changed = true;
			}
		}
		for (const [a, partners] of spousePairs) {
			for (const b of partners) {
				const ra = rank.get(a) ?? 0;
				const rb = rank.get(b) ?? 0;
				const m = Math.max(ra, rb);
				if (!locked.has(a) && ra < m) {
					rank.set(a, m);
					changed = true;
				}
				if (!locked.has(b) && rb < m) {
					rank.set(b, m);
					changed = true;
				}
			}
		}
		if (!changed) break;
	}
	let minRank = Infinity;
	for (const r of rank.values()) minRank = Math.min(minRank, r);
	if (minRank < 0) {
		const shift = -minRank;
		for (const [id, r] of rank) rank.set(id, r + shift);
	}
	return rank;
}

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 mapAddToSet<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
	const s = map.get(key);
	if (s) s.add(value);
	else map.set(key, new Set([value]));
}
