// Per-game runes store that owns the local order draft. Mirrors the
// Phase 11 `GameStateStore` lifecycle: one instance per game, created
// in `routes/games/[id]/+layout.svelte`, exposed to descendants via
// Svelte context, disposed when the layout unmounts.
//
// Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list.
//
// Phase 14 wires the auto-sync pipeline: every successful mutation
// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
// server always mirrors the local draft. The Submit button is gone —
// the player's intent is the source of truth and the engine is kept
// in lock-step. Phase 26 will hide the order tab in history mode
// through a flag passed by the layout (the store itself remains
// alive across that transition so the draft survives history-mode
// round-trips).
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering
// any UI.

import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import { OrderQueue, type OrderQueueStartOptions } from "./order-queue.svelte";
import {
	isRelation,
	isShipGroupCargo,
	isShipGroupUpgradeTech,
	type CommandStatus,
	type OrderCommand,
} from "./order-types";
import { submitOrder } from "./submit";
import { validateEntityName } from "$lib/util/entity-name";
import { validateShipClass } from "$lib/util/ship-class-validation";
import { validateScience } from "$lib/util/science-validation";

const NAMESPACE = "order-drafts";
const draftKey = (gameId: string): string => `${gameId}/draft`;

/**
 * ORDER_DRAFT_CONTEXT_KEY is the Svelte context key the in-game shell
 * layout uses to expose its `OrderDraftStore` instance to descendants.
 * The order tab and any later command-builder UI resolve the store via
 * `getContext(ORDER_DRAFT_CONTEXT_KEY)`.
 */
export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");

type Status = "idle" | "ready" | "error";

/**
 * SyncStatus is the order-tab status-bar projection of the auto-sync
 * pipeline. Phase 14 introduced the `idle`/`syncing`/`synced`/`error`
 * triplet; Phase 25 adds `offline` (queued during a network outage,
 * will retry on reconnect), `conflict` (server told us the turn was
 * already closed; banner pending), and `paused` (game in pause; no
 * submits until it resumes).
 */
export type SyncStatus =
	| "idle"
	| "syncing"
	| "synced"
	| "error"
	| "offline"
	| "conflict"
	| "paused";

/**
 * ConflictBanner is the optimistic-conflict UX state displayed
 * above the order list when a submit landed after the turn cutoff.
 * `turn` is the value the player thought was open at submit time;
 * it is read from the `getCurrentTurn` callback supplied to
 * `bindClient`. The banner is cleared by `resetForNewTurn` (next
 * `game.turn.ready`) or by any local mutation.
 */
export interface ConflictBanner {
	turn: number | null;
	code: string;
	message: string;
}

/**
 * PausedBanner is displayed when the server tells us the game is
 * paused. The banner is cleared by `resetForNewTurn` once the game
 * resumes (a fresh `game.turn.ready` event).
 */
export interface PausedBanner {
	code: string;
	message: string;
	reason: string;
}

export class OrderDraftStore {
	commands: OrderCommand[] = $state([]);
	statuses: Record<string, CommandStatus> = $state({});
	updatedAt = $state(0);
	status: Status = $state("idle");
	error: string | null = $state(null);

	/**
	 * syncStatus reflects the auto-sync pipeline state for the order
	 * tab status bar:
	 *   - `idle`     — no sync attempted yet (e.g. fresh draft after
	 *                  hydration or before the first mutation).
	 *   - `syncing`  — a `submitOrder` call is in flight.
	 *   - `synced`   — the last sync succeeded; statuses match the
	 *                  server's view.
	 *   - `error`    — the last sync failed (network or non-`ok`); the
	 *                  next mutation triggers a retry, or the user can
	 *                  force a re-sync via `forceSync`.
	 *   - `offline`  — the browser is offline; the last submit was
	 *                  held. A fresh send fires on the next `online`
	 *                  flip via the queue callback.
	 *   - `conflict` — the gateway returned `turn_already_closed`;
	 *                  the in-flight commands are marked `conflict`
	 *                  and `conflictBanner` carries the user-facing
	 *                  copy.
	 *   - `paused`   — the gateway returned `game_paused` (or a
	 *                  `game.paused` push frame arrived); no submits
	 *                  fire until `resetForNewTurn` clears it.
	 */
	syncStatus: SyncStatus = $state("idle");
	syncError: string | null = $state(null);

