<!--
BattleScene — radial SVG visualisation of one battle frame.

Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a *cloud* of class circles
arranged on a Vogel sunflower spiral. Spiral positions are
reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (the bucket with the largest initial ship count)
always sits at the most-inward Vogel slot.

Tech-variant groups of the same `(race, className)` collapse to one
visual node — per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate. Order, position, radius and mass are locked at
battle start; only NumberLeft (the label number) and per-bucket
visibility change per frame. Empty buckets are hidden so the
remaining ones keep their original spots without reshuffling.

Observer groups (`inBattle === false`) are filtered out by
`buildFrames`. Same-race opponents are forbidden by the engine's
combat filter, so a shot never collapses to a single visual node.
-->
<script lang="ts">
	import { getContext } from "svelte";

	import type { BattleReport } from "../../api/battle-fetch";
	import {
		CORE_CONTEXT_KEY,
		type CoreHandle,
	} from "$lib/core-context.svelte";
	import { layoutRaces } from "./radial-layout";
	import {
		computeBattleGroupMass,
		radiusForMass,
		MAX_RADIUS,
		type ShipClassLookup,
	} from "./mass";
	import {
		buildGroupRaceMap,
		normaliseGroups,
		type Frame,
	} from "./timeline";

	let {
		report,
		frame,
		shipClassLookup,
		shotVisible = true,
	}: {
		report: BattleReport;
		frame: Frame;
		shipClassLookup?: ShipClassLookup;
		shotVisible?: boolean;
	} = $props();

	const VIEW_BOX = 800;
	const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
	const PLANET_RADIUS = 60;
	const RACE_RING_RADIUS = 280;
	const BASE_STEP = 1.8 * MAX_RADIUS;
	const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
	const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
	const LABEL_MIN_Y = 24;

	const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);

	const groupRace = $derived(buildGroupRaceMap(report.protocol));
	const allGroups = $derived(normaliseGroups(report));

	type StaticBucket = {
		bucketKey: string;
		className: string;
		race: string;
		raceId: number;
		groupKeys: number[];
		initialNum: number;
		mass: number;
		radius: number;
		// Local offsets in the cluster's (u, v) basis. `u` always
		// points from the race anchor toward the planet, so a
		// constant local-frame layout produces the same "inward" feel
		// regardless of which slot on the outer ring the race
		// currently occupies (races rotate when peers die).
		offsetU: number;
		offsetV: number;
	};

	// staticBucketsByRace locks the bucket roster, ordering, masses,
	// radii and local positions for the lifetime of this viewer. The
	// derivation only re-runs when `report` or the wasm `core` flip
	// (initial mount and core boot completion). Per-frame NumberLeft
	// changes do not touch this map — they live in `renderedByRace`.
	const staticBucketsByRace = $derived.by(() => {
		const core = coreHandle?.core ?? null;
		const out = new Map<number, StaticBucket[]>();
		const bucketIndex = new Map<string, StaticBucket>();
		for (const g of allGroups) {
			const bucketKey = `${g.raceId}::${g.group.className}`;
			let bucket = bucketIndex.get(bucketKey);
			if (bucket === undefined) {
				const classDef =
					shipClassLookup?.get(g.group.race, g.group.className) ?? null;
				const mass = core
					? computeBattleGroupMass(g.group, classDef, core)
					: 0;
				bucket = {
					bucketKey,
					className: g.group.className,
					race: g.group.race,
					raceId: g.raceId,
					groupKeys: [],
					initialNum: 0,
					mass,
					radius: MAX_RADIUS,
					offsetU: 0,
					offsetV: 0,
				};
				bucketIndex.set(bucketKey, bucket);
				const list = out.get(g.raceId) ?? [];
				list.push(bucket);
				out.set(g.raceId, list);
			}
			bucket.groupKeys.push(g.key);
			bucket.initialNum += g.group.num;
		}

		// Per-battle mass normalisation: the heaviest bucket renders
		// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
		let maxMass = 0;
		for (const bucket of bucketIndex.values()) {
			if (bucket.mass > maxMass) maxMass = bucket.mass;
		}
		for (const bucket of bucketIndex.values()) {
			bucket.radius = radiusForMass(bucket.mass, maxMass);
		}

		// Sort each race's buckets by initial count (descending) +
		// className as a stable tie-break, then assign Vogel positions
		// reordered by inward dot product (offsetU desc) so the
		// largest-by-num bucket lands at the most-inward Vogel slot.
		for (const list of out.values()) {
			list.sort((a, b) => {
				if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
				return a.className.localeCompare(b.className);
			});
			const N = list.length;
			const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
			const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
			const positions = Array.from({ length: N }, (_, r) => {
				const radius = step * Math.sqrt(r);
				const angle = r * GOLDEN_ANGLE;
				return {
					offsetU: radius * Math.cos(angle),
					offsetV: radius * Math.sin(angle),
				};
			});
			positions.sort((a, b) => {
				if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
				return a.offsetV - b.offsetV;
			});
			for (let r = 0; r < N; r++) {
				list[r].offsetU = positions[r].offsetU;
				list[r].offsetV = positions[r].offsetV;
			}
		}
		return out;
	});

	type RenderedBucket = StaticBucket & { numLeft: number };

	// renderedByRace overlays the per-frame `remaining` map onto the
	// static cluster: only buckets with `numLeft > 0` survive into
	// the render list, so an emptied class disappears from the cloud
	// while its neighbours keep their slots.
	const renderedByRace = $derived.by(() => {
		const out = new Map<number, RenderedBucket[]>();
		for (const [raceId, list] of staticBucketsByRace) {
			const filtered: RenderedBucket[] = [];
			for (const bucket of list) {
				let numLeft = 0;
				for (const key of bucket.groupKeys) {
					numLeft += frame.remaining.get(key) ?? 0;
				}
				if (numLeft > 0) filtered.push({ ...bucket, numLeft });
			}
			if (filtered.length > 0) out.set(raceId, filtered);
		}
		return out;
	});

	// visibleBucketByGroupKey lets shot endpoints resolve to a node
	// only when the bucket is currently rendered. A phantom shot
	// against an already-empty bucket therefore returns `null` and
	// no line is drawn.
	const visibleBucketByGroupKey = $derived.by(() => {
		const out = new Map<number, RenderedBucket>();
		for (const list of renderedByRace.values()) {
			for (const bucket of list) {
				for (const key of bucket.groupKeys) {
					out.set(key, bucket);
				}
			}
		}
		return out;
	});

	const raceLayout = $derived(
		layoutRaces(frame.activeRaceIds, {
			center: CENTER,
			radius: RACE_RING_RADIUS,
		}),
	);

	type ClusterBasis = {
		anchorX: number;
		anchorY: number;
		ux: number;
		uy: number;
		vx: number;
		vy: number;
	};

	const clusterBasisById = $derived.by(() => {
		const out = new Map<number, ClusterBasis>();
		for (const anchor of raceLayout) {
			const dx = CENTER.x - anchor.x;
			const dy = CENTER.y - anchor.y;
			const len = Math.hypot(dx, dy) || 1;
			const ux = dx / len;
			const uy = dy / len;
			const vx = uy;
			const vy = -ux;
			out.set(anchor.raceId, {
				anchorX: anchor.x,
				anchorY: anchor.y,
				ux,
				uy,
				vx,
				vy,
			});
		}
		return out;
	});

	function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
		return {
			x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
			y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
		};
	}

	function findClassCircleCenter(groupKey: number) {
		const bucket = visibleBucketByGroupKey.get(groupKey);
		if (!bucket) return null;
		const basis = clusterBasisById.get(bucket.raceId);
		if (!basis) return null;
		return worldPosition(basis, bucket);
	}

	const shotLine = $derived.by(() => {
		const action = frame.lastAction;
		if (!action) return null;
		const from = findClassCircleCenter(action.sa);
		const to = findClassCircleCenter(action.sd);
		if (!from || !to) return null;
		return { from, to, destroyed: action.x, defenderKey: action.sd };
	});

	const flashDefenderBucketKey = $derived.by(() => {
		if (!shotLine || !shotVisible) return null;
		const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
		return bucket?.bucketKey ?? null;
	});

	const raceLabelById = $derived.by(() => {
		const out = new Map<number, string>();
		for (const g of allGroups) {
			out.set(g.raceId, g.group.race);
		}
		for (const [, raceId] of groupRace) {
			if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
		}
		return out;
	});

	// raceLabelYById finds a y just above the visible cluster's top
	// edge and clamps it to the SVG viewport so the north race
	// (anchor near the top) never has its label clipped off-canvas.
	const raceLabelYById = $derived.by(() => {
		const out = new Map<number, number>();
		for (const [raceId, list] of renderedByRace) {
			const basis = clusterBasisById.get(raceId);
			if (!basis || list.length === 0) continue;
			let topY = basis.anchorY;
			for (const bucket of list) {
				const world = worldPosition(basis, bucket);
				const top = world.y - bucket.radius;
				if (top < topY) topY = top;
			}
			const fallback = basis.anchorY - MAX_RADIUS - 12;
			const target = Math.min(topY - 12, fallback);
			out.set(raceId, Math.max(target, LABEL_MIN_Y));
		}
		return out;
	});
