// Typed wrapper around `GalaxyClient.executeCommand("user.games.report",
// ...)`. The signed-gRPC wire shape is the FlatBuffers
// `report.GameReportRequest` for the request and `report.Report` for
// the response (see `pkg/schema/fbs/report.fbs`). Full ship / fleet /
// science decoding lands in Phases 17-22.
//
// Phase 13 expanded the per-planet projection so the inspector can
// render every documented field without a second round-trip. Each
// planet field is optional: the FBS schema carries different field
// sets for `LocalPlanet`, `OtherPlanet`, `UninhabitedPlanet`, and
// `UnidentifiedPlanet`, and the wrapper preserves that nullability
// instead of inventing zero values.
//
// Phase 14 adds `applyOrderOverlay`: every applied / submitting
// rename in the local draft swaps the planet name on the rendered
// report so the player sees their intent reflected immediately,
// without waiting for the next turn cutoff.
//
// Phase 15 added a name-only `localShipClass` projection so the
// planet inspector's Build-Ship sub-picker had data to render.
// Phase 17 widens `ShipClassSummary` to the full attribute set
// (drive / armament / weapons / shields / cargo) so the ship-class
// table and designer can render every documented field, and
// extends `applyOrderOverlay` with the `createShipClass` /
// `removeShipClass` variants — pending Save / Delete actions are
// reflected in the table immediately, without waiting for the
// auto-sync round-trip.
//
// Phase 21 adds `localScience` (a list of `ScienceSummary` rows
// decoded from `Report.local_science`) so the sciences table and
// designer have data to render, and extends `applyOrderOverlay` with
// the `createScience` / `removeScience` variants — pending Save /
// Delete actions surface in the table and the planet production
// picker's Research sub-row immediately.

import { Builder, ByteBuffer } from "flatbuffers";

import type { GalaxyClient } from "./galaxy-client";
import { UUID } from "../proto/galaxy/fbs/common";
import {
	Bombing,
	GameReportRequest,
	IncomingGroup,
	LocalFleet,
	LocalGroup,
	OtherGroup,
	OtherScience,
	OthersShipClass,
	Report,
	ShipProduction,
	UnidentifiedGroup,
} from "../proto/galaxy/fbs/report";
import type {
	CargoLoadType,
	CommandStatus,
	OrderCommand,
	ProductionType,
	Relation,
} from "../sync/order-types";
import {
	CARGO_LOAD_TYPE_VALUES,
	isCargoLoadType,
	isRelation,
} from "../sync/order-types";

const MESSAGE_TYPE = "user.games.report";

export class GameStateError extends Error {
	readonly resultCode: string;
	readonly code: string;

	constructor(resultCode: string, code: string, message: string) {
		super(message);
		this.name = "GameStateError";
		this.resultCode = resultCode;
		this.code = code;
	}
}

export interface ReportPlanet {
	number: number;
	name: string;
	x: number;
	y: number;
	kind: "local" | "other" | "uninhabited" | "unidentified";
	owner: string | null;
	size: number | null;
	resources: number | null;
	// Engine field naming carries history: `capital` ($) is the
	// industry stockpile, `material` (M) is the materials stockpile.
	// `pkg/model/report/planet.go` is the source of truth for these.
	industryStockpile: number | null;
	materialsStockpile: number | null;
	industry: number | null;
	population: number | null;
	colonists: number | null;
	production: string | null;
	freeIndustry: number | null;
}

/**
 * ShipClassSummary is the projection of `report.ShipClass` the
 * ship-class table and designer render. Phase 15 carried just the
 * `name` for the Build-Ship sub-picker; Phase 17 added the five
 * tech-derived numbers so the table can sort / filter on them and
 * the designer can populate read-only previews. The numeric ranges
 * mirror `pkg/calc/validator.go.ValidateShipTypeValues` exactly:
 * each of `drive`, `weapons`, `shields`, `cargo` is either zero or
 * ≥ 1, and `armament` is a non-negative integer.
 */
export interface ShipClassSummary {
	name: string;
	drive: number;
	armament: number;
	weapons: number;
	shields: number;
	cargo: number;
}

/**
 * ScienceSummary is the projection of `report.Science` the sciences
 * table and designer render. The four tech proportions are fractions
 * in `[0, 1]` summing to `1.0`, mirroring
 * `pkg/calc/validator.go.ValidateScienceValues` exactly. The designer
 * presents them as percentages (`value * 100`) so users can type and
 * reason about whole-number proportions; the wire shape stays
 * fractional. Used by `lib/active-view/table-sciences.svelte`,
 * `lib/active-view/designer-science.svelte`, and the planet
 * production picker (`lib/inspectors/planet/production.svelte`).
 */
export interface ScienceSummary {
	name: string;
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}

/**
 * ReportRouteEntry is one slot of a planet's cargo-route table —
 * a (loadType, destinationPlanetNumber) pair. The engine stores
 * the entries as `map[RouteType]uint` per planet
 * (`game/internal/model/game/planet.go`); this type flattens that
 * map into an array so iteration order is stable for tests and
 * the map-arrow renderer.
 */
export interface ReportRouteEntry {
	loadType: CargoLoadType;
	destinationPlanetNumber: number;
}

/**
 * ReportRoute groups every cargo-route slot configured on a
 * single source planet. `entries` is sorted by
 * `CARGO_LOAD_TYPE_VALUES` priority (COL → CAP → MAT → EMP) so
 * the inspector and the map renderer see deterministic order.
 */
export interface ReportRoute {
	sourcePlanetNumber: number;
	entries: ReportRouteEntry[];
}

/**
 * ShipGroupTech holds the four component tech levels carried by every
 * ship group. Mirrors the `tech` map on `pkg/model/report.OtherGroup`
 * (encoded on the wire as a `[TechEntry]` vector) but flattens the
 * four well-known keys into a fixed-shape struct so the inspector can
 * render them with the same call as the planet-side ship-class table.
 * Keys missing from the wire default to zero.
 */
export interface ShipGroupTech {
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}

/**
 * ReportShipGroupBase carries the fields shared by `LocalGroup` and
 * `OtherGroup` server-side. `cargo` is `"NONE"` when the group is
 * empty (legacy `"-"` is normalised to that literal here so the union
 * with `CargoLoadType` is closed). `origin` and `range` are non-null
 * iff the group is in hyperspace.
 */
export interface ReportShipGroupBase {
	count: number;
	class: string;
	tech: ShipGroupTech;
	cargo: CargoLoadType | "NONE";
	load: number;
	destination: number;
	origin: number | null;
	range: number | null;
	speed: number;
	mass: number;
}