	/**
	 * conflictBanner is non-null whenever `syncStatus === "conflict"`.
	 * The order tab renders the banner above the command list with
	 * the turn number interpolated; clearing it is the
	 * `resetForNewTurn` / mutation responsibility.
	 */
	conflictBanner: ConflictBanner | null = $state(null);

	/**
	 * pausedBanner is non-null whenever `syncStatus === "paused"`.
	 * The order tab renders a pause-specific banner separate from
	 * the conflict path.
	 */
	pausedBanner: PausedBanner | null = $state(null);

	private cache: Cache | null = null;
	private gameId = "";
	private destroyed = false;
	private client: GalaxyClient | null = null;
	private syncing: Promise<void> | null = null;
	private pending = false;
	private queue = new OrderQueue();
	private queueStarted = false;
	private getCurrentTurn: (() => number) | null = null;
	private getHistoryMode: (() => boolean) | null = null;

	/**
	 * init loads the persisted draft for `opts.gameId` from `opts.cache`
	 * into `commands` and flips `status` to `ready`. The call is
	 * idempotent on the same store instance — the layout always
	 * constructs a fresh store per game, so there is no need to support
	 * mid-life game switching here.
	 *
	 * The cache load is the fast path so the order tab paints
	 * immediately on reopening the game; `hydrateFromServer` (called
	 * by the layout once the current turn is known) is the
	 * authoritative read that always overwrites the local cache when
	 * the server has a stored order.
	 */
	async init(
		opts: { cache: Cache; gameId: string; queue?: OrderQueueStartOptions },
	): Promise<void> {
		this.cache = opts.cache;
		this.gameId = opts.gameId;
		try {
			const stored = await opts.cache.get<OrderCommand[]>(
				NAMESPACE,
				draftKey(opts.gameId),
			);
			if (this.destroyed) return;
			this.commands = Array.isArray(stored) ? [...stored] : [];
			this.recomputeStatuses();
			this.status = "ready";
		} catch (err) {
			if (this.destroyed) return;
			this.status = "error";
			this.error = err instanceof Error ? err.message : "load failed";
		}
		this.startQueue(opts.queue);
	}

	/**
	 * bindClient stores the per-game `GalaxyClient` so subsequent
	 * mutations can drive the auto-sync pipeline. The layout calls
	 * this after the boot `Promise.all` resolves and before
	 * `hydrateFromServer`, so any mutation that lands afterwards goes
	 * through the network.
	 *
	 * Phase 25: `opts.getCurrentTurn` lets the conflict banner
	 * interpolate the turn number the player was composing for. The
	 * layout passes `() => gameState.currentTurn`; tests may omit it,
	 * in which case the banner falls back to a turn-less template.
	 *
	 * Phase 26: `opts.getHistoryMode` lets `add` / `remove` / `move`
	 * short-circuit while the user is viewing a past turn. Without
	 * the gate, inspector affordances built in Phases 14–22 would
	 * happily push commands into the draft even though the order tab
	 * is hidden and the read-only banner is visible. Tests may omit
	 * it; the default is "never in history mode".
	 */
	bindClient(
		client: GalaxyClient,
		opts: {
			getCurrentTurn?: () => number;
			getHistoryMode?: () => boolean;
		} = {},
	): void {
		this.client = client;
		this.getCurrentTurn = opts.getCurrentTurn ?? null;
		this.getHistoryMode = opts.getHistoryMode ?? null;
	}

