// Phase 19 ship-group → World primitive translation. Sits next to
// `state-binding.ts` so the latter can stay focused on planets while
// the more involved group geometry (in-hyperspace interpolation,
// incoming-trajectory lines) lives here.
//
// Position rules:
//   - On-planet local / other groups (origin === null, range === null)
//     are NOT rendered on the map. Stationed groups would otherwise
//     pile up next to every populated planet and turn the canvas
//     into noise; the planet inspector lists them instead
//     (see `lib/inspectors/planet/ship-groups.svelte`).
//   - In-hyperspace local / other groups (origin / range set) —
//     interpolated along the origin → destination line at `range`
//     world units from the destination. The line is the wrap-aware
//     shortest path on a torus.
//   - Incoming groups — origin and destination are always present;
//     emit a dashed red trajectory line from origin to a wrap-aware
//     destination plus a clickable point at the interpolated
//     position (range = the `distance` field).
//   - Unidentified groups — drawn at the absolute (x, y) the radar
//     reports.
//
// Torus-shortest deltas come from `map/math.torusShortestDelta`. The
// canonical Go-side equivalent is `pkg/calc.ShortestDelta`; the TS
// helper duplicates the formula because the renderer's hot path
// avoids the WASM boundary cost. Both implementations agree on the
// half-circumference tie-break.
//
// PrimitiveIDs are partitioned via large per-variant offsets so they
// never collide with planet ids (which run in `[0, planetCount)`).

import type {
	GameReport,
	ReportLocalShipGroup,
	ReportOtherShipGroup,
	ReportPlanet,
	ReportUnidentifiedShipGroup,
} from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { torusShortestDelta } from "./math";
import {
	DARK_THEME,
	type LinePrim,
	type PointPrim,
	type PrimitiveID,
	type Style,
	type Theme,
} from "./world";

/**
 * SHIP_GROUP_ID_OFFSETS partitions the primitive-id namespace so a
 * hit on a ship-group primitive is unambiguous: the offset alone
 * disambiguates the variant and `id - offset` recovers the index
 * (or, for `local`, lookup happens via the parallel hitLookup map
 * since UUID strings cannot fit in a numeric primitive id).
 */
export const SHIP_GROUP_ID_OFFSETS = {
	local: 100_000_000,
	localLine: 150_000_000,
	other: 200_000_000,
	incoming: 300_000_000,
	incomingLine: 350_000_000,
	unidentified: 400_000_000,
} as const;

// shipGroupStyles builds the per-variant `Style` objects for the
// active theme. Only the colours are theme-driven; the alpha, radius,
// and dash spacing are fixed emphasis values. The in-space track
// reuses the own-group colour and the incoming trajectory line reuses
// the incoming colour so each pair reads as one entity.
function shipGroupStyles(theme: Theme): {
	local: Style;
	localLine: Style;
	other: Style;
	incoming: Style;
	incomingLine: Style;
	unidentified: Style;
} {
	return {
		local: { fillColor: theme.shipLocal, fillAlpha: 0.95, pointRadiusPx: 3 },
		localLine: {
			strokeColor: theme.shipLocal,
			strokeAlpha: 0.7,
			strokeWidthPx: 1,
			strokeDashPx: 4,
		},
		other: { fillColor: theme.shipOther, fillAlpha: 0.9, pointRadiusPx: 3 },
		incoming: {
			fillColor: theme.shipIncoming,
			fillAlpha: 1,
			pointRadiusPx: 4,
		},
		incomingLine: {
			strokeColor: theme.shipIncoming,
			strokeAlpha: 0.85,
			strokeWidthPx: 1,
			strokeDashPx: 4,
		},
		unidentified: {
			fillColor: theme.shipUnidentified,
			fillAlpha: 0.65,
			pointRadiusPx: 3,
		},
	};
}

// Priority order inside `hit-test`: ship groups outrank planets so a
// hyperspace group landing on top of an unidentified planet is
// selectable. The trajectory line itself is given the lowest priority
// so a click on the dashed segment never "wins" over the clickable
// point at the interpolated position.
const PRIORITY_LOCAL = 5;
const PRIORITY_LOCAL_LINE = 0;
const PRIORITY_OTHER = 5;
const PRIORITY_INCOMING_POINT = 6;
const PRIORITY_INCOMING_LINE = 0;
const PRIORITY_UNIDENTIFIED = 4;