/**
 * ReportLocalShipGroup is the player's own ship group, carrying the
 * group UUID (used for selection and for the upcoming Phase 20 order
 * envelopes), the engine state (`In_Orbit` / `In_Space` / `In_Battle`
 * / `Out_Battle`), and the optional fleet membership.
 */
export interface ReportLocalShipGroup extends ReportShipGroupBase {
	id: string;
	state: string;
	fleet: string | null;
}

export type ReportOtherShipGroup = ReportShipGroupBase;

/**
 * ReportIncomingShipGroup is a foreign group inbound to one of the
 * player's planets. The legacy "Incoming Groups" table only exposes
 * the bare path/distance/speed/mass — the actual ship class is
 * unknown until the group lands and shows up in a battle roster.
 */
export interface ReportIncomingShipGroup {
	origin: number;
	destination: number;
	distance: number;
	speed: number;
	mass: number;
}

/**
 * ReportUnidentifiedShipGroup is a blip on radar — no class, no
 * destination, just absolute coordinates. Phase 19 renders it as a
 * dim point and exposes the coordinates in a minimal inspector.
 */
export interface ReportUnidentifiedShipGroup {
	x: number;
	y: number;
}

/**
 * ReportLocalFleet is the player's own combat fleet — a named group
 * of groups. Phase 19 surfaces only the fleet name on the
 * ship-group inspector; full fleet listings are deferred.
 */
export interface ReportLocalFleet {
	name: string;
	groupCount: number;
	destination: number;
	origin: number | null;
	range: number | null;
	speed: number;
	state: string;
}

/**
 * ReportOtherRace is the per-other-race projection rendered by the
 * Phase 22 Races View. The fields mirror `report.fbs:Player` row-by-
 * row, with `relation` narrowed to the wire-stable `Relation` union
 * (the engine emits a `"-"` sentinel for the self row, which never
 * appears in `GameReport.races` because self is filtered out by
 * `decodeReport`). Tech values are float fractions — the table
 * renders them through the same `formatPercent` helper the sciences
 * table uses.
 *
 * `relation` reflects the local player's stance TOWARD this race,
 * not the other way around (`rules.txt` line 1162). Per the engine
 * (`controller/race.go.UpdateRelation`) the relation is stored
 * unilaterally — race A can be at war with race B while race B is
 * at peace with race A.
 *
 * `votesReceived` is the count of votes this race received in the
 * last turn cutoff tally (`Player.votes` on the wire). The total
 * game votes equal the sum of every non-extinct row's
 * `votesReceived`, since every race always votes for someone
 * (`controller/race.go` initialises `r.VoteFor = r.ID` on creation
 * and reassigns to self on extinction of the voted-for race).
 */
export interface ReportOtherRace {
	name: string;
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
	population: number;
	industry: number;
	planets: number;
	relation: Relation;
	votesReceived: number;
}

/**
 * ReportPlayer is the per-player projection consumed by the Phase 23
 * Report View's Player Status section. Unlike `ReportOtherRace`, this
 * row carries the local player and extinct rows too: the section is a
 * status overview, not a diplomacy surface. Sorted alphabetically by
 * name (case-insensitive); `isLocal` flags the calling player's row so
 * the section can highlight it. The wire `relation` field is
 * intentionally omitted — the self row carries the engine's "-"
 * sentinel and the other-race rows already expose it via
 * `GameReport.races`.
 */
export interface ReportPlayer {
	name: string;
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
	population: number;
	industry: number;
	planets: number;
	votesReceived: number;
	extinct: boolean;
	isLocal: boolean;
}

/**
 * ReportOtherScience is a single row in the Phase 23 Report View's
 * Foreign Sciences section. Mirrors the wire `OtherScience` (carries
 * the owning `race` alongside the four tech proportions). Stable
 * order: sorted by `(race, name)` so the report's per-race sub-tables
 * render deterministically.
 */
export interface ReportOtherScience {
	race: string;
	name: string;
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}

/**
 * ReportOtherShipClass is a single row in the Phase 23 Report View's
 * Foreign Ship Classes section. Mirrors the wire `OthersShipClass`
 * (carries the owning `race`, the five tech-derived numbers, plus the
 * `mass` the local ship-classes table does not surface — useful for
 * fleet-mass comparison against incoming groups). Stable order:
 * sorted by `(race, name)`.
 */
export interface ReportOtherShipClass {
	race: string;
	name: string;
	drive: number;
	armament: number;
	weapons: number;
	shields: number;
	cargo: number;
	mass: number;
}

/**
 * ReportBombing is a single row in the Phase 23 Report View's
 * Bombings section. Mirrors the wire `Bombing` (post-bombing planet
 * snapshot, attacker/owner identity, attack power, and the boolean
 * `wiped` flag that drives a visually-distinct row state). Sorted by
 * `planetNumber` for deterministic rendering.
 *
 * Field naming follows the existing `ReportPlanet` convention:
 * `capital → industryStockpile`, `material → materialsStockpile`,
 * `number → planetNumber`.
 */
export interface ReportBombing {
	planetNumber: number;
	planet: string;
	owner: string;
	attacker: string;
	production: string;
	industry: number;
	population: number;
	colonists: number;
	industryStockpile: number;
	materialsStockpile: number;
	attackPower: number;
	wiped: boolean;
}

/**
 * ReportShipProduction is a single row in the Phase 23 Report View's
 * Ships In Production section. Mirrors the wire `ShipProduction`.
 * `planetNumber` resolves against `GameReport.planets` so the section
 * can render the producing planet's name; `cost` is the per-ship
 * production cost (`ShipProductionCost(shipMass)`, not including the
 * per-turn material-farming term); `prodUsed` is the engine's residual
 * production poured into the partial ship this turn; `percent` is the
 * cumulative build progress as a fraction in [0, 1]; `freeIndustry`
 * mirrors the producing planet's free industry. Stable order: sorted
 * by `(planetNumber, class)`.
 */
/**
 * ReportBattle is one battle summary in the current turn. Carries the
 * battle UUID, planet number, and shot count — enough to render a
 * battle marker on the map and to link into the Battle Viewer without
 * fetching the full BattleReport.
 */
export interface ReportBattle {
	id: string;
	planet: number;
	shots: number;
}

export interface ReportShipProduction {
	planetNumber: number;
	class: string;
	cost: number;
	prodUsed: number;
	percent: number;
	freeIndustry: number;
}