	/**
	 * hydrateFromServer issues `user.games.order.get` for the current
	 * turn and overwrites the local cache with the server's stored
	 * order. The server is the source of truth: a player who logged
	 * in from a fresh device must see their existing orders, and a
	 * cache that's out-of-sync (e.g. a stale browser tab) is
	 * superseded by the gateway's view. A `found = false` answer
	 * empties the local draft. Network failures keep the local cache
	 * intact and surface as `syncStatus = "error"`.
	 */
	async hydrateFromServer(opts: {
		client: GalaxyClient;
		turn: number;
	}): Promise<void> {
		if (this.status !== "ready") return;
		// Phase 25: a `game.paused` push frame may arrive before the
		// initial hydrate completes (the layout subscribes early to
		// avoid losing in-flight frames). The pause is stickier than a
		// freshly-loaded snapshot — keep the banner up and skip the
		// fetch entirely. A subsequent `resetForNewTurn` (triggered by
		// `game.turn.ready` after the game resumes) re-runs the
		// hydration from scratch.
		if (this.syncStatus === "paused") return;
		this.client = opts.client;
		// Guard against placeholder game ids the Phase 10 e2e specs
		// still use — auto-sync needs a real UUID for the FBS request
		// envelope, and a parser exception here would only be visible
		// as a noisy `console.warn` deep in the layout boot path.
		if (!isUuid(this.gameId)) {
			this.syncStatus = "idle";
			return;
		}
		this.syncStatus = "syncing";
		this.syncError = null;
		try {
			const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
			if (this.destroyed) return;
			// If `markPaused` landed between the initial syncStatus
			// flip and the awaited fetch, the pause is the
			// authoritative state — do not overwrite it with synced.
			// The fetched commands are still adopted so a later
			// `resetForNewTurn` can build on top of them.
			this.commands = fetched.commands;
			this.updatedAt = fetched.updatedAt;
			this.recomputeStatuses();
			// Server-fetched commands echo cmdApplied=true for entries
			// that survived previous turns; keep them as `applied` so
			// the overlay continues to project them on the inspector.
			const next = { ...this.statuses };
			for (const cmd of this.commands) {
				if (next[cmd.id] === "valid") {
					next[cmd.id] = "applied";
				}
			}
			this.statuses = next;
			await this.persist();
			if ((this.syncStatus as SyncStatus) !== "paused") {
				this.syncStatus = "synced";
			}
		} catch (err) {
			if (this.destroyed) return;
			if ((this.syncStatus as SyncStatus) !== "paused") {
				this.syncStatus = "error";
				this.syncError = err instanceof Error ? err.message : "fetch failed";
			}
			console.warn("order-draft: server hydration failed", err);
		}
	}

	/**
	 * add appends a command to the end of the draft, runs local
	 * validation for the new entry, persists the updated list, and
	 * triggers an auto-sync to keep the server in lock-step.
	 * Mutations made before `init` resolves are ignored — the layout
	 * always awaits `init` before exposing the store.
	 *
	 * Collapse rules:
	 *
	 *   - `setProductionType` collapses by `planetNumber`: a new
	 *     entry supersedes any prior `setProductionType` for the
	 *     same planet, so the draft holds at most one production
	 *     choice per planet.
	 *   - `setCargoRoute` and `removeCargoRoute` share a collapse
	 *     key on `(sourcePlanetNumber, loadType)` — the engine
	 *     stores a single (planet, type) → destination mapping, so
	 *     a newer entry for the same slot supersedes any prior
	 *     `set` or `remove` for that slot. Different load-types or
	 *     different sources coexist.
	 *   - `setDiplomaticStance` collapses by `acceptor`: the engine
	 *     tracks a single war/peace stance per opponent, so a newer
	 *     entry supersedes any prior `setDiplomaticStance` for the
	 *     same other race.
	 *   - `setVoteRecipient` collapses singleton: per `rules.txt`
	 *     each race controls a single vote slot, so a newer entry
	 *     supersedes any prior `setVoteRecipient` regardless of the
	 *     acceptor.
	 *   - `planetRename` and `placeholder` append unconditionally;
	 *     each rename is a distinct user-visible action.
	 */
	async add(command: OrderCommand): Promise<void> {
		if (this.status !== "ready") return;
		// Phase 26: history mode hides the order tab and treats every
		// view as read-only. The inspector affordances are not aware of
		// the mode, so the gate lives here — one chokepoint protects
		// every Phase 14–22 caller without per-component edits.
		if (this.getHistoryMode?.() === true) return;
		this.clearConflictForMutation();
		const removed: string[] = [];
		let nextCommands: OrderCommand[];
		if (command.kind === "setProductionType") {
			nextCommands = [];
			for (const existing of this.commands) {
				if (
					existing.kind === "setProductionType" &&
					existing.planetNumber === command.planetNumber
				) {
					removed.push(existing.id);
					continue;
				}
				nextCommands.push(existing);
			}
			nextCommands.push(command);
		} else if (
			command.kind === "setCargoRoute" ||
			command.kind === "removeCargoRoute"
		) {
			nextCommands = [];
			for (const existing of this.commands) {
				if (
					(existing.kind === "setCargoRoute" ||
						existing.kind === "removeCargoRoute") &&
					existing.sourcePlanetNumber === command.sourcePlanetNumber &&
					existing.loadType === command.loadType
				) {
					removed.push(existing.id);
					continue;
				}
				nextCommands.push(existing);
			}
			nextCommands.push(command);
		} else if (command.kind === "setDiplomaticStance") {
			nextCommands = [];
			for (const existing of this.commands) {
				if (
					existing.kind === "setDiplomaticStance" &&
					existing.acceptor === command.acceptor
				) {
					removed.push(existing.id);
					continue;
				}
				nextCommands.push(existing);
			}
			nextCommands.push(command);
		} else if (command.kind === "setVoteRecipient") {
			nextCommands = [];
			for (const existing of this.commands) {
				if (existing.kind === "setVoteRecipient") {
					removed.push(existing.id);
					continue;
				}
				nextCommands.push(existing);
			}
			nextCommands.push(command);
		} else {
			nextCommands = [...this.commands, command];
		}
		this.commands = nextCommands;
		const nextStatuses = { ...this.statuses };
		for (const id of removed) {
			delete nextStatuses[id];
		}
		nextStatuses[command.id] = validateCommand(command);
		this.statuses = nextStatuses;
		await this.persist();
		this.scheduleSync();
	}

