import type { components } from '$lib/generated/api';

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[]>;
	viewX: number;
	viewY: number;
	viewW: number;
	viewH: number;
};

export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
	const parentToChildren = new Map<string, string[]>();
	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));
	// Marriage years keyed by undirected pair (#361) drive the multi-spouse
	// sort order: fromYear ASC NULLS LAST, displayName ASC.
	const spouseFromYear = new Map<string, number | undefined>();

	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':
				// Defensive guard against edges referencing IDs outside the
				// node list (stale or partial graph snapshots) — keeps every
				// downstream iteration safely scoped to known nodes.
				if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) break;
				mapAddToSet(spousePairs, e.personId, e.relatedPersonId);
				mapAddToSet(spousePairs, e.relatedPersonId, e.personId);
				spouseFromYear.set(spousePairKey(e.personId, e.relatedPersonId), e.fromYear);
				break;
		}
	}

	const rank = assignRanks(allNodes, childToParents, spousePairs);

	// Group by rank, then sort within rank by display name.
	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);
		});
	}

	// 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: handle loose nodes.
		//
		// First pass collects every loose node that has a parented partner,
		// grouped by that partner. A second pass sorts each group by
		// (fromYear ASC NULLS LAST, displayName ASC) and inserts all spouses
		// immediately to the right of the parented partner in one splice —
		// matching Leonie's UX rule ("All spouses render to the right of the
		// focal person, ordered by marriage date, earliest closest").
		// Truly-loose nodes (no parented partner) get their own block here
		// and merge with their dual-loose partner in step 3.
		type LooseAttachment = { id: string; fromYear: number | undefined };
		const looseByParented = new Map<string, LooseAttachment[]>();
		for (const id of ids) {
			if (memberLookup.has(id)) continue;
			const partners = spousePairs.get(id);
			let parentedSpouse: string | undefined;
			if (partners) {
				for (const partnerId of partners) {
					if (memberLookup.get(partnerId)?.parented) {
						parentedSpouse = partnerId;
						break;
					}
				}
			}

			if (parentedSpouse) {
				const lookupKey = memberLookup.get(parentedSpouse)!.key;
				mapPush(looseByParented, parentedSpouse, {
					id,
					fromYear: spouseFromYear.get(spousePairKey(id, parentedSpouse))
				});
				memberLookup.set(id, { key: lookupKey, parented: false });
			} else {
				const blockKey = `__loose__${id}`;
				blocksByKey.set(blockKey, {
					members: [{ id, parented: false }],
					center: 0
				});
				memberLookup.set(id, { key: blockKey, parented: false });
			}
		}

		for (const [parentedId, attachments] of looseByParented) {
			attachments.sort((a, b) => {
				const ya = a.fromYear ?? Number.POSITIVE_INFINITY;
				const yb = b.fromYear ?? Number.POSITIVE_INFINITY;
				if (ya !== yb) return ya - yb;
				const an = byId.get(a.id)?.displayName ?? '';
				const bn = byId.get(b.id)?.displayName ?? '';
				return an.localeCompare(bn);
			});
			const block = blocksByKey.get(memberLookup.get(parentedId)!.key)!;
			const parentedIdx = block.members.findIndex((m) => m.id === parentedId);
			block.members.splice(
				parentedIdx + 1,
				0,
				...attachments.map((a) => ({ id: a.id, parented: false }))
			);
		}

		// Merge dual-loose spouse blocks into a single block. With multi-spouse,
		// iterate every partner so a loose person with N loose marriages ends
		// up in one shared block. Partners are sorted by (fromYear ASC NULLS
		// LAST, displayName ASC) before iteration so the resulting block
		// places spouses in the UX-spec order to the right of the focal.
		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 partners = spousePairs.get(member.id);
			if (!partners) continue;
			const sortedPartners = [...partners].sort((a, b) => {
				const ya = spouseFromYear.get(spousePairKey(member.id, a)) ?? Number.POSITIVE_INFINITY;
				const yb = spouseFromYear.get(spousePairKey(member.id, b)) ?? Number.POSITIVE_INFINITY;
				if (ya !== yb) return ya - yb;
				const an = byId.get(a)?.displayName ?? '';
				const bn = byId.get(b)?.displayName ?? '';
				return an.localeCompare(bn);
			});
			for (const partnerId of sortedPartners) {
				const spouseLookup = memberLookup.get(partnerId);
				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 3.5 (#361 AC2): Intra-family marriage. Two parented members at
		// the same rank in different sibling blocks who marry each other are
		// merged into one block — A's siblings on the left, the spouses on
		// the join boundary, B's siblings on the right — so the spouse line
		// stays short and no other node sits between them.
		const mergedKeys = new Set<string>();
		for (const [aKey, aBlock] of blocksByKey) {
			if (aKey.startsWith('__loose__')) continue;
			if (mergedKeys.has(aKey)) continue;
			for (const aMember of aBlock.members) {
				if (!aMember.parented) continue;
				const partners = spousePairs.get(aMember.id);
				if (!partners) continue;
				for (const partnerId of partners) {
					const partnerLookup = memberLookup.get(partnerId);
					if (!partnerLookup || !partnerLookup.parented) continue;
					if (partnerLookup.key === aKey) continue;
					if (partnerLookup.key.startsWith('__loose__')) continue;
					if (mergedKeys.has(partnerLookup.key)) continue;
					const bBlock = blocksByKey.get(partnerLookup.key)!;
					// A's spouse to the right-most slot in A's block; B's spouse
					// to the left-most slot in B's block; then concatenate.
					moveMemberToEnd(aBlock.members, aMember.id);
					moveMemberToStart(bBlock.members, partnerId);
					for (const m of bBlock.members) {
						memberLookup.set(m.id, { key: aKey, parented: m.parented });
					}
					aBlock.members.push(...bBlock.members);
					aBlock.center = (aBlock.center + bBlock.center) / 2;
					mergedKeys.add(partnerLookup.key);
				}
			}
		}
		for (const key of mergedKeys) 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;
		}
	}

	const viewBox = computeViewBox(positions);
	return { positions, generations, ...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]));
}

function spousePairKey(a: string, b: string): string {
	return a < b ? `${a}|${b}` : `${b}|${a}`;
}

function moveMemberToEnd<T extends { id: string }>(members: T[], id: string) {
	const idx = members.findIndex((m) => m.id === id);
	if (idx < 0 || idx === members.length - 1) return;
	const [m] = members.splice(idx, 1);
	members.push(m);
}

function moveMemberToStart<T extends { id: string }>(members: T[], id: string) {
	const idx = members.findIndex((m) => m.id === id);
	if (idx <= 0) return;
	const [m] = members.splice(idx, 1);
	members.unshift(m);
}