export interface GameReport {
	turn: number;
	mapWidth: number;
	mapHeight: number;
	planetCount: number;
	planets: ReportPlanet[];
	/**
	 * race is the calling player's race name as resolved by the
	 * engine from the runtime player mapping. Empty when the engine
	 * has not produced a report yet (boot state).
	 */
	race: string;
	/**
	 * localShipClass enumerates the player's own designed ship classes
	 * by name. Empty until at least one class is created
	 * (`CommandShipClassCreate`, Phase 17). The Build-Ship sub-picker
	 * shows a localized "no ship classes" placeholder when this is
	 * empty.
	 */
	localShipClass: ShipClassSummary[];
	/**
	 * localScience enumerates the player's own defined sciences. Each
	 * entry carries the four tech proportions as fractions in `[0, 1]`
	 * summing to `1.0`. Empty until at least one science is created
	 * (`CommandScienceCreate`, Phase 21). The sciences table and the
	 * planet production picker's Research sub-row read from this
	 * projection (after `applyOrderOverlay`) so freshly-queued
	 * `createScience` / `removeScience` actions surface immediately.
	 */
	localScience: ScienceSummary[];
	/**
	 * routes lists every cargo route the player has configured.
	 * Each entry is keyed by source planet; the per-planet
	 * `entries` array is sorted in turn-cutoff load order
	 * (`CARGO_LOAD_TYPE_VALUES`). Empty when no routes are set or
	 * when the report does not carry the route field.
	 */
	routes: ReportRoute[];
	/**
	 * localPlayerDrive, localPlayerWeapons, localPlayerShields,
	 * localPlayerCargo carry the local player's four tech levels,
	 * read from the matching `Player` row in the report. Drive
	 * powers reach (`40 * driveTech`,
	 * `game/internal/model/game/race.go.FlightDistance`) and the
	 * cargo-route picker; cargo feeds the ship-class designer's
	 * cargo-capacity preview (`pkg/calc/ship.go.CargoCapacity` and
	 * `CarryingMass`); weapons and shields are surfaced ahead of
	 * Phases 19-21 (ship-group inspector, science designer) so
	 * future patches do not need to re-extend the report decoder.
	 * All four are zero on boot or when the report's player block
	 * is missing the local entry.
	 */
	localPlayerDrive: number;
	localPlayerWeapons: number;
	localPlayerShields: number;
	localPlayerCargo: number;
	/**
	 * localShipGroups, otherShipGroups, incomingShipGroups,
	 * unidentifiedShipGroups, and localFleets land in Phase 19. Empty
	 * arrays are emitted whenever the report does not carry the
	 * matching wire field — boot state, history-mode snapshots, and
	 * the synthetic-report path that cannot derive a section from
	 * legacy text.
	 */
	localShipGroups: ReportLocalShipGroup[];
	otherShipGroups: ReportOtherShipGroup[];
	incomingShipGroups: ReportIncomingShipGroup[];
	unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
	localFleets: ReportLocalFleet[];
	/**
	 * otherRaces lists the names of every non-extinct race other than
	 * the local player, sorted alphabetically. Drawn from the
	 * `report.player[]` block in the FBS report (each `Player` row
	 * carries an `extinct` flag). The ship-group inspector consumes
	 * this list for the "transfer to race" picker; Phase 22's Races
	 * View also uses it for the vote-recipient picker so the read
	 * shape stays stable across stages. Empty when the report has no
	 * `player` block (boot state, history-mode snapshots) or when the
	 * local player is the only non-extinct race.
	 */
	otherRaces: string[];
	/**
	 * races is the richer per-other-race projection Phase 22 added
	 * for the Races View table — same population (non-extinct, self
	 * excluded, alphabetical) as `otherRaces`, but with each row
	 * carrying tech levels, totals, planet count, the local player's
	 * stance toward that race, and the race's votes received. Rows
	 * with an unknown wire `relation` (anything other than `WAR` or
	 * `PEACE`) default to `PEACE` so the table never blanks out the
	 * toggle on an engine schema bump; the same row continues to
	 * appear in the table.
	 */
	races: ReportOtherRace[];
	/**
	 * myVotes is the local player's total vote weight in the current
	 * report, read from `Report.votes` (the engine assigns one vote
	 * per 1000 population, see `rules.txt:1060`). Zero when the
	 * report has not been produced yet.
	 */
	myVotes: number;
	/**
	 * myVoteFor is the race the local player currently votes for,
	 * read from `Report.vote_for`. Empty string when no value has
	 * been recorded yet (boot state) or when the engine emitted an
	 * empty string. The engine's default initial state is each race
	 * voting for itself (`controller/race.go`), so a stable game's
	 * report always carries a non-empty value.
	 */
	myVoteFor: string;
	/**
	 * players is the richer per-player projection Phase 23 added for
	 * the Report View's Player Status section. Same data source as
	 * `races[]` (`report.player[]`) but with the local player and
	 * extinct rows included, sorted alphabetically by name and tagged
	 * with `isLocal`. `races[]` stays Phase 22's view (other,
	 * non-extinct) so diplomatic-stance code paths do not churn.
	 */
	players: ReportPlayer[];
	/**
	 * otherScience is the per-race foreign-sciences projection Phase
	 * 23 added for the Report View's Foreign Sciences section. Sorted
	 * by `(race, name)`. Empty when the report has no foreign science
	 * data (boot state, single-race game, legacy synthetic data).
	 */
	otherScience: ReportOtherScience[];
	/**
	 * otherShipClass is the per-race foreign-ship-classes projection
	 * Phase 23 added for the Report View's Foreign Ship Classes
	 * section. Sorted by `(race, name)`. Empty when the report has no
	 * foreign ship-class data.
	 */
	otherShipClass: ReportOtherShipClass[];
	/**
	 * battles is the list of battle summaries the engine recorded for
	 * the current turn. Each entry carries the battle UUID, the planet
	 * it happened on, and the number of shots exchanged. The Reports
	 * View uses `id` to link into the Battle Viewer; the map renderer
	 * uses `planet` to locate the marker and `shots` to scale its
	 * stroke. Empty when no battles occurred last turn.
	 */
	battles: ReportBattle[];
	/**
	 * battleIds is a convenience derived list of UUIDs from `battles`,
	 * preserved for legacy callers (Phase 23 report section, fixtures).
	 */
	battleIds: string[];
	/**
	 * bombings is the per-bombing projection Phase 23 added for the
	 * Report View's Bombings section. Sorted by `planetNumber`. Empty
	 * when no planets were bombed last turn.
	 */
	bombings: ReportBombing[];
	/**
	 * shipProductions is the per-ship-production projection Phase 23
	 * added for the Report View's Ships In Production section.
	 * Sorted by `(planetNumber, class)`. Empty when no planet is
	 * currently producing a ship.
	 */
	shipProductions: ReportShipProduction[];
}