/**
 * ShipGroupCategory tags every emitted primitive with the toggleable
 * surface it belongs to. The Phase 29 hide-set machinery in
 * `lib/active-view/map.svelte` looks these up via `categories` to
 * decide whether to hide the primitive when the matching `MapToggles`
 * flag is `false`.
 */
export type ShipGroupCategory =
	| "hyperspaceGroup"
	| "incomingGroup"
	| "unidentifiedGroup";

export interface ShipGroupPrimitives {
	primitives: (PointPrim | LinePrim)[];
	lookup: Map<PrimitiveID, ShipGroupRef>;
	categories: Map<PrimitiveID, ShipGroupCategory>;
	/**
	 * planetDependents maps a planet number to the set of primitive
	 * ids that should hide together with that planet. In Phase 29 the
	 * hide-by-id machinery cascades planet visibility onto in-space
	 * and incoming groups flying *to* the planet (their points + the
	 * trajectory / track lines). Unidentified groups have no planet
	 * anchor and therefore contribute nothing here.
	 */
	planetDependents: Map<number, Set<PrimitiveID>>;
}

function addDependent(
	planetDependents: Map<number, Set<PrimitiveID>>,
	planetNumber: number,
	primitiveId: PrimitiveID,
): void {
	let set = planetDependents.get(planetNumber);
	if (set === undefined) {
		set = new Set();
		planetDependents.set(planetNumber, set);
	}
	set.add(primitiveId);
}

export function shipGroupsToPrimitives(
	report: GameReport,
	theme: Theme = DARK_THEME,
): ShipGroupPrimitives {
	const styles = shipGroupStyles(theme);
	const primitives: (PointPrim | LinePrim)[] = [];
	const lookup = new Map<PrimitiveID, ShipGroupRef>();
	const categories = new Map<PrimitiveID, ShipGroupCategory>();
	const planetDependents = new Map<number, Set<PrimitiveID>>();
	const planetIndex = new Map<number, ReportPlanet>();
	for (const planet of report.planets) {
		planetIndex.set(planet.number, planet);
	}
	const w = report.mapWidth;
	const h = report.mapHeight;

	for (let i = 0; i < report.localShipGroups.length; i++) {
		const group = report.localShipGroups[i]!;
		const pos = computeInSpacePosition(group, planetIndex, w, h);
		if (pos === null) continue;
		const id = SHIP_GROUP_ID_OFFSETS.local + i;
		primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, styles.local));
		lookup.set(id, { variant: "local", id: group.id });
		categories.set(id, "hyperspaceGroup");
		addDependent(planetDependents, group.destination, id);
		// Yellow dashed track from the origin planet to the destination
		// planet. The colour matches the in-space group point so the
		// player can read both as one entity at a glance. Wrap-aware
		// like the incoming-line: we unwrap `destination` relative to
		// `origin`, drawing the segment in a single tile, and PixiJS
		// repeats the world in torus mode.
		const origin = planetIndex.get(group.origin!);
		const destination = planetIndex.get(group.destination);
		if (origin !== undefined && destination !== undefined) {
			const dx = torusShortestDelta(origin.x, destination.x, w);
			const dy = torusShortestDelta(origin.y, destination.y, h);
			const lineId = SHIP_GROUP_ID_OFFSETS.localLine + i;
			primitives.push({
				kind: "line",
				id: lineId,
				priority: PRIORITY_LOCAL_LINE,
				style: styles.localLine,
				hitSlopPx: 0,
				x1: origin.x,
				y1: origin.y,
				x2: origin.x + dx,
				y2: origin.y + dy,
			});
			categories.set(lineId, "hyperspaceGroup");
			addDependent(planetDependents, group.destination, lineId);
		}
	}

	for (let i = 0; i < report.otherShipGroups.length; i++) {
		const group = report.otherShipGroups[i]!;
		const pos = computeInSpacePosition(group, planetIndex, w, h);
		if (pos === null) continue;
		const id = SHIP_GROUP_ID_OFFSETS.other + i;
		primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, styles.other));
		lookup.set(id, { variant: "other", index: i });
		categories.set(id, "hyperspaceGroup");
		addDependent(planetDependents, group.destination, id);
	}

	for (let i = 0; i < report.incomingShipGroups.length; i++) {
		const group = report.incomingShipGroups[i]!;
		const origin = planetIndex.get(group.origin);
		const destination = planetIndex.get(group.destination);
		if (origin === undefined || destination === undefined) continue;
		// Unwrap the destination relative to origin so the line crosses
		// the torus seam when that is the shorter path. Renderer-side
		// we draw the segment in a single tile; in torus mode PixiJS
		// repeats the world so the line still appears continuous on
		// the visible side of the seam.
		const dx = torusShortestDelta(origin.x, destination.x, w);
		const dy = torusShortestDelta(origin.y, destination.y, h);
		const destX = origin.x + dx;
		const destY = origin.y + dy;
		const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i;
		primitives.push({
			kind: "line",
			id: lineId,
			priority: PRIORITY_INCOMING_LINE,
			style: styles.incomingLine,
			hitSlopPx: 0,
			x1: origin.x,
			y1: origin.y,
			x2: destX,
			y2: destY,
		});
		categories.set(lineId, "incomingGroup");
		addDependent(planetDependents, group.destination, lineId);
		const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance);
		const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
		primitives.push(
			makePoint(
				pointId,
				pos.x,
				pos.y,
				PRIORITY_INCOMING_POINT,
				styles.incoming,
				/*hitSlopPx*/ 4,
			),
		);
		lookup.set(pointId, { variant: "incoming", index: i });
		categories.set(pointId, "incomingGroup");
		addDependent(planetDependents, group.destination, pointId);
	}

	for (let i = 0; i < report.unidentifiedShipGroups.length; i++) {
		const group: ReportUnidentifiedShipGroup =
			report.unidentifiedShipGroups[i]!;
		const id = SHIP_GROUP_ID_OFFSETS.unidentified + i;
		primitives.push(
			makePoint(
				id,
				group.x,
				group.y,
				PRIORITY_UNIDENTIFIED,
				styles.unidentified,
			),
		);
		lookup.set(id, { variant: "unidentified", index: i });
		categories.set(id, "unidentifiedGroup");
	}

	return { primitives, lookup, categories, planetDependents };
}