</script>

<svg
	class="battle-scene"
	viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
	preserveAspectRatio="xMidYMid meet"
	role="img"
	aria-label="battle scene"
	data-testid="battle-scene"
>
	<circle
		cx={CENTER.x}
		cy={CENTER.y}
		r={PLANET_RADIUS}
		class="planet"
		data-testid="battle-scene-planet"
	/>
	<text
		x={CENTER.x}
		y={CENTER.y + PLANET_RADIUS + 24}
		text-anchor="middle"
		class="planet-label"
	>{report.planetName} (#{report.planet})</text>

	{#each raceLayout as anchor (anchor.raceId)}
		{@const cluster = renderedByRace.get(anchor.raceId) ?? []}
		{@const basis = clusterBasisById.get(anchor.raceId)}
		{#if basis && cluster.length > 0}
			<g
				class="race-cluster"
				data-testid="battle-race-cluster"
				data-race-id={anchor.raceId}
			>
				<text
					x={anchor.x}
					y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
					text-anchor="middle"
					class="race-label"
				>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
				{#each cluster as entry (entry.bucketKey)}
					{@const pos = worldPosition(basis, entry)}
					{@const flash =
						entry.bucketKey === flashDefenderBucketKey
							? shotLine?.destroyed
								? "destroyed"
								: "shielded"
							: null}
					<g
						class="class-marker"
						data-testid="battle-class-marker"
						data-bucket-key={entry.bucketKey}
						data-class-name={entry.className}
						data-flash={flash}
					>
						<circle cx={pos.x} cy={pos.y} r={entry.radius} />
						<text
							x={pos.x}
							y={pos.y + entry.radius + 12}
							text-anchor="middle"
							class="class-label"
						>{entry.className}:{entry.numLeft}</text>
					</g>
				{/each}
			</g>
		{/if}
	{/each}

	{#if shotLine && shotVisible}
		<line
			x1={shotLine.from.x}
			y1={shotLine.from.y}
			x2={shotLine.to.x}
			y2={shotLine.to.y}
			class="shot"
			class:destroyed={shotLine.destroyed}
			data-testid="battle-shot"
			data-destroyed={shotLine.destroyed ? "true" : "false"}
		/>
	{/if}
</svg>

<style>
	.battle-scene {
		width: 100%;
		height: 100%;
		background: #0a0d1a;
		display: block;
	}
	.planet {
		fill: #2a2f40;
		stroke: #4a5066;
		stroke-width: 1.5;
	}
	.planet-label {
		fill: #6d7388;
		font-size: 18px;
		font-family: ui-sans-serif, system-ui, sans-serif;
	}
	.race-label {
		fill: #e2e6ff;
		font-size: 16px;
		font-weight: 600;
		font-family: ui-sans-serif, system-ui, sans-serif;
	}
	.class-marker circle {
		fill: #1a2042;
		stroke: #6d7bb5;
		stroke-width: 1.5;
		transition:
			fill 80ms ease-in,
			stroke 80ms ease-in;
	}
	.class-marker[data-flash="destroyed"] circle {
		fill: #ee3344;
		stroke: #ee3344;
	}
	.class-marker[data-flash="shielded"] circle {
		fill: #44dd66;
		stroke: #44dd66;
	}
	.class-label {
		fill: #b8c0e6;
		font-size: 11px;
		font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	}
	.shot {
		stroke: #44dd66;
		stroke-width: 2;
	}
	.shot.destroyed {
		stroke: #ee3344;
	}
</style>