export async function fetchGameReport(
	client: GalaxyClient,
	gameId: string,
	turn: number,
): Promise<GameReport> {
	const builder = new Builder(64);
	const [hi, lo] = uuidToHiLo(gameId);
	const gameIdOffset = UUID.createUUID(builder, hi, lo);
	GameReportRequest.startGameReportRequest(builder);
	GameReportRequest.addGameId(builder, gameIdOffset);
	GameReportRequest.addTurn(builder, turn);
	builder.finish(GameReportRequest.endGameReportRequest(builder));

	const result = await client.executeCommand(MESSAGE_TYPE, builder.asUint8Array());
	if (result.resultCode !== "ok") {
		const { code, message } = decodeErrorMessage(result.payloadBytes);
		throw new GameStateError(result.resultCode, code, message);
	}
	const buffer = new ByteBuffer(result.payloadBytes);
	const report = Report.getRootAsReport(buffer);
	return decodeReport(report);
}

function decodeReport(report: Report): GameReport {
	const planets: ReportPlanet[] = [];

	for (let i = 0; i < report.localPlanetLength(); i++) {
		const p = report.localPlanet(i);
		if (p === null) continue;
		planets.push({
			number: Number(p.number()),
			name: p.name() ?? "",
			x: p.x(),
			y: p.y(),
			kind: "local",
			owner: null,
			size: p.size(),
			resources: p.resources(),
			industryStockpile: p.capital(),
			materialsStockpile: p.material(),
			industry: p.industry(),
			population: p.population(),
			colonists: p.colonists(),
			production: p.production() ?? null,
			freeIndustry: p.freeIndustry(),
		});
	}

	for (let i = 0; i < report.otherPlanetLength(); i++) {
		const p = report.otherPlanet(i);
		if (p === null) continue;
		planets.push({
			number: Number(p.number()),
			name: p.name() ?? "",
			x: p.x(),
			y: p.y(),
			kind: "other",
			owner: p.owner() ?? null,
			size: p.size(),
			resources: p.resources(),
			industryStockpile: p.capital(),
			materialsStockpile: p.material(),
			industry: p.industry(),
			population: p.population(),
			colonists: p.colonists(),
			production: p.production() ?? null,
			freeIndustry: p.freeIndustry(),
		});
	}

	for (let i = 0; i < report.uninhabitedPlanetLength(); i++) {
		const p = report.uninhabitedPlanet(i);
		if (p === null) continue;
		planets.push({
			number: Number(p.number()),
			name: p.name() ?? "",
			x: p.x(),
			y: p.y(),
			kind: "uninhabited",
			owner: null,
			size: p.size(),
			resources: p.resources(),
			industryStockpile: p.capital(),
			materialsStockpile: p.material(),
			industry: null,
			population: null,
			colonists: null,
			production: null,
			freeIndustry: null,
		});
	}

	for (let i = 0; i < report.unidentifiedPlanetLength(); i++) {
		const p = report.unidentifiedPlanet(i);
		if (p === null) continue;
		planets.push({
			number: Number(p.number()),
			name: "",
			x: p.x(),
			y: p.y(),
			kind: "unidentified",
			owner: null,
			size: null,
			resources: null,
			industryStockpile: null,
			materialsStockpile: null,
			industry: null,
			population: null,
			colonists: null,
			production: null,
			freeIndustry: null,
		});
	}

	const localShipClass: ShipClassSummary[] = [];
	for (let i = 0; i < report.localShipClassLength(); i++) {
		const sc = report.localShipClass(i);
		if (sc === null) continue;
		localShipClass.push({
			name: sc.name() ?? "",
			drive: sc.drive(),
			armament: Number(sc.armament()),
			weapons: sc.weapons(),
			shields: sc.shields(),
			cargo: sc.cargo(),
		});
	}

	const localScience: ScienceSummary[] = [];
	for (let i = 0; i < report.localScienceLength(); i++) {
		const s = report.localScience(i);
		if (s === null) continue;
		localScience.push({
			name: s.name() ?? "",
			drive: s.drive(),
			weapons: s.weapons(),
			shields: s.shields(),
			cargo: s.cargo(),
		});
	}

	const raceName = report.race() ?? "";
	const routes = decodeReportRoutes(report);
	const localTech = findLocalPlayerTech(report, raceName);
	const otherRaces = collectOtherRaces(report, raceName);
	const races = collectOtherRaceRows(report, raceName);
	const players = decodePlayers(report, raceName);
	const localShipGroups = decodeLocalShipGroups(report);
	const otherShipGroups = decodeOtherShipGroups(report);
	const incomingShipGroups = decodeIncomingShipGroups(report);
	const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report);
	const localFleets = decodeLocalFleets(report);
	const otherScience = decodeOtherScience(report);
	const otherShipClass = decodeOtherShipClass(report);
	const battles = decodeBattles(report);
	const battleIds = battles.map((b) => b.id);
	const bombings = decodeBombings(report);
	const shipProductions = decodeShipProductions(report);

	return {
		turn: Number(report.turn()),
		mapWidth: report.width(),
		mapHeight: report.height(),
		planetCount: report.planetCount(),
		planets,
		race: raceName,
		localShipClass,
		localScience,
		routes,
		localPlayerDrive: localTech.drive,
		localPlayerWeapons: localTech.weapons,
		localPlayerShields: localTech.shields,
		localPlayerCargo: localTech.cargo,
		localShipGroups,
		otherShipGroups,
		incomingShipGroups,
		unidentifiedShipGroups,
		localFleets,
		otherRaces,
		races,
		myVotes: report.votes(),
		myVoteFor: report.voteFor() ?? "",
		players,
		otherScience,
		otherShipClass,
		battles,
		battleIds,
		bombings,
		shipProductions,
	};
}

/**
 * decodeReportRoutes flattens `report.route()[]` into the typed
 * `ReportRoute[]`. Each `Route` carries `planet` (source) and an
 * array of `RouteEntry` rows where `key` is the destination
 * planet number and `value` is the load-type string. Entries
 * with unknown load-types are dropped with a `console.warn` so a
 * future schema bump never silently corrupts the inspector.
 */
