<!--
In-game shell. Composes the header, a conditionally-visible sidebar
(Calculator / Inspector / Order tabs), the active-view area selected
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
app-shell there are no per-view routes: the active game id comes from
`appScreen.gameId` and the visible view from `activeView`, both held
in `$lib/app-nav.svelte`. The shell owns:

- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
  sidebar pinned via CSS; mobile hides it entirely.
- `mobileTool` — mobile-only tool overlay state. The tool only
  visually overrides the active-view area when the active view is the
  map, so switching to any other view through the More drawer or the
  header view-menu naturally drops the overlay even if `mobileTool`
  was set on a previous tap.
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
  `order`). Held here, bound into the sidebar so a planet click on
  the map can flip it to `inspector` from the outside.
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
  `SelectionStore`. All three are exposed to descendants via Svelte
  context; their lifetimes match the shell instance.

The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
the matching lobby record to discover `current_turn`, then loads the
report. The store is shared with descendants via
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
the map view, and the inspector tab all read from the same snapshot.

The planet inspector: the shell watches the selection store and, on
the null → planet transition, flips `activeTab` to `inspector` and
`sidebarOpen` to `true` so the inspector becomes visible regardless
of breakpoint (desktop already has the sidebar pinned; tablet needs
the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
the same selection and displays a read-only sheet over the map;
closing the sheet clears the selection.

The per-game bootstrap (client construction, store init, push-event
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
the cleanup tears the previous game's subscriptions down and the body
re-initialises the shared stores for the new id, so a direct
game → game switch (without leaving the shell) rebinds cleanly. The
shell unmounts when the dispatcher leaves the `game` screen, so a
return to the lobby still disposes the stores via `onDestroy`.
-->
<script lang="ts">
	import { onDestroy, setContext, untrack } from "svelte";
	import { i18n } from "$lib/i18n/index.svelte";
	import { appScreen, activeView } from "$lib/app-nav.svelte";
	import Header from "$lib/header/header.svelte";
	import HistoryBanner from "$lib/header/history-banner.svelte";
	import Sidebar from "$lib/sidebar/sidebar.svelte";
	import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
	import Calculator from "$lib/sidebar/calculator-tab.svelte";
	import Order from "$lib/sidebar/order-tab.svelte";
	import MapView from "$lib/active-view/map.svelte";
	import TableView from "$lib/active-view/table.svelte";
	import ReportView from "$lib/active-view/report.svelte";
	import BattleView from "$lib/active-view/battle.svelte";
	import MailView from "$lib/active-view/mail.svelte";
	import DesignerScience from "$lib/active-view/designer-science.svelte";
	import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
	import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
	import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
	import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
	import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
	import {
		SelectionStore,
		SELECTION_CONTEXT_KEY,
	} from "$lib/selection.svelte";
	import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
	import {
		createRenderedReportSource,
		RENDERED_REPORT_CONTEXT_KEY,
	} from "$lib/rendered-report.svelte";
	import {
		ORDER_DRAFT_CONTEXT_KEY,
		OrderDraftStore,
	} from "../../sync/order-draft.svelte";
	import {
		MAP_PICK_CONTEXT_KEY,
		MapPickService,
	} from "$lib/map-pick.svelte";
	import {
		GALAXY_CLIENT_CONTEXT_KEY,
		GalaxyClientHolder,
	} from "$lib/galaxy-client-context.svelte";
	import {
		CORE_CONTEXT_KEY,
		CoreHolder,
	} from "$lib/core-context.svelte";
	import { session } from "$lib/session-store.svelte";
	import { loadStore } from "../../platform/store/index";
	import { loadCore } from "../../platform/core/index";
	import { createGatewayClient } from "../../api/connect";
	import { GalaxyClient } from "../../api/galaxy-client";
	import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
	import {
		getSyntheticReport,
		isSyntheticGameId,
	} from "../../api/synthetic-report";
	import {
		eventStream,
		type VerifiedEvent,
	} from "../../api/events.svelte";
	import { toast } from "$lib/toast.svelte";
	import { mailStore } from "$lib/mail-store.svelte";

	let sidebarOpen = $state(false);
	let mobileTool: MobileTool = $state("map");
	let activeTab: SidebarTab = $state("inspector");

	// The tool overlay (Calculator / Order) only replaces the active
	// view while the map is showing; switching to any other view drops
	// it, matching the previous URL-driven behaviour.
	const isOnMap = $derived(activeView.view === "map");
	const effectiveTool: MobileTool = $derived.by(() =>
		isOnMap ? mobileTool : "map",
	);

	const gameState = new GameStateStore();
	setContext(GAME_STATE_CONTEXT_KEY, gameState);
	const orderDraft = new OrderDraftStore();
	setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
	// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
	// when the player is viewing a past turn. The flag is owned by
	// `GameStateStore` (single source of truth for "what turn are we
	// looking at") and surfaced here so the Phase 12 sidebar wiring,
	// the new `HistoryBanner`, and `orderDraft.bindClient` all read
	// from the same derivation.
	const historyMode = $derived(gameState.historyMode);
	const selection = new SelectionStore();
	setContext(SELECTION_CONTEXT_KEY, selection);
	const renderedReport = createRenderedReportSource(gameState, orderDraft);
	setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
	const galaxyClient = new GalaxyClientHolder();
	setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
	const coreHolder = new CoreHolder();
	setContext(CORE_CONTEXT_KEY, coreHolder);
	// `MapPickService` lives at the layout so both the active map
	// view (which binds the renderer-side resolver) and the
	// inspector subsections (which call `pick(...)`) see the same
	// instance via context — they sit on sibling branches of the
	// component tree.
	const mapPick = new MapPickService();
	setContext(MAP_PICK_CONTEXT_KEY, mapPick);

	// selectedPlanet resolves the current selection against the live
	// report so both the desktop sidebar and the mobile sheet display
	// the same snapshot. A selection that points at a planet missing
	// from the current report (e.g. visibility lost between turns)
	// reads as `null` here, which collapses the inspector and the
	// sheet without surfacing a stale row. The rendered report layers
	// the local order draft on top so the player sees their pending
	// renames immediately.
	const selectedPlanet = $derived.by(() => {
		const sel = selection.selected;
		if (sel === null || sel.kind !== "planet") return null;
		const report = renderedReport.report;
		if (report === null) return null;
		return report.planets.find((p) => p.number === sel.id) ?? null;
	});
	const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
		const sel = selection.selected;
		if (sel === null || sel.kind !== "shipGroup") return null;
		const report = renderedReport.report;
		if (report === null) return null;
		const ref = sel.ref;
		switch (ref.variant) {
			case "local": {
				const group = report.localShipGroups.find((g) => g.id === ref.id);
				if (group === undefined) return null;
				return { variant: "local", group };
			}
			case "other": {
				const group = report.otherShipGroups[ref.index];
				if (group === undefined) return null;
				return { variant: "other", group };
			}
			case "incoming": {
				const group = report.incomingShipGroups[ref.index];
				if (group === undefined) return null;
				return { variant: "incoming", group };
			}
			case "unidentified": {
				const group = report.unidentifiedShipGroups[ref.index];
				if (group === undefined) return null;
				return { variant: "unidentified", group };
			}
		}
	});
	const localShipClass = $derived(
		renderedReport.report?.localShipClass ?? [],
	);
	const localScience = $derived(renderedReport.report?.localScience ?? []);
	const inspectorPlanets = $derived(renderedReport.report?.planets ?? []);
	const inspectorRoutes = $derived(renderedReport.report?.routes ?? []);
	const inspectorMapWidth = $derived(renderedReport.report?.mapWidth ?? 1);
	const inspectorMapHeight = $derived(renderedReport.report?.mapHeight ?? 1);
	const inspectorLocalDrive = $derived(
		renderedReport.report?.localPlayerDrive ?? 0,
	);
	const inspectorLocalWeapons = $derived(
		renderedReport.report?.localPlayerWeapons ?? 0,
	);
	const inspectorLocalShields = $derived(
		renderedReport.report?.localPlayerShields ?? 0,
	);
	const inspectorLocalCargo = $derived(
		renderedReport.report?.localPlayerCargo ?? 0,
	);
	const inspectorLocalShipGroups = $derived(
		renderedReport.report?.localShipGroups ?? [],
	);
	const inspectorOtherShipGroups = $derived(
		renderedReport.report?.otherShipGroups ?? [],
	);
	const inspectorLocalFleets = $derived(
		renderedReport.report?.localFleets ?? [],
	);
	const inspectorOtherRaces = $derived(
		renderedReport.report?.otherRaces ?? [],
	);
	const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");

	// Reveal the inspector whenever a new planet selection lands.
	// Reading `selection.selected` once outside the effect keeps the
	// effect dependent on the rune transition and not on the derived
	// `selectedPlanet`, which can flicker as the report refreshes.
	$effect(() => {
		const sel = selection.selected;
		if (sel === null) return;
		// Stay in the calculator when a planet is picked: the calculator
		// consumes the selection in its planet area + reach circles, and
		// it is a long-lived workspace the user should not be ejected
		// from. `activeTab` is read untracked so a manual tab switch does
		// not re-fire this effect. Any other case (including a ship-group
		// selection, which the calculator does not use) reveals the
		// inspector as before.
		const tab = untrack(() => activeTab);
		if (!(tab === "calculator" && sel.kind === "planet")) {
			activeTab = "inspector";
		}
		sidebarOpen = true;
	});

	// Reveal the calculator whenever the ship-classes table or the
	// bottom-tabs entry asks to load a class (or start a fresh design).
	let lastCalcLoadToken = 0;
	$effect(() => {
		const token = calculatorLoadRequest.token;
		if (token === lastCalcLoadToken) return;
		lastCalcLoadToken = token;
		activeTab = "calculator";
		sidebarOpen = true;
	});

	function toggleSidebar(): void {
		sidebarOpen = !sidebarOpen;
	}

	async function sha256(payload: Uint8Array): Promise<Uint8Array> {
		const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
		return new Uint8Array(digest);
	}

	// `unsubTurnReady` / `unsubGamePaused` carry the
	// `eventStream.on(...)` disposers for the game-scoped push
	// handlers. The layout registers them once the local
	// `GameStateStore` is initialised so an event arriving before
	// `currentTurn` is known cannot misfire.
	let unsubTurnReady: (() => void) | null = null;
	let unsubGamePaused: (() => void) | null = null;
	let unsubMailReceived: (() => void) | null = null;
	const turnReadyDecoder = new TextDecoder("utf-8");

	function parseTurnReadyPayload(
		event: VerifiedEvent,
	): { gameId: string; turn: number } | null {
		try {
			const text = turnReadyDecoder.decode(event.payloadBytes);
			const json: unknown = JSON.parse(text);
			if (typeof json !== "object" || json === null) {
				return null;
			}
			const record = json as Record<string, unknown>;
			const eventGameId = record.game_id;
			const eventTurn = record.turn;
			if (
				typeof eventGameId !== "string" ||
				typeof eventTurn !== "number" ||
				!Number.isFinite(eventTurn)
			) {
				return null;
			}
			return { gameId: eventGameId, turn: eventTurn };
		} catch {
			return null;
		}
	}

	function parseMailReceivedPayload(
		event: VerifiedEvent,
	): { gameId: string; from: string } | null {
		try {
			const text = turnReadyDecoder.decode(event.payloadBytes);
			const json: unknown = JSON.parse(text);
			if (typeof json !== "object" || json === null) {
				return null;
			}
			const record = json as Record<string, unknown>;
			const eventGameId = record.game_id;
			if (typeof eventGameId !== "string") {
				return null;
			}
			const subject =
				typeof record.subject === "string" && record.subject !== ""
					? record.subject
					: typeof record.preview === "string"
						? record.preview
						: "";
			return { gameId: eventGameId, from: subject };
		} catch {
			return null;
		}
	}

	function parseGamePausedPayload(
		event: VerifiedEvent,
	): { gameId: string; reason: string } | null {
		try {
			const text = turnReadyDecoder.decode(event.payloadBytes);
			const json: unknown = JSON.parse(text);
			if (typeof json !== "object" || json === null) {
				return null;
			}
			const record = json as Record<string, unknown>;
			const eventGameId = record.game_id;
			if (typeof eventGameId !== "string") {
				return null;
			}
			const reason = typeof record.reason === "string" ? record.reason : "";
			return { gameId: eventGameId, reason };
		} catch {
			return null;
		}
	}

	let activeTurnReadyToastId: string | null = null;

	$effect(() => {
		const pending = gameState.pendingTurn;
		if (pending === null) {
			if (activeTurnReadyToastId !== null) {
				toast.dismiss(activeTurnReadyToastId);
				activeTurnReadyToastId = null;
			}
			return;
		}
		activeTurnReadyToastId = toast.show({
			messageKey: "game.events.turn_ready.message",
			messageParams: { turn: String(pending) },
			actionLabelKey: "game.events.turn_ready.action",
			onAction: () => {
				void gameState.advanceToPending();
			},
			durationMs: null,
		});
	});

	function teardownSubscriptions(): void {
		if (unsubTurnReady !== null) {
			unsubTurnReady();
			unsubTurnReady = null;
		}
		if (unsubGamePaused !== null) {
			unsubGamePaused();
			unsubGamePaused = null;
		}
		if (unsubMailReceived !== null) {
			unsubMailReceived();
			unsubMailReceived = null;
		}
	}

	// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
	// changes: its cleanup tears the previous game's push-event
	// subscriptions down, then the body rebinds the shared stores to the
	// new id. The shared store instances persist across the switch
	// (descendants captured them through context at construction), so a
	// game → game switch re-initialises them in place rather than
	// recreating them; `onDestroy` performs the terminal `dispose()`
	// when the dispatcher leaves the `game` screen and unmounts the
	// shell. A null id (no active game) is a no-op.
	$effect(() => {
		const activeGameId = appScreen.gameId;
		if (activeGameId === null || activeGameId === "") return;

		(async (): Promise<void> => {
			// DEV-only synthetic-report path. The lobby's "Load
			// synthetic report" affordance enters the game with a
			// `synthetic-<uuid>` id and the matching report
			// pre-registered in an in-memory map. A page reload
			// loses the map entry; that case returns to the lobby
			// so the user reloads the JSON.
			if (isSyntheticGameId(activeGameId)) {
				const report = getSyntheticReport(activeGameId);
				if (report === undefined) {
					appScreen.go("lobby");
					return;
				}
				try {
					// Synthetic mode still needs the wasm `Core` so
					// components that bridge to `pkg/calc/ship.go`
					// (designer preview, BattleViewer mass radii) can
					// resolve their math against the same engine helpers
					// the live path uses. The live branch below also
					// calls `loadCore()`; without it here the Battle
					// Viewer rendered every ship-class circle at
					// MAX_RADIUS in synthetic mode.
					const [{ cache }, core] = await Promise.all([
						loadStore(),
						loadCore(),
					]);
					coreHolder.set(core);
					await Promise.all([
						gameState.initSynthetic({ cache, gameId: activeGameId, report }),
						orderDraft.init({ cache, gameId: activeGameId }),
					]);
					// Deliberately no `galaxyClient.set` and no
					// `orderDraft.bindClient`: synthetic mode never
					// sends to the gateway. The auto-sync pipeline
					// already short-circuits via the UUID guard in
					// `scheduleSync`, but skipping the bind keeps
					// the path simple to reason about.
				} catch (err) {
					gameState.failBootstrap(describeBootstrapError(err));
				}
				return;
			}

			if (
				session.keypair === null ||
				session.deviceSessionId === null ||
				GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
			) {
				return;
			}
			const keypair = session.keypair;
			const deviceSessionId = session.deviceSessionId;
			try {
				const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
				coreHolder.set(core);
				const client = new GalaxyClient({
					core,
					edge: createGatewayClient(gatewayRpcBaseUrl()),
					signer: (canonical) => keypair.sign(canonical),
					sha256,
					deviceSessionId,
					gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
				});
				// Register the `game.turn.ready` dispatch before the
				// network roundtrips below so an event delivered
				// while `gameState.init` is still in flight is not
				// dropped by the singleton stream. `markPendingTurn`
				// already protects against turns that do not advance
				// past the current snapshot. Phase 25: a turn-ready
				// frame arriving while the draft is in `conflict` or
				// `paused` state also resets the draft and rehydrates
				// from the server for the new turn — the old commands
				// became history at the cutoff.
				unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
					const parsed = parseTurnReadyPayload(event);
					if (parsed === null || parsed.gameId !== activeGameId) {
						return;
					}
					gameState.markPendingTurn(parsed.turn);
					if (
						orderDraft.syncStatus === "conflict" ||
						orderDraft.syncStatus === "paused"
					) {
						void orderDraft.resetForNewTurn({
							client,
							turn: parsed.turn,
						});
					}
				});
				unsubGamePaused = eventStream.on("game.paused", (event) => {
					const parsed = parseGamePausedPayload(event);
					if (parsed === null || parsed.gameId !== activeGameId) {
						return;
					}
					orderDraft.markPaused({ reason: parsed.reason });
				});
				unsubMailReceived = eventStream.on(
					"diplomail.message.received",
					(event) => {
						const parsed = parseMailReceivedPayload(event);
						if (parsed === null || parsed.gameId !== activeGameId) {
							return;
						}
						void mailStore.applyPushEvent(parsed.gameId);
						toast.show({
							messageKey: "game.events.mail_new.message",
							messageParams: { from: parsed.from },
							actionLabelKey: "game.events.mail_new.action",
							onAction: () => {
								activeView.select("mail");
							},
							durationMs: 8000,
						});
					},
				);
				await Promise.all([
					gameState.init({ client, cache, gameId: activeGameId }),
					orderDraft.init({ cache, gameId: activeGameId }),
					mailStore.init({ client, cache, gameId: activeGameId }),
				]);
				// A restored or stale game id may point at a game that is
				// no longer in the player's list (cancelled, removed, or
				// access revoked). `init` flags that distinct case via
				// `gameState.notFound` (a transient network error keeps it
				// false and surfaces the in-game error state instead). Drop
				// to the lobby with a toast rather than stranding the user
				// on the in-game "not in your list" error. Leaving the
				// `game` screen unmounts the shell, so the stores are
				// disposed via `onDestroy`; the rest of the bootstrap
				// (client bind, server hydration) is skipped for the dead
				// game.
				if (gameState.notFound) {
					appScreen.go("lobby");
					toast.show({
						messageKey: "game.events.unavailable.message",
						durationMs: 8000,
					});
					return;
				}
				galaxyClient.set(client);
				orderDraft.bindClient(client, {
					getCurrentTurn: () => gameState.currentTurn,
					getHistoryMode: () => gameState.historyMode,
				});
				// The server is always polled at game boot — its
				// stored order may be fresher than the local cache
				// (e.g. user is on a new device), and an offline
				// edit must catch up at re-sync time. The hydration
				// is non-fatal: a network error keeps the local
				// cache and surfaces through `draft.syncStatus`.
				await orderDraft.hydrateFromServer({
					client,
					turn: gameState.currentTurn,
				});
			} catch (err) {
				gameState.failBootstrap(describeBootstrapError(err));
			}
		})();

		return teardownSubscriptions;
	});

	onDestroy(() => {
		teardownSubscriptions();
		gameState.dispose();
		orderDraft.dispose();
		selection.dispose();
	});

	function describeBootstrapError(err: unknown): string {
		if (err instanceof Error) return err.message;
		return "request failed";
	}