	/**
	 * remove drops the command with the given id from the draft,
	 * persists the result, and triggers an auto-sync. A miss is a
	 * no-op. Even removing the last command sends an explicit empty
	 * order to the server so its stored state matches the local one
	 * (the engine accepts an empty `cmd[]` per the order handler).
	 */
	async remove(id: string): Promise<void> {
		if (this.status !== "ready") return;
		if (this.getHistoryMode?.() === true) return;
		const next = this.commands.filter((cmd) => cmd.id !== id);
		if (next.length === this.commands.length) return;
		this.clearConflictForMutation();
		this.commands = next;
		const nextStatuses = { ...this.statuses };
		delete nextStatuses[id];
		this.statuses = nextStatuses;
		await this.persist();
		this.scheduleSync();
	}

	/**
	 * move relocates the command at `fromIndex` to `toIndex`, shifting
	 * the intermediate commands. Out-of-range indices and identical
	 * positions are no-ops; both indices are clamped against the
	 * current `commands` length. Triggers an auto-sync — the server
	 * stores commands in submission order and the engine relies on
	 * that order at turn cutoff.
	 */
	async move(fromIndex: number, toIndex: number): Promise<void> {
		if (this.status !== "ready") return;
		if (this.getHistoryMode?.() === true) return;
		const length = this.commands.length;
		if (fromIndex < 0 || fromIndex >= length) return;
		if (toIndex < 0 || toIndex >= length) return;
		if (fromIndex === toIndex) return;
		this.clearConflictForMutation();
		const next = [...this.commands];
		const [picked] = next.splice(fromIndex, 1);
		if (picked === undefined) return;
		next.splice(toIndex, 0, picked);
		this.commands = next;
		await this.persist();
		this.scheduleSync();
	}

	/**
	 * forceSync re-runs the auto-sync without requiring a mutation.
	 * Used by the order tab's retry-on-error affordance.
	 */
	forceSync(): void {
		this.scheduleSync();
	}