function decodeReportRoutes(report: Report): ReportRoute[] {
	const out: ReportRoute[] = [];
	for (let i = 0; i < report.routeLength(); i++) {
		const route = report.route(i);
		if (route === null) continue;
		const sourcePlanetNumber = Number(route.planet());
		const entries: ReportRouteEntry[] = [];
		for (let j = 0; j < route.routeLength(); j++) {
			const entry = route.route(j);
			if (entry === null) continue;
			const value = entry.value() ?? "";
			if (!isCargoLoadType(value)) {
				console.warn(
					`decodeReport: skipping RouteEntry with unknown load-type "${value}"`,
				);
				continue;
			}
			entries.push({
				loadType: value,
				destinationPlanetNumber: Number(entry.key()),
			});
		}
		entries.sort(compareRouteEntriesByLoadType);
		out.push({ sourcePlanetNumber, entries });
	}
	return out;
}

/**
 * decodeShipGroupTech walks a ship-group's `tech` vector and copies the
 * four well-known keys into the fixed-shape `ShipGroupTech` struct.
 * Unknown keys are dropped silently — adding a new tech component to
 * the engine does not break older clients, only widens the union.
 * Missing keys default to zero so the inspector never has to guard
 * against `undefined`.
 */
function decodeShipGroupTech(
	techAt: (i: number) => { key(): string | null; value(): number } | null,
	techLength: number,
): ShipGroupTech {
	const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
	for (let i = 0; i < techLength; i++) {
		const entry = techAt(i);
		if (entry === null) continue;
		const key = entry.key();
		if (key === null) continue;
		const value = entry.value();
		switch (key) {
			case "drive":
				out.drive = value;
				break;
			case "weapons":
				out.weapons = value;
				break;
			case "shields":
				out.shields = value;
				break;
			case "cargo":
				out.cargo = value;
				break;
		}
	}
	return out;
}

/**
 * normaliseCargoType maps the wire `cargo` string into the closed
 * union the inspector consumes. The legacy convention uses `"-"` for
 * empty groups; the typed contract spells that as `"NONE"`. Unknown
 * values warn and collapse to `"NONE"` so a future schema bump never
 * silently corrupts the inspector.
 */
function normaliseCargoType(raw: string | null): CargoLoadType | "NONE" {
	if (raw === null || raw === "" || raw === "-") return "NONE";
	if (isCargoLoadType(raw)) return raw;
	console.warn(`decodeReport: unknown cargo type "${raw}"`);
	return "NONE";
}

function decodeLocalShipGroups(report: Report): ReportLocalShipGroup[] {
	const out: ReportLocalShipGroup[] = [];
	for (let i = 0; i < report.localGroupLength(); i++) {
		const g = report.localGroup(i);
		if (g === null) continue;
		const id = uuidStringFromFB(g.id());
		if (id === null) continue;
		const origin = g.origin();
		const range = g.range();
		out.push({
			id,
			count: Number(g.number()),
			class: g.class_() ?? "",
			tech: decodeShipGroupTech(
				(j) => g.tech(j),
				g.techLength(),
			),
			cargo: normaliseCargoType(g.cargo()),
			load: g.load(),
			destination: Number(g.destination()),
			origin: origin === null ? null : Number(origin),
			range,
			speed: g.speed(),
			mass: g.mass(),
			state: g.state() ?? "",
			fleet: g.fleet(),
		});
	}
	return out;
}

function decodeOtherShipGroups(report: Report): ReportOtherShipGroup[] {
	const out: ReportOtherShipGroup[] = [];
	for (let i = 0; i < report.otherGroupLength(); i++) {
		const g = report.otherGroup(i);
		if (g === null) continue;
		const origin = g.origin();
		const range = g.range();
		out.push({
			count: Number(g.number()),
			class: g.class_() ?? "",
			tech: decodeShipGroupTech(
				(j) => g.tech(j),
				g.techLength(),
			),
			cargo: normaliseCargoType(g.cargo()),
			load: g.load(),
			destination: Number(g.destination()),
			origin: origin === null ? null : Number(origin),
			range,
			speed: g.speed(),
			mass: g.mass(),
		});
	}
	return out;
}

function decodeIncomingShipGroups(report: Report): ReportIncomingShipGroup[] {
	const out: ReportIncomingShipGroup[] = [];
	for (let i = 0; i < report.incomingGroupLength(); i++) {
		const g = report.incomingGroup(i);
		if (g === null) continue;
		out.push({
			origin: Number(g.origin()),
			destination: Number(g.destination()),
			distance: g.distance(),
			speed: g.speed(),
			mass: g.mass(),
		});
	}
	return out;
}

function decodeUnidentifiedShipGroups(
	report: Report,
): ReportUnidentifiedShipGroup[] {
	const out: ReportUnidentifiedShipGroup[] = [];
	for (let i = 0; i < report.unidentifiedGroupLength(); i++) {
		const g = report.unidentifiedGroup(i);
		if (g === null) continue;
		out.push({ x: g.x(), y: g.y() });
	}
	return out;
}

function decodeLocalFleets(report: Report): ReportLocalFleet[] {
	const out: ReportLocalFleet[] = [];
	for (let i = 0; i < report.localFleetLength(); i++) {
		const f = report.localFleet(i);
		if (f === null) continue;
		const origin = f.origin();
		const range = f.range();
		out.push({
			name: f.name() ?? "",
			groupCount: Number(f.groups()),
			destination: Number(f.destination()),
			origin: origin === null ? null : Number(origin),
			range,
			speed: f.speed(),
			state: f.state() ?? "",
		});
	}
	return out;
}

/**
 * uuidStringFromFB stitches a `common.UUID` flatbuffer struct back
 * into the canonical 36-character hex form. Inverse of
 * [uuidToHiLo]. Returns `null` for a missing UUID — the caller
 * decides whether to skip the row (current Phase 19 behaviour) or
 * synthesise a placeholder.
 */
function uuidStringFromFB(uuid: UUID | null): string | null {
	if (uuid === null) return null;
	const hi = uuid.hi();
	const lo = uuid.lo();
	const hex = bigUintTo16Hex(hi) + bigUintTo16Hex(lo);
	return (
		hex.slice(0, 8) +
		"-" +
		hex.slice(8, 12) +
		"-" +
		hex.slice(12, 16) +
		"-" +
		hex.slice(16, 20) +
		"-" +
		hex.slice(20, 32)
	);
}

function bigUintTo16Hex(value: bigint): string {
	let hex = (value & ((BigInt(1) << BigInt(64)) - BigInt(1))).toString(16);
	while (hex.length < 16) hex = "0" + hex;
	return hex;
}

