// Unit tests for the BattleViewer's pure helpers: radial layout and
// the timeline frame builder. Both are pure functions and don't
// require DOM mounting, so they exercise the playback semantics in
// isolation.

import { describe, expect, it } from "vitest";

import type { BattleReport } from "../src/api/battle-fetch";
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
import {
	MAX_RADIUS,
	MIN_RADIUS,
	radiusForMass,
} from "../src/lib/battle-player/mass";
import {
	buildFrames,
	buildGroupRaceMap,
	normaliseGroups,
} from "../src/lib/battle-player/timeline";

describe("layoutRaces", () => {
	const center = { x: 100, y: 100 };
	const radius = 50;

	it("returns no anchors for an empty input", () => {
		expect(layoutRaces([], { center, radius })).toEqual([]);
	});

	it("places one race at the 12 o'clock position", () => {
		const result = layoutRaces([0], { center, radius });
		expect(result).toHaveLength(1);
		expect(result[0].raceId).toBe(0);
		expect(result[0].x).toBeCloseTo(center.x, 5);
		expect(result[0].y).toBeCloseTo(center.y - radius, 5);
	});

	it("places two races on the horizontal axis (9 vs 3 o'clock)", () => {
		// Special-case duel layout: two anchors face each other on
		// the horizontal axis so neither cluster's race label clips
		// against the SVG top edge.
		const result = layoutRaces([0, 1], { center, radius });
		expect(result).toHaveLength(2);
		expect(result[0].x).toBeCloseTo(center.x - radius, 5);
		expect(result[0].y).toBeCloseTo(center.y, 5);
		expect(result[1].x).toBeCloseTo(center.x + radius, 5);
		expect(result[1].y).toBeCloseTo(center.y, 5);
	});

	it("places three races at 120° intervals", () => {
		const result = layoutRaces([0, 1, 2], { center, radius });
		expect(result).toHaveLength(3);
		expect(result[0].angle).toBeCloseTo(-Math.PI / 2, 5);
		expect(result[1].angle - result[0].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
		expect(result[2].angle - result[1].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
	});

	it("preserves the input race order", () => {
		const result = layoutRaces([7, 2, 5], { center, radius });
		expect(result.map((a) => a.raceId)).toEqual([7, 2, 5]);
	});
});

const TWO_RACE_BATTLE: BattleReport = {
	id: "battle-1",
	planet: 4,
	planetName: "Test",
	races: { "0": "race-A-uuid", "1": "race-B-uuid" },
	ships: {
		"10": {
			race: "Alpha",
			className: "Drone",
			tech: {},
			num: 3,
			numLeft: 1,
			loadType: "EMP",
			loadQuantity: 0,
			inBattle: true,
		},
		"20": {
			race: "Beta",
			className: "Spy",
			tech: {},
			num: 2,
			numLeft: 0,
			loadType: "EMP",
			loadQuantity: 0,
			inBattle: true,
		},
		"99": {
			race: "Gamma",
			className: "Observer",
			tech: {},
			num: 4,
			numLeft: 4,
			loadType: "EMP",
			loadQuantity: 0,
			inBattle: false,
		},
	},
	protocol: [
		{ a: 0, sa: 10, d: 1, sd: 20, x: false },
		{ a: 1, sa: 20, d: 0, sd: 10, x: true },
		{ a: 0, sa: 10, d: 1, sd: 20, x: true },
		{ a: 0, sa: 10, d: 1, sd: 20, x: true },
	],
};

describe("buildGroupRaceMap", () => {
	it("derives group → race from protocol entries", () => {
		const map = buildGroupRaceMap(TWO_RACE_BATTLE.protocol);
		expect(map.get(10)).toBe(0);
		expect(map.get(20)).toBe(1);
	});
});

describe("normaliseGroups", () => {
	it("returns only in-battle groups with race index attached", () => {
		const groups = normaliseGroups(TWO_RACE_BATTLE);
		expect(groups.map((g) => g.key).sort((a, b) => a - b)).toEqual([10, 20]);
		expect(groups.every((g) => g.group.inBattle)).toBe(true);
	});
});

describe("buildFrames", () => {
	it("produces protocol.length + 1 frames", () => {
		const frames = buildFrames(TWO_RACE_BATTLE);
		expect(frames).toHaveLength(TWO_RACE_BATTLE.protocol.length + 1);
	});

	it("frame 0 reports initial ship counts and all active races", () => {
		const [first] = buildFrames(TWO_RACE_BATTLE);
		expect(first.shotIndex).toBe(0);
		expect(first.lastAction).toBeNull();
		expect(first.remaining.get(10)).toBe(3);
		expect(first.remaining.get(20)).toBe(2);
		expect(first.activeRaceIds).toEqual([0, 1]);
	});

	it("decrements destroyed defenders only on x === true", () => {
		const frames = buildFrames(TWO_RACE_BATTLE);
		// Action 1: x=false → no decrement on defender 20.
		expect(frames[1].remaining.get(20)).toBe(2);
		// Action 2: x=true → attacker is race 1 group 20, defender
		// is race 0 group 10 → group 10 drops 3→2.
		expect(frames[2].remaining.get(10)).toBe(2);
	});

	it("drops a race from activeRaceIds once its last in-battle group reaches zero", () => {
		const frames = buildFrames(TWO_RACE_BATTLE);
		// After the 4-th action both Beta ships have been destroyed.
		expect(frames[4].remaining.get(20)).toBe(0);
		expect(frames[4].activeRaceIds).toEqual([0]);
	});
});

describe("buildFrames phantom-destroy clamp", () => {
	it("does not drop a race when destroyed shots exceed initial counts", () => {
		// Race "Phantom" has a single group with 2 ships; the engine
		// emits five Destroyed shots against it (legacy emitter quirk
		// reproduced in KNNTS041 planet #7). The group goes to 0
		// after two real destroys; the remaining three are phantoms
		// and must not push raceTotals into negatives or drop the
		// race from activeRaceIds prematurely. Race "Survivor" keeps
		// its single ship throughout so it stays active alongside
		// Phantom until Phantom legitimately empties.
		const report: BattleReport = {
			id: "phantom-battle",
			planet: 1,
			planetName: "P",
			races: { "0": "phantom-uuid", "1": "survivor-uuid" },
			ships: {
				"10": {
					race: "Phantom",
					className: "Drone",
					tech: {},
					num: 2,
					numLeft: 0,
					loadType: "",
					loadQuantity: 0,
					inBattle: true,
				},
				"20": {
					race: "Survivor",
					className: "Hawk",
					tech: {},
					num: 1,
					numLeft: 1,
					loadType: "",
					loadQuantity: 0,
					inBattle: true,
				},
			},
			protocol: [
				{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1
				{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0
				{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1
				{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2
				{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3
			],
		};
		const frames = buildFrames(report);
		expect(frames[2].remaining.get(10)).toBe(0);
		// After the 2nd real destroy Phantom has 0 ships in its only
		// group and must drop out of activeRaceIds.
		expect(frames[2].activeRaceIds).toEqual([1]);
		// Phantoms past frame 2 must NOT keep decrementing — group
		// stays at 0, totals don't go negative, and Survivor remains
		// the only active race for the remainder of the protocol.
		expect(frames[5].remaining.get(10)).toBe(0);
		expect(frames[5].activeRaceIds).toEqual([1]);
	});

	it("keeps a race active while phantom destroys hit one of its empty groups", () => {
		// One race ("Doublet"), two groups of different class. Class
		// A gets all five Destroyed shots; class B never gets hit.
		// Class A only has 2 ships → 3 phantoms. The race must stay
		// active because class B's single ship is intact.
		const report: BattleReport = {
			id: "doublet-battle",
			planet: 2,
			planetName: "P2",
			races: { "0": "doublet-uuid", "1": "attacker-uuid" },
			ships: {
				"10": {
					race: "Doublet",
					className: "A",
					tech: {},
					num: 2,
					numLeft: 0,
					loadType: "",
					loadQuantity: 0,
					inBattle: true,
				},
				"11": {
					race: "Doublet",
					className: "B",
					tech: {},
					num: 1,
					numLeft: 1,
					loadType: "",
					loadQuantity: 0,
					inBattle: true,
				},
				"20": {
					race: "Attacker",
					className: "Gun",
					tech: {},
					num: 1,
					numLeft: 1,
					loadType: "",
					loadQuantity: 0,
					inBattle: true,
				},
			},
			protocol: [
				// Open the protocol with a shot that names class B so
				// normaliseGroups picks it up (groups never referenced
				// in the protocol are filtered out of the visual
				// roster); the shot misses so class B stays intact.
				{ a: 1, sa: 20, d: 0, sd: 11, x: false },
				{ a: 1, sa: 20, d: 0, sd: 10, x: true },
				{ a: 1, sa: 20, d: 0, sd: 10, x: true },
				{ a: 1, sa: 20, d: 0, sd: 10, x: true },
				{ a: 1, sa: 20, d: 0, sd: 10, x: true },
				{ a: 1, sa: 20, d: 0, sd: 10, x: true },
			],
		};
		const frames = buildFrames(report);
		// After all 6 actions: Doublet:A is at 0 (group capped at 2
		// real destroys + 3 phantoms), Doublet:B unchanged at 1, so
		// race totals = 1 → race stays active.
		expect(frames[6].remaining.get(10)).toBe(0);
		expect(frames[6].remaining.get(11)).toBe(1);
		expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]);
	});
});

describe("radiusForMass", () => {
	it("returns MAX_RADIUS when mass is zero", () => {
		expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
	});

	it("returns MAX_RADIUS when maxMassInBattle is zero", () => {
		expect(radiusForMass(50, 0)).toBe(MAX_RADIUS);
	});

	it("returns MAX_RADIUS at the per-battle ceiling", () => {
		expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5);
	});

	it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => {
		const r = radiusForMass(25, 100);
		const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5;
		expect(r).toBeCloseTo(expected, 5);
	});

	it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => {
		expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS);
		expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS);
	});
});