	/**
	 * markPaused projects an incoming `game.paused` push event into
	 * the store: the order tab shows the pause banner, the auto-sync
	 * loop short-circuits, and any submitting rows revert to `valid`
	 * (the matching engine state is still the old one). The layout
	 * calls this from the `game.paused` subscription. `reason`
	 * carries the raw runtime status published by lobby
	 * (`engine_unreachable` / `generation_failed`); the UI ignores
	 * it today but the payload is preserved for future copy
	 * differentiation.
	 */
	markPaused(opts: { reason: string; message?: string }): void {
		if (this.status !== "ready") return;
		this.revertSubmittingToValidInternal();
		this.pausedBanner = {
			code: "game_paused",
			message: opts.message ?? "Game paused. Orders are not accepted until it resumes.",
			reason: opts.reason,
		};
		this.syncStatus = "paused";
		this.syncError = null;
	}

	/**
	 * resetForNewTurn drops the local draft, clears every Phase 25
	 * banner, and hydrates from the server for the supplied turn.
	 * The layout calls this from the `game.turn.ready` subscription
	 * when the prior `syncStatus` was `conflict` or `paused`. The
	 * effect mirrors a fresh boot: cache wipe → fetch → seed.
	 */
	async resetForNewTurn(opts: {
		client: GalaxyClient;
		turn: number;
	}): Promise<void> {
		if (this.status !== "ready") return;
		this.commands = [];
		this.statuses = {};
		this.updatedAt = 0;
		this.conflictBanner = null;
		this.pausedBanner = null;
		this.syncStatus = "idle";
		this.syncError = null;
		await this.persist();
		await this.hydrateFromServer({ client: opts.client, turn: opts.turn });
	}

	dispose(): void {
		this.destroyed = true;
		this.cache = null;
		this.client = null;
		this.getCurrentTurn = null;
		this.getHistoryMode = null;
		if (this.queueStarted) {
			this.queue.stop();
			this.queueStarted = false;
		}
	}

	private scheduleSync(): void {
		if (this.client === null) return;
		// Same UUID guard as `hydrateFromServer` — placeholder game
		// ids in test fixtures must not blow up the auto-sync path.
		if (!isUuid(this.gameId)) return;
		// Conflict / paused states are sticky: the order tab is
		// waiting for the next `game.turn.ready` (conflict) or for
		// the admin to resume (paused). Local mutations clear the
		// conflict; the layout's `markPaused`/`resetForNewTurn` clear
		// the pause. Trying to send mid-state would re-elicit the
		// same gateway reply on every keystroke and overwrite the
		// banner with the same message.
		if (this.syncStatus === "conflict" || this.syncStatus === "paused") {
			return;
		}
		if (this.syncing !== null) {
			this.pending = true;
			return;
		}
		this.syncing = this.runSync().finally(() => {
			this.syncing = null;
		});
	}

	private async runSync(): Promise<void> {
		while (true) {
			this.pending = false;
			const client = this.client;
			if (client === null || this.destroyed) return;

			// Capture the snapshot up-front: the in-flight request
			// reflects the draft as it was when the mutation landed,
			// even if the user adds another command before the
			// gateway responds.
			const snapshot: OrderCommand[] = $state.snapshot(
				this.commands,
			) as OrderCommand[];
			// Auto-sync sends every command the player still has in
			// the draft except the locally-invalid ones (we can't
			// expect the server to accept a name that fails our own
			// validator) and the Phase 12 placeholder. `applied` and
			// `rejected` entries are re-sent so the server's stored
			// view always mirrors the local one — re-applying an
			// already-applied command is idempotent at the engine
			// level (the rename ends at the same name).
			const submittable = snapshot.filter((cmd) => {
				const status = this.statuses[cmd.id];
				return status !== "invalid" && status !== "draft";
			});
			const submittingIds = submittable.map((cmd) => cmd.id);

			this.markSubmittingInternal(submittingIds);
			this.syncStatus = "syncing";
			this.syncError = null;

			const outcome = await this.queue.send(() =>
				submitOrder(client, this.gameId, submittable, {
					updatedAt: this.updatedAt,
				}),
			);
			if (this.destroyed) return;
			switch (outcome.kind) {
				case "success": {
					this.applyResultsInternal(
						outcome.result.results,
						outcome.result.updatedAt,
					);
					// Even with `result.ok === true` an individual
					// command may have been rejected by the engine
					// (e.g. validation passed transcoders but failed
					// the in-game rule). Surface that as an error in
					// the sync bar so the player notices and can fix
					// or remove the offending command.
					const anyRejected = Array.from(
						outcome.result.results.values(),
					).some((s) => s === "rejected");
					this.syncStatus = anyRejected ? "error" : "synced";
					this.syncError = anyRejected
						? "engine rejected one or more commands"
						: null;
					break;
				}
				case "rejected": {
					this.markRejectedInternal(submittingIds);
					this.syncStatus = "error";
					this.syncError = outcome.failure.message;
					break;
				}
				case "conflict": {
					this.markConflictInternal(submittingIds);
					this.conflictBanner = {
						turn: this.getCurrentTurn?.() ?? null,
						code: outcome.code,
						message: outcome.message,
					};
					this.syncStatus = "conflict";
					this.syncError = null;
					// Stickiness: conflict overrides any pending
					// mutations until the next `game.turn.ready` or a
					// local edit clears the banner.
					return;
				}
				case "paused": {
					this.revertSubmittingToValidInternal();
					this.pausedBanner = {
						code: outcome.code,
						message: outcome.message,
						reason: outcome.code,
					};
					this.syncStatus = "paused";
					this.syncError = null;
					return;
				}
				case "offline": {
					this.revertSubmittingToValidInternal();
					this.syncStatus = "offline";
					this.syncError = null;
					return;
				}
				case "failed": {
					this.revertSubmittingToValidInternal();
					this.syncStatus = "error";
					this.syncError = outcome.reason;
					break;
				}
			}

			if (!this.pending) return;
		}
	}