const LOAD_TYPE_ORDER: Record<CargoLoadType, number> = (() => {
	const map = {} as Record<CargoLoadType, number>;
	CARGO_LOAD_TYPE_VALUES.forEach((value, index) => {
		map[value] = index;
	});
	return map;
})();

function compareRouteEntriesByLoadType(
	a: ReportRouteEntry,
	b: ReportRouteEntry,
): number {
	return LOAD_TYPE_ORDER[a.loadType] - LOAD_TYPE_ORDER[b.loadType];
}

interface LocalPlayerTech {
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}

/**
 * findLocalPlayerTech locates the local player's four tech levels
 * by matching `Player.name` against the report's `race` field (the
 * engine uses race name as the runtime player identifier). Returns
 * a zero-filled record when the lookup fails — boot state, an
 * incomplete report, or a future schema bump that switches to
 * UUIDs. Wrapping the lookup in one helper keeps the migration
 * cost contained.
 */
function findLocalPlayerTech(
	report: Report,
	raceName: string,
): LocalPlayerTech {
	if (raceName === "") return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
	for (let i = 0; i < report.playerLength(); i++) {
		const player = report.player(i);
		if (player === null) continue;
		if ((player.name() ?? "") !== raceName) continue;
		return {
			drive: player.drive(),
			weapons: player.weapons(),
			shields: player.shields(),
			cargo: player.cargo(),
		};
	}
	return { drive: 0, weapons: 0, shields: 0, cargo: 0 };
}

/**
 * collectOtherRaces walks the `report.player[]` block and returns
 * the alphabetically-sorted names of every non-extinct race other
 * than the local player. Used by `GameReport.otherRaces` to back the
 * ship-group inspector's transfer-to-race picker (Phase 20) and the
 * Races View vote-recipient picker (Phase 22).
 */
function collectOtherRaces(report: Report, raceName: string): string[] {
	const out: string[] = [];
	for (let i = 0; i < report.playerLength(); i++) {
		const player = report.player(i);
		if (player === null) continue;
		if (player.extinct()) continue;
		const name = player.name() ?? "";
		if (name === "" || name === raceName) continue;
		out.push(name);
	}
	out.sort((a, b) => a.localeCompare(b));
	return out;
}

/**
 * collectOtherRaceRows walks the `report.player[]` block and returns
 * the richer per-race projection consumed by the Phase 22 Races
 * View. Same filter as `collectOtherRaces` (non-extinct, named,
 * self excluded), same alphabetical sort. The engine emits
 * `Player.relation = "-"` on the self row only — that row is
 * filtered out, so a non-`"WAR"`/`"PEACE"` value here would mean a
 * schema bump; we fall back to `"PEACE"` and keep the row visible
 * rather than dropping it silently.
 */
function collectOtherRaceRows(
	report: Report,
	raceName: string,
): ReportOtherRace[] {
	const out: ReportOtherRace[] = [];
	for (let i = 0; i < report.playerLength(); i++) {
		const player = report.player(i);
		if (player === null) continue;
		if (player.extinct()) continue;
		const name = player.name() ?? "";
		if (name === "" || name === raceName) continue;
		const wire = player.relation() ?? "";
		const relation: Relation = isRelation(wire) ? wire : "PEACE";
		out.push({
			name,
			drive: player.drive(),
			weapons: player.weapons(),
			shields: player.shields(),
			cargo: player.cargo(),
			population: player.population(),
			industry: player.industry(),
			planets: player.planets(),
			relation,
			votesReceived: player.votes(),
		});
	}
	out.sort((a, b) => a.name.localeCompare(b.name));
	return out;
}

/**
 * decodePlayers walks `report.player[]` and emits the full status
 * roster the Phase 23 Report View's Player Status section renders:
 * every named row including the local player and extinct races,
 * sorted alphabetically (case-insensitive). The local row carries
 * `isLocal: true` so the section can highlight it; the wire
 * `relation` field is intentionally dropped (self carries the engine
 * "-" sentinel, other rows already surface relation through
 * `GameReport.races`).
 */
function decodePlayers(report: Report, raceName: string): ReportPlayer[] {
	const out: ReportPlayer[] = [];
	for (let i = 0; i < report.playerLength(); i++) {
		const player = report.player(i);
		if (player === null) continue;
		const name = player.name() ?? "";
		if (name === "") continue;
		out.push({
			name,
			drive: player.drive(),
			weapons: player.weapons(),
			shields: player.shields(),
			cargo: player.cargo(),
			population: player.population(),
			industry: player.industry(),
			planets: player.planets(),
			votesReceived: player.votes(),
			extinct: player.extinct(),
			isLocal: name === raceName,
		});
	}
	out.sort((a, b) =>
		a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
	);
	return out;
}

function decodeOtherScience(report: Report): ReportOtherScience[] {
	const out: ReportOtherScience[] = [];
	for (let i = 0; i < report.otherScienceLength(); i++) {
		const s = report.otherScience(i);
		if (s === null) continue;
		out.push({
			race: s.race() ?? "",
			name: s.name() ?? "",
			drive: s.drive(),
			weapons: s.weapons(),
			shields: s.shields(),
			cargo: s.cargo(),
		});
	}
	out.sort((a, b) => {
		const byRace = a.race.localeCompare(b.race);
		if (byRace !== 0) return byRace;
		return a.name.localeCompare(b.name);
	});
	return out;
}

function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
	const out: ReportOtherShipClass[] = [];
	for (let i = 0; i < report.otherShipClassLength(); i++) {
		const sc = report.otherShipClass(i);
		if (sc === null) continue;
		out.push({
			race: sc.race() ?? "",
			name: sc.name() ?? "",
			drive: sc.drive(),
			armament: Number(sc.armament()),
			weapons: sc.weapons(),
			shields: sc.shields(),
			cargo: sc.cargo(),
			mass: sc.mass(),
		});
	}
	out.sort((a, b) => {
		const byRace = a.race.localeCompare(b.race);
		if (byRace !== 0) return byRace;
		return a.name.localeCompare(b.name);
	});
	return out;
}

function decodeBattles(report: Report): ReportBattle[] {
	const out: ReportBattle[] = [];
	for (let i = 0; i < report.battleLength(); i++) {
		const summary = report.battle(i);
		if (summary === null) continue;
		const id = uuidStringFromFB(summary.id());
		if (id === null) continue;
		out.push({
			id,
			planet: Number(summary.planet()),
			shots: Number(summary.shots()),
		});
	}
	return out;
}