/**
 * computeInSpacePosition returns the renderer-side (x, y) of a local
 * or foreign group that is currently in hyperspace. On-planet groups
 * (origin === null || range === null) are intentionally skipped so the
 * map does not pile dozens of primitives onto every populated planet
 * — the planet inspector lists them instead. Returns null when either
 * the group is on-planet, or the origin / destination planets are
 * not visible to the local player.
 *
 * Exported so the active-view map can centre the camera on an
 * in-space group when the F8-10 tables raise a `selection.focus`
 * request for one.
 */
export function computeInSpacePosition(
	group: ReportLocalShipGroup | ReportOtherShipGroup,
	planetIndex: Map<number, ReportPlanet>,
	mapWidth: number,
	mapHeight: number,
): { x: number; y: number } | null {
	if (group.origin === null || group.range === null) return null;
	const destination = planetIndex.get(group.destination);
	if (destination === undefined) return null;
	const origin = planetIndex.get(group.origin);
	if (origin === undefined) return null;
	const dx = torusShortestDelta(destination.x, origin.x, mapWidth);
	const dy = torusShortestDelta(destination.y, origin.y, mapHeight);
	const total = Math.hypot(dx, dy);
	if (total === 0 || group.range <= 0) {
		return { x: destination.x, y: destination.y };
	}
	const t = Math.min(1, group.range / total);
	return { x: destination.x + t * dx, y: destination.y + t * dy };
}

/**
 * interpolateAlongLine returns the point that sits `range` world
 * units away from `(dx, dy)` toward `(ox, oy)`. The total path length
 * is the Euclidean distance between the two anchors; the position is
 * `dest + (range / total) × (origin - dest)`. When the anchors are
 * coincident or `range` is zero the result is the destination, which
 * is fine for the ship-group rendering — a degenerate group still
 * gets a click target on the destination planet.
 */
function interpolateAlongLine(
	dx: number,
	dy: number,
	ox: number,
	oy: number,
	range: number,
): { x: number; y: number } {
	const ddx = ox - dx;
	const ddy = oy - dy;
	const total = Math.hypot(ddx, ddy);
	if (total === 0 || range <= 0) return { x: dx, y: dy };
	const t = Math.min(1, range / total);
	return { x: dx + t * ddx, y: dy + t * ddy };
}

function makePoint(
	id: PrimitiveID,
	x: number,
	y: number,
	priority: number,
	style: Style,
	hitSlopPx = 0,
): PointPrim {
	return { kind: "point", id, priority, style, hitSlopPx, x, y };
}