</script>

<div class="game-shell" data-testid="game-shell">
	<a class="skip-link" href="#active-view-host">
		{i18n.t("common.skip_to_content")}
	</a>
	<Header
		{sidebarOpen}
		onToggleSidebar={toggleSidebar}
	/>
	<HistoryBanner />
	<div class="body">
		<main
			class="active-view-host"
			id="active-view-host"
			tabindex="-1"
			data-testid="active-view-host"
		>
			{#if effectiveTool === "calc"}
				<Calculator />
			{:else if effectiveTool === "order"}
				<Order />
			{:else if activeView.view === "map"}
				<MapView />
			{:else if activeView.view === "table"}
				<TableView entity={activeView.state.tableEntity ?? ""} />
			{:else if activeView.view === "report"}
				<ReportView />
			{:else if activeView.view === "battle"}
				<BattleView
					gameId={appScreen.gameId ?? ""}
					turn={activeView.state.turn ?? 0}
					battleId={activeView.state.battleId ?? ""}
				/>
			{:else if activeView.view === "mail"}
				<MailView />
			{:else if activeView.view === "designer-science"}
				<DesignerScience scienceId={activeView.state.scienceId} />
			{/if}
		</main>
		<Sidebar
			open={sidebarOpen}
			onClose={() => (sidebarOpen = false)}
			{historyMode}
			bind:activeTab
		/>
	</div>
	<BottomTabs
		activeTool={effectiveTool}
		onSelectTool={(tool) => (mobileTool = tool)}
		hideOrder={historyMode}
	/>
	<PlanetSheet
		planet={selectedPlanet}
		{localShipClass}
		{localScience}
		routes={inspectorRoutes}
		planets={inspectorPlanets}
		mapWidth={inspectorMapWidth}
		mapHeight={inspectorMapHeight}
		localPlayerDrive={inspectorLocalDrive}
		localShipGroups={inspectorLocalShipGroups}
		otherShipGroups={inspectorOtherShipGroups}
		localRace={inspectorLocalRace}
		onMap={effectiveTool === "map"}
		onClose={() => selection.clear()}
	/>
	<ShipGroupSheet
		selection={selectedShipGroup}
		planets={inspectorPlanets}
		{localShipClass}
		localFleets={inspectorLocalFleets}
		otherRaces={inspectorOtherRaces}
		mapWidth={inspectorMapWidth}
		mapHeight={inspectorMapHeight}
		localPlayerDrive={inspectorLocalDrive}
		localPlayerWeapons={inspectorLocalWeapons}
		localPlayerShields={inspectorLocalShields}
		localPlayerCargo={inspectorLocalCargo}
		onMap={effectiveTool === "map"}
		onClose={() => selection.clear()}
	/>
</div>

<style>
	.game-shell {
		display: flex;
		flex-direction: column;
		min-height: 100vh;
		background: var(--color-bg);
		color: var(--color-text);
	}
	.body {
		flex: 1;
		display: flex;
		min-height: 0;
	}
	.active-view-host {
		flex: 1;
		min-width: 0;
		overflow-y: auto;
	}
	@media (max-width: 767.98px) {
		.body {
			padding-bottom: 3.25rem;
		}
	}
</style>