function decodeBombings(report: Report): ReportBombing[] {
	const out: ReportBombing[] = [];
	for (let i = 0; i < report.bombingLength(); i++) {
		const b = report.bombing(i);
		if (b === null) continue;
		out.push({
			planetNumber: Number(b.number()),
			planet: b.planet() ?? "",
			owner: b.owner() ?? "",
			attacker: b.attacker() ?? "",
			production: b.production() ?? "",
			industry: b.industry(),
			population: b.population(),
			colonists: b.colonists(),
			industryStockpile: b.capital(),
			materialsStockpile: b.material(),
			attackPower: b.attackPower(),
			wiped: b.wiped(),
		});
	}
	out.sort((a, b) => a.planetNumber - b.planetNumber);
	return out;
}

function decodeShipProductions(report: Report): ReportShipProduction[] {
	const out: ReportShipProduction[] = [];
	for (let i = 0; i < report.shipProductionLength(); i++) {
		const sp = report.shipProduction(i);
		if (sp === null) continue;
		out.push({
			planetNumber: Number(sp.planet()),
			class: sp.class_() ?? "",
			cost: sp.cost(),
			prodUsed: sp.prodUsed(),
			percent: sp.percent(),
			freeIndustry: sp.free(),
		});
	}
	out.sort((a, b) => {
		const byPlanet = a.planetNumber - b.planetNumber;
		if (byPlanet !== 0) return byPlanet;
		return a.class.localeCompare(b.class);
	});
	return out;
}

/**
 * uuidToHiLo splits the canonical 36-character UUID string
 * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
 * uint64 halves used by `common.UUID`. Mirrors `pkg/transcoder/uuid.go`.
 */
export function uuidToHiLo(value: string): [bigint, bigint] {
	const hex = value.replace(/-/g, "").toLowerCase();
	if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) {
		throw new GameStateError(
			"invalid_request",
			"invalid_request",
			`invalid uuid: ${value}`,
		);
	}
	const hi = BigInt(`0x${hex.slice(0, 16)}`);
	const lo = BigInt(`0x${hex.slice(16, 32)}`);
	return [hi, lo];
}

/**
 * applyOrderOverlay returns a copy of `report` with every locally-
 * valid or still-in-flight or applied command from `commands`
 * projected on top. Phase 14 introduced the overlay for
 * `planetRename`; Phase 15 extended it to `setProductionType`;
 * Phase 16 to `setCargoRoute` / `removeCargoRoute`; Phase 17 to
 * `createShipClass` / `removeShipClass` so the ship-class table
 * shows pending Save / Delete actions immediately; Phase 21 to
 * `createScience` / `removeScience` so the sciences table and the
 * planet production picker's Research sub-row mirror pending Save /
 * Delete actions. Other variants pass through. The function is pure:
 * callers re-derive the overlay whenever the draft or the report
 * change.
 *
 * `statuses` maps command id → status. Entries with `valid`,
 * `submitting`, or `applied` participate in the overlay — together
 * they describe "the player's committed intent for this turn":
 * locally-valid (auto-sync about to fire), in-flight on the wire,
 * or acknowledged by the engine. Entries with `draft`, `invalid`,
 * or `rejected` skip the overlay so the player keeps the server's
 * (un-renamed) view.
 */