	private startQueue(opts?: OrderQueueStartOptions): void {
		if (this.queueStarted) return;
		this.queue.start({
			onOnline: () => {
				if (this.destroyed) return;
				if (this.syncStatus === "offline") {
					this.scheduleSync();
				}
			},
			onlineProbe: opts?.onlineProbe,
			addEventListener: opts?.addEventListener,
			removeEventListener: opts?.removeEventListener,
		});
		this.queueStarted = true;
	}

	private markSubmittingInternal(ids: string[]): void {
		const next = { ...this.statuses };
		for (const id of ids) {
			// `applied` rows stay applied while the wire request is in
			// flight — re-sending an already-applied command is a
			// no-op idempotent operation, and flipping the badge back
			// to `submitting` would flicker the inspector overlay.
			if (next[id] === "valid" || next[id] === "rejected") {
				next[id] = "submitting";
			}
		}
		this.statuses = next;
	}

	private applyResultsInternal(
		results: Map<string, CommandStatus>,
		updatedAt: number,
	): void {
		const liveIds = new Set(this.commands.map((cmd) => cmd.id));
		const next = { ...this.statuses };
		for (const [id, status] of results.entries()) {
			// Drop verdicts for commands the user removed while the
			// request was in flight — they are no longer in the
			// draft, so re-introducing a stale `applied` row would
			// confuse the order tab and the overlay.
			if (!liveIds.has(id)) continue;
			next[id] = status;
		}
		this.statuses = next;
		this.updatedAt = updatedAt;
	}

	private markRejectedInternal(ids: string[]): void {
		const next = { ...this.statuses };
		for (const id of ids) {
			next[id] = "rejected";
		}
		this.statuses = next;
	}

	private markConflictInternal(ids: string[]): void {
		const next = { ...this.statuses };
		for (const id of ids) {
			next[id] = "conflict";
		}
		this.statuses = next;
	}

	/**
	 * clearConflictForMutation drops the conflict banner and
	 * re-validates every `conflict`-marked command back to its
	 * pre-submit status. Called from every mutation (`add`,
	 * `remove`, `move`) so the user-driven "Edit and resubmit" flow
	 * works without an extra dismiss step.
	 */
	private clearConflictForMutation(): void {
		if (this.syncStatus !== "conflict" && this.conflictBanner === null) {
			return;
		}
		const next = { ...this.statuses };
		let mutated = false;
		for (const cmd of this.commands) {
			if (next[cmd.id] === "conflict") {
				next[cmd.id] = validateCommand(cmd);
				mutated = true;
			}
		}
		if (mutated) {
			this.statuses = next;
		}
		this.conflictBanner = null;
		if (this.syncStatus === "conflict") {
			this.syncStatus = "idle";
			this.syncError = null;
		}
	}