export function applyOrderOverlay(
	report: GameReport,
	commands: OrderCommand[],
	statuses: Record<string, CommandStatus>,
): GameReport {
	if (commands.length === 0) return report;
	let mutatedPlanets: ReportPlanet[] | null = null;
	let mutatedRoutes: ReportRoute[] | null = null;
	let mutatedShipClass: ShipClassSummary[] | null = null;
	let mutatedScience: ScienceSummary[] | null = null;
	let mutatedRaces: ReportOtherRace[] | null = null;
	let mutatedVoteFor: string | null = null;
	for (const cmd of commands) {
		const status = statuses[cmd.id];
		if (
			status !== "valid" &&
			status !== "submitting" &&
			status !== "applied"
		) {
			continue;
		}
		if (cmd.kind === "planetRename") {
			const idx = report.planets.findIndex(
				(p) => p.number === cmd.planetNumber,
			);
			if (idx < 0) continue;
			if (mutatedPlanets === null) {
				mutatedPlanets = [...report.planets];
			}
			mutatedPlanets[idx] = { ...mutatedPlanets[idx]!, name: cmd.name };
			continue;
		}
		if (cmd.kind === "setProductionType") {
			const idx = report.planets.findIndex(
				(p) => p.number === cmd.planetNumber,
			);
			if (idx < 0) continue;
			if (mutatedPlanets === null) {
				mutatedPlanets = [...report.planets];
			}
			mutatedPlanets[idx] = {
				...mutatedPlanets[idx]!,
				production: productionDisplayFromCommand(
					cmd.productionType,
					cmd.subject,
				),
			};
			continue;
		}
		if (cmd.kind === "setCargoRoute") {
			if (mutatedRoutes === null) {
				mutatedRoutes = cloneRoutes(report.routes);
			}
			upsertRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, {
				loadType: cmd.loadType,
				destinationPlanetNumber: cmd.destinationPlanetNumber,
			});
			continue;
		}
		if (cmd.kind === "removeCargoRoute") {
			if (mutatedRoutes === null) {
				mutatedRoutes = cloneRoutes(report.routes);
			}
			deleteRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, cmd.loadType);
			continue;
		}
		if (cmd.kind === "createShipClass") {
			if (mutatedShipClass === null) {
				mutatedShipClass = [...(report.localShipClass ?? [])];
			}
			// Skip duplicates: the engine refuses them server-side and
			// the designer's local validator prevents them client-side,
			// but a stale draft could still carry a row whose name now
			// collides with the server snapshot. Keeping the projection
			// unique avoids two rows in the table for the same name.
			if (mutatedShipClass.some((cls) => cls.name === cmd.name)) continue;
			mutatedShipClass.push({
				name: cmd.name,
				drive: cmd.drive,
				armament: cmd.armament,
				weapons: cmd.weapons,
				shields: cmd.shields,
				cargo: cmd.cargo,
			});
			continue;
		}
		if (cmd.kind === "removeShipClass") {
			if (mutatedShipClass === null) {
				mutatedShipClass = [...(report.localShipClass ?? [])];
			}
			const idx = mutatedShipClass.findIndex((cls) => cls.name === cmd.name);
			if (idx < 0) continue;
			mutatedShipClass.splice(idx, 1);
			continue;
		}
		if (cmd.kind === "createScience") {
			if (mutatedScience === null) {
				// `?? []` guards a real failure mode: in DEV with hot
				// module replacement the running `gameState.report`
				// object can predate the decoder bump that introduced
				// `localScience` — its field is then `undefined` on
				// the live JS object even though the type declares it
				// as a required array. A naked spread on `undefined`
				// throws inside the reactive overlay getter and aborts
				// the map's `$effect` silently, leaving the canvas
				// blank until a full reload. The default keeps the
				// overlay well-defined for any upstream that supplies
				// a partial report shape.
				mutatedScience = [...(report.localScience ?? [])];
			}
			// Skip duplicates by name: the engine refuses duplicates
			// server-side and the designer's local validator pre-checks
			// against the live overlay, but a stale draft could still
			// carry an entry whose name now collides with the server
			// snapshot. Keeping the projection unique avoids two rows in
			// the table for the same name.
			if (mutatedScience.some((sci) => sci.name === cmd.name)) continue;
			mutatedScience.push({
				name: cmd.name,
				drive: cmd.drive,
				weapons: cmd.weapons,
				shields: cmd.shields,
				cargo: cmd.cargo,
			});
			continue;
		}
		if (cmd.kind === "removeScience") {
			if (mutatedScience === null) {
				mutatedScience = [...(report.localScience ?? [])];
			}
			const idx = mutatedScience.findIndex((sci) => sci.name === cmd.name);
			if (idx < 0) continue;
			mutatedScience.splice(idx, 1);
			continue;
		}
		if (cmd.kind === "setDiplomaticStance") {
			if (mutatedRaces === null) {
				// `?? []` mirrors the per-branch HMR guard pattern: a
				// running `gameState.report` produced before Phase 22's
				// shape bump may not carry `races` yet — preserve a
				// well-defined array on the way out so downstream
				// `$derived` blocks (`races.map`, `races.find`, …)
				// never fault on `undefined`.
				mutatedRaces = [...(report.races ?? [])];
			}
			const idx = mutatedRaces.findIndex((r) => r.name === cmd.acceptor);
			if (idx < 0) continue;
			mutatedRaces[idx] = {
				...mutatedRaces[idx]!,
				relation: cmd.relation,
			};
			continue;
		}
		if (cmd.kind === "setVoteRecipient") {
			mutatedVoteFor = cmd.acceptor;
			continue;
		}
	}
	if (
		mutatedPlanets === null &&
		mutatedRoutes === null &&
		mutatedShipClass === null &&
		mutatedScience === null &&
		mutatedRaces === null &&
		mutatedVoteFor === null
	) {
		return report;
	}
	return {
		...report,
		planets: mutatedPlanets ?? report.planets,
		routes: mutatedRoutes ?? report.routes,
		// `?? []` mirrors the per-branch HMR guard above: an old
		// in-memory `report` whose shape predates a field bump must
		// still produce a well-defined array on the way out, otherwise
		// downstream `$derived` blocks (`localShipClass.map`,
		// `localScience.find`, …) fault and the active view blanks.
		localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
		localScience: mutatedScience ?? report.localScience ?? [],
		races: mutatedRaces ?? report.races ?? [],
		myVoteFor: mutatedVoteFor ?? report.myVoteFor,
		// Phase 23 read-only fields. No overlay branches touch them
		// today; the `?? []` keeps a stale HMR-instance of `report`
		// (loaded before the shape bump) from blanking the Report
		// View when its section components iterate.
		players: report.players ?? [],
		otherScience: report.otherScience ?? [],
		otherShipClass: report.otherShipClass ?? [],
		battles: report.battles ?? [],
		battleIds: report.battleIds ?? [],
		bombings: report.bombings ?? [],
		shipProductions: report.shipProductions ?? [],
	};
}

function cloneRoutes(routes: ReportRoute[]): ReportRoute[] {
	return routes.map((r) => ({
		sourcePlanetNumber: r.sourcePlanetNumber,
		entries: r.entries.map((e) => ({ ...e })),
	}));
}

function upsertRouteEntry(
	routes: ReportRoute[],
	sourcePlanetNumber: number,
	entry: ReportRouteEntry,
): void {
	let route = routes.find((r) => r.sourcePlanetNumber === sourcePlanetNumber);
	if (route === undefined) {
		route = { sourcePlanetNumber, entries: [] };
		routes.push(route);
	}
	const idx = route.entries.findIndex((e) => e.loadType === entry.loadType);
	if (idx >= 0) {
		route.entries[idx] = entry;
	} else {
		route.entries.push(entry);
	}
	route.entries.sort(compareRouteEntriesByLoadType);
}

function deleteRouteEntry(
	routes: ReportRoute[],
	sourcePlanetNumber: number,
	loadType: CargoLoadType,
): void {
	const routeIndex = routes.findIndex(
		(r) => r.sourcePlanetNumber === sourcePlanetNumber,
	);
	if (routeIndex < 0) return;
	const route = routes[routeIndex]!;
	route.entries = route.entries.filter((e) => e.loadType !== loadType);
	if (route.entries.length === 0) {
		routes.splice(routeIndex, 1);
	}
}

/**
 * productionDisplayFromCommand mirrors the engine's
 * `Cache.PlanetProductionDisplayName`
 * (`game/internal/controller/planet.go`) for the optimistic overlay.
 * Keeping the strings byte-equal with the next server report avoids
 * a flicker when the overlay drops on the next turn cutoff.
 */
export function productionDisplayFromCommand(
	productionType: ProductionType,
	subject: string,
): string {
	switch (productionType) {
		case "MAT":
			return "Material";
		case "CAP":
			return "Capital";
		case "DRIVE":
			return "Drive";
		case "WEAPONS":
			return "Weapons";
		case "SHIELDS":
			return "Shields";
		case "CARGO":
			return "Cargo";
		case "SCIENCE":
		case "SHIP":
			return subject;
	}
}

function decodeErrorMessage(payload: Uint8Array): { code: string; message: string } {
	if (payload.length === 0) {
		return { code: "internal_error", message: "empty error payload" };
	}
	try {
		const text = new TextDecoder().decode(payload);
		const parsed = JSON.parse(text) as { code?: string; message?: string };
		return {
			code: typeof parsed.code === "string" ? parsed.code : "internal_error",
			message: typeof parsed.message === "string" ? parsed.message : text,
		};
	} catch {
		return { code: "internal_error", message: "non-json error payload" };
	}
}