	private revertSubmittingToValidInternal(): void {
		const next = { ...this.statuses };
		for (const cmd of this.commands) {
			if (next[cmd.id] === "submitting") {
				next[cmd.id] = validateCommand(cmd);
			}
		}
		this.statuses = next;
	}

	private recomputeStatuses(): void {
		const next: Record<string, CommandStatus> = {};
		for (const cmd of this.commands) {
			next[cmd.id] = validateCommand(cmd);
		}
		this.statuses = next;
	}

	private async persist(): Promise<void> {
		if (this.cache === null || this.destroyed) return;
		// `commands` is `$state`, so individual entries are proxies.
		// IndexedDB's structured clone refuses to clone proxies, so the
		// snapshot must be taken before the put.
		const snapshot = $state.snapshot(this.commands) as OrderCommand[];
		await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot);
	}
}

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function isUuid(value: string): boolean {
	return UUID_RE.test(value);
}

function validateCommand(cmd: OrderCommand): CommandStatus {
	switch (cmd.kind) {
		case "planetRename":
			return validateEntityName(cmd.name).ok ? "valid" : "invalid";
		case "setProductionType":
			// Mirrors the engine's `subject=Production` validator
			// (`game/internal/router/validator.go`): SCIENCE and SHIP
			// require a non-empty entity-name-valid subject; the other
			// six production types accept any subject (typically empty)
			// because the engine only consults the subject for those
			// two cases.
			if (
				cmd.productionType === "SCIENCE" ||
				cmd.productionType === "SHIP"
			) {
				return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
			}
			return "valid";
		case "setCargoRoute":
			// The picker pre-checks reach (and so refuses to emit a
			// route to an unreachable destination) and the engine
			// re-validates ownership / reach server-side. Locally we
			// only refuse a self-route — the FBS validator
			// (`pkg/model/order/order.go`) accepts every other
			// (origin, destination, load_type) triple.
			if (cmd.sourcePlanetNumber === cmd.destinationPlanetNumber) {
				return "invalid";
			}
			return "valid";
		case "removeCargoRoute":
			// `removeCargoRoute` carries no destination; the only
			// engine-side check is ownership of the source planet,
			// which the inspector enforces by only mounting the
			// component on `kind === "local"`.
			return "valid";
		case "createShipClass":
			// Mirrors `pkg/calc/validator.go.ValidateShipTypeValues`
			// plus the entity-name rules. The duplicate-name check is
			// the designer's responsibility (it sees the live overlay
			// list); here the validator runs without `existingNames`
			// so a draft that was valid at creation time does not flip
			// to invalid just because another `createShipClass` for
			// the same name landed in the draft afterwards — both
			// rows ride out the wire and the engine arbitrates.
			return validateShipClass({
				name: cmd.name,
				drive: cmd.drive,
				armament: cmd.armament,
				weapons: cmd.weapons,
				shields: cmd.shields,
				cargo: cmd.cargo,
			}).ok
				? "valid"
				: "invalid";
		case "removeShipClass":
			// `removeShipClass` carries only the name; the engine
			// checks that the class exists and is not referenced by
			// active production / ship groups. Local validation only
			// guards the name shape.
			return validateEntityName(cmd.name).ok ? "valid" : "invalid";
		case "createScience":
			// Mirrors `pkg/calc/validator.go.ValidateScienceValues`
			// plus the entity-name rules. The wire shape is fractions
			// (sum to 1.0); the validator runs without `existingNames`
			// here for the same reason ship-class create does — a
			// duplicate-name check is the designer's UX responsibility,
			// not the draft store's.
			return validateScience({
				name: cmd.name,
				drive: cmd.drive * 100,
				weapons: cmd.weapons * 100,
				shields: cmd.shields * 100,
				cargo: cmd.cargo * 100,
			}).ok
				? "valid"
				: "invalid";
		case "removeScience":
			// `removeScience` carries only the name; the engine checks
			// that the science exists and is not referenced by active
			// production. Local validation only guards the name shape.
			return validateEntityName(cmd.name).ok ? "valid" : "invalid";
		case "breakShipGroup":
			// Engine rule (`controller/ship_group.go.breakGroup`):
			// quantity must be at least 1 and strictly less than the
			// source group size. We do not know the source size here
			// (it lives on the report), so the inspector enforces the
			// upper bound before emitting; locally we only refuse the
			// degenerate cases — non-positive `quantity`, missing or
			// equal UUIDs.
			if (cmd.quantity <= 0) return "invalid";
			if (!isUuid(cmd.groupId) || !isUuid(cmd.newGroupId)) return "invalid";
			if (cmd.groupId === cmd.newGroupId) return "invalid";
			return "valid";
		case "sendShipGroup":
			// Reach is enforced by the picker before the command lands
			// in the draft. Locally we only refuse a degenerate
			// destination (the engine uses planet number `0` as the
			// "no planet" sentinel; FBS encodes as `int64`, so any
			// strictly-positive number is wire-valid).
			if (cmd.destinationPlanetNumber <= 0) return "invalid";
			if (!isUuid(cmd.groupId)) return "invalid";
			return "valid";
		case "loadShipGroup":
			// Cargo type and quantity are pre-checked by the inspector
			// against the planet stock and the group's free capacity;
			// local validation only guards the wire-valid shape.
			if (!isShipGroupCargo(cmd.cargo)) return "invalid";
			if (cmd.quantity <= 0) return "invalid";
			if (!isUuid(cmd.groupId)) return "invalid";
			return "valid";
		case "unloadShipGroup":
			if (cmd.quantity <= 0) return "invalid";
			if (!isUuid(cmd.groupId)) return "invalid";
			return "valid";
		case "upgradeShipGroup":
			// Engine rule
			// (`controller/ship_group_upgrade.go.shipGroupUpgrade:56`):
			// `tech === "ALL"` requires `level === 0`; per-block tech
			// requires a strictly positive level. The inspector also
			// caps the level to the player's race tech, but the
			// engine re-validates server-side.
			if (!isShipGroupUpgradeTech(cmd.tech)) return "invalid";
			if (cmd.tech === "ALL") {
				if (cmd.level !== 0) return "invalid";
			} else if (cmd.level <= 0) {
				return "invalid";
			}
			if (!isUuid(cmd.groupId)) return "invalid";
			return "valid";
		case "dismantleShipGroup":
			return isUuid(cmd.groupId) ? "valid" : "invalid";
		case "transferShipGroup":
			// `acceptor` is a race name; race names follow the same
			// entity-name rules as planet/fleet names. The inspector
			// restricts the picker to `GameReport.otherRaces`, so a
			// locally-valid name is always a real race.
			if (!validateEntityName(cmd.acceptor).ok) return "invalid";
			if (!isUuid(cmd.groupId)) return "invalid";
			return "valid";
		case "joinFleetShipGroup":
			if (!validateEntityName(cmd.name).ok) return "invalid";
			if (!isUuid(cmd.groupId)) return "invalid";
			return "valid";
		case "setDiplomaticStance":
			// `acceptor` is the opponent's race name; race names follow
			// the same entity-name rules as planet/fleet names. The
			// races-table view restricts the per-row picker to live
			// `GameReport.races[]` entries, so a locally-valid name is
			// always a real race. `relation` must be one of the two
			// wire-stable values (`WAR` or `PEACE`); the FBS
			// `UNKNOWN = 0` sentinel is never emitted.
			if (!validateEntityName(cmd.acceptor).ok) return "invalid";
			if (!isRelation(cmd.relation)) return "invalid";
			return "valid";
		case "setVoteRecipient":
			// `acceptor` is the race the local player votes for. The
			// engine accepts a self-vote as the neutral default
			// (`controller/race.go`), so the table picker may include
			// the local race as a valid choice. Local validation only
			// guards the name shape.
			if (!validateEntityName(cmd.acceptor).ok) return "invalid";
			return "valid";
		case "placeholder":
			// Phase 12 placeholder entries are content-free and never
			// transition out of `draft` — they are not submittable.
			return "draft";
	}
}
