<!--
Phase 11 map active view: integrates the Phase 9 renderer with the
per-game `GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
once the store has produced a report and re-mounts when the
report's turn changes (a refresh that returns the same turn keeps
the existing renderer instance alive). Empty-planet reports render
the empty world without errors — the regression test in
`tests/e2e/game-shell-map.spec.ts` covers this.

Phase 9 owns the renderer's hit-test and pan/zoom semantics. Phase 13
plugs map clicks into the inspector by translating the renderer's
`clicked` event into a hit-test, looking the planet up by id in the
report, and calling `SelectionStore.selectPlanet`. The selection
store, set in the layout, drives both the desktop sidebar inspector
tab and the mobile bottom-sheet — the map view itself does not need
to know which surface is showing the result.

Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
preference the store already manages.
-->
<script lang="ts">
	import { getContext, onDestroy, onMount, untrack } from "svelte";
	import { goto } from "$app/navigation";
	import { page } from "$app/state";
	import { i18n } from "$lib/i18n/index.svelte";
	import {
		createRenderer,
		minScaleNoWrap,
		type RendererHandle,
	} from "../../map/index";
	import { buildCargoRouteLines } from "../../map/cargo-routes";
	import { buildPendingSendLines } from "../../map/pending-send-routes";
	import { reportToWorld, type HitTarget } from "../../map/state-binding";
	import type { PrimitiveID } from "../../map/world";
	import {
		ORDER_DRAFT_CONTEXT_KEY,
		OrderDraftStore,
	} from "../../sync/order-draft.svelte";
	import type { OrderCommand } from "../../sync/order-types";
	import {
		GAME_STATE_CONTEXT_KEY,
		type GameStateStore,
	} from "$lib/game-state.svelte";
	import {
		SELECTION_CONTEXT_KEY,
		type SelectionStore,
	} from "$lib/selection.svelte";
	import {
		RENDERED_REPORT_CONTEXT_KEY,
		type RenderedReportSource,
	} from "$lib/rendered-report.svelte";
	import {
		MAP_PICK_CONTEXT_KEY,
		MapPickService,
	} from "$lib/map-pick.svelte";
	import {
		installRendererDebugSurface,
		registerMapCameraProvider,
		registerMapPickStateProvider,
		registerMapPrimitivesProvider,
		type MapCameraSnapshot,
		type MapPickStateSnapshot,
		type MapPrimitiveSnapshot,
	} from "$lib/debug-surface.svelte";

	const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
	const renderedReport = getContext<RenderedReportSource | undefined>(
		RENDERED_REPORT_CONTEXT_KEY,
	);
	const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
	// `MapPickService` is owned by the in-game shell layout (set on
	// the context tree the inspector subsections also descend from).
	// Renderer changes attach / detach via `bindResolver` so a
	// remount mid-pick does not orphan a pending promise. The map
	// view is mounted only beneath the layout, so the service is
	// always present in production; tests render the map in isolation
	// and may omit it.
	const pickService = getContext<MapPickService | undefined>(
		MAP_PICK_CONTEXT_KEY,
	);
	// Pending Send commands turn into green dashed tracks on the
	// map overlay; the draft is read alongside the report so the
	// overlay stays in lock-step with the order tab.
	const orderDraft = getContext<OrderDraftStore | undefined>(
		ORDER_DRAFT_CONTEXT_KEY,
	);

	let canvasEl: HTMLCanvasElement | null = $state(null);
	let containerEl: HTMLDivElement | null = $state(null);
	let mountError: string | null = $state(null);

	let handle: RendererHandle | null = null;
	let hitLookup = new Map<PrimitiveID, HitTarget>();
	let mountedTurn: number | null = null;
	let mountedGameId: string | null = null;
	let onResize: (() => void) | null = null;
	let detachClick: (() => void) | null = null;
	let detachDebugProviders: (() => void) | null = null;
	let detachDebugSurface: (() => void) | null = null;
	// `mounted` must be `$state` so the renderer-mount effect re-runs
	// once `onMount` flips it true. On the first map navigation the
	// effect's initial pass returns early (gameState is still hydrating
	// → `report` is null), and the subsequent server-driven `report`
	// transition re-fires the effect after `onMount` has already
	// completed. On a second navigation back to /map the report is
	// already loaded — without reactivity here the effect's first
	// pass would gate on `mounted === false`, and there would be no
	// later state change to wake it up. The visible symptom is a
	// black canvas (renderer never re-mounted on the new DOM).
	let mounted = $state(false);
	// Mount serialization. The `$effect` may re-fire while the
	// async `mountRenderer` is mid-flight (e.g. report transitions
	// from null → populated → overlay-mutated during boot). Without
	// the in-progress gate, parallel `createRenderer` awaits would
	// leave both old and new viewport listeners on the canvas,
	// double-firing every click. The gate is intentionally a plain
	// `let` (not `$state`) so reads from the effect do not register
	// as a reactive dependency.
	let mountInProgress = false;
	let pendingMountSignal = $state(0);
	// Track the latest cargo-route fingerprint we pushed to the
	// renderer so a no-op push (e.g. report refresh that yields the
	// same overlay) doesn't churn Pixi graphics needlessly.
	let lastExtrasFingerprint: string | null = null;

	$effect(() => {
		// Read the overlay-applied report so the map labels reflect
		// pending renames immediately. Falls back to raw report when
		// the rendered source is missing (e.g. component used outside
		// the in-game shell layout).
		const report = renderedReport?.report ?? store?.report;
		const status = store?.status ?? "idle";
		// Track the wrap mode so the renderer remounts when Phase 29's
		// toggle UI flips it; the read here also subscribes the effect.
		const mode = store?.wrapMode ?? "torus";
		const gameId = store?.gameId ?? "";
		if (!mounted || canvasEl === null || containerEl === null) return;
		if (status !== "ready" || !report) return;

		// Cargo-route arrows and pending-Send tracks are pushed onto
		// the live renderer via `setExtraPrimitives` so the overlay
		// can change inside a single turn without disposing the Pixi
		// `Application` — Pixi 8 does not reliably re-init on the
		// same canvas. The fingerprint guard avoids redundant Pixi
		// rebuilds when the overlay computation re-runs but the
		// routes / pending-Send content is unchanged (e.g. status
		// transitions valid → submitting → applied for the same
		// command).
		const draftCommands = orderDraft?.commands ?? [];
		const draftStatuses = orderDraft?.statuses ?? {};
		const extrasFingerprint =
			computeRoutesFingerprint(report.routes) +
			"|" +
			computePendingSendFingerprint(draftCommands, draftStatuses);

		const sameSnapshot =
			mountedTurn === report.turn &&
			mountedGameId === gameId &&
			handle !== null &&
			handle.getMode() === mode;
		if (sameSnapshot) {
			if (lastExtrasFingerprint !== extrasFingerprint) {
				untrack(() => {
					handle?.setExtraPrimitives([
						...buildCargoRouteLines(report),
						...buildPendingSendLines(report, draftCommands, draftStatuses),
					]);
				});
				lastExtrasFingerprint = extrasFingerprint;
			}
			return;
		}

		// Read the pending-mount signal so the effect re-runs after
		// the in-flight mount completes (it bumps the signal in its
		// finally block). Without this, a dep change observed while
		// `mountInProgress` is true would be silently dropped.
		void pendingMountSignal;
		if (mountInProgress) return;
		untrack(() => {
			void runSerializedMount(report, mode, extrasFingerprint);
		});
	});

	async function runSerializedMount(
		report: NonNullable<GameStateStore["report"]>,
		mode: "torus" | "no-wrap",
		routesFingerprint: string,
	): Promise<void> {
		mountInProgress = true;
		try {
			await mountRenderer(report, mode, routesFingerprint);
		} finally {
			mountInProgress = false;
			// Bump the reactive signal so any dep change observed
			// while the gate was up gets a fresh effect run with the
			// current state.
			pendingMountSignal += 1;
		}
	}

	function computeRoutesFingerprint(
		routes: NonNullable<GameStateStore["report"]>["routes"],
	): string {
		if (routes.length === 0) return "";
		const parts = routes.map((route) => {
			const entries = route.entries
				.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
				.join(",");
			return `${route.sourcePlanetNumber}:${entries}`;
		});
		return parts.join(";");
	}

	function computePendingSendFingerprint(
		commands: readonly OrderCommand[],
		statuses: Readonly<Record<string, string>>,
	): string {
		const parts: string[] = [];
		for (const cmd of commands) {
			if (cmd.kind !== "sendShipGroup") continue;
			const status = statuses[cmd.id];
			if (status === "rejected" || status === "invalid") continue;
			parts.push(`${cmd.groupId}->${cmd.destinationPlanetNumber}`);
		}
		return parts.join(";");
	}

	async function mountRenderer(
		report: NonNullable<GameStateStore["report"]>,
		mode: "torus" | "no-wrap",
		routesFingerprint: string,
	): Promise<void> {
		if (canvasEl === null || containerEl === null) return;
		// Capture camera state before disposing so a remount inside
		// the same game (e.g. cargo-route overlay change) keeps the
		// user's pan/zoom. A new game / first mount has no prior
		// camera, so `previousCamera` stays null and the default
		// centring path runs.
		const previousGameId = mountedGameId;
		const targetGameId = store?.gameId ?? "";
		const previousCamera =
			handle !== null && previousGameId === targetGameId
				? handle.getCamera()
				: null;
		if (detachClick !== null) {
			detachClick();
			detachClick = null;
		}
		// Detach the previous resolver before disposing — the
		// renderer's `dispose` already calls `onPick(null)` on any
		// open session, which `bindResolver(null)` would also do, so
		// we route the cancel through one path only.
		pickService?.bindResolver(null);
		if (detachDebugProviders !== null) {
			detachDebugProviders();
			detachDebugProviders = null;
		}
		if (handle !== null) {
			handle.dispose();
			handle = null;
		}
		try {
			const { world, hitLookup: nextHitLookup } = reportToWorld(report);
			hitLookup = nextHitLookup;
			handle = await createRenderer({
				canvas: canvasEl,
				world,
				mode,
				preference: ["webgpu", "webgl"],
			});
			const minScale = minScaleNoWrap(
				{
					widthPx: containerEl.clientWidth,
					heightPx: containerEl.clientHeight,
				},
				world,
			);
			if (previousCamera !== null) {
				// Same-game remount — preserve pan/zoom. Clamp zoom
				// to `minScale` so a remount that re-derives the
				// minimum (e.g. a viewport resize between renderers)
				// does not strand the user below the current floor.
				handle.viewport.moveCenter(
					previousCamera.centerX,
					previousCamera.centerY,
				);
				handle.viewport.setZoom(
					Math.max(previousCamera.scale, minScale),
					true,
				);
			} else {
				handle.viewport.moveCenter(world.width / 2, world.height / 2);
				handle.viewport.setZoom(minScale * 1.05, true);
			}
			if (mode === "no-wrap") handle.setMode("no-wrap");
			detachClick = handle.onClick(handleMapClick);
			pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
				if (handle === null) {
					onResolve(null);
					return null;
				}
				const planet = report.planets.find(
					(p) => p.number === sourcePlanetNumber,
				);
				if (planet === undefined) {
					onResolve(null);
					return null;
				}
				return handle.setPickMode({
					sourcePrimitiveId: sourcePlanetNumber,
					sourceX: planet.x,
					sourceY: planet.y,
					reachableIds,
					onPick: onResolve,
				});
			});
			const detachPrim = registerMapPrimitivesProvider(() => {
				const h = handle;
				if (h === null) return [];
				return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
					id: p.id,
					kind: p.kind,
					priority: p.priority,
					alpha: h.getPrimitiveAlpha(p.id),
					fillColor: p.style.fillColor ?? null,
					strokeColor: p.style.strokeColor ?? null,
					x: p.kind === "point" ? p.x : null,
					y: p.kind === "point" ? p.y : null,
				}));
			});
			const detachPick = registerMapPickStateProvider(() => {
				const h = handle;
				if (h === null) {
					return {
						active: false,
						sourcePlanetNumber: null,
						reachableIds: [],
						hoveredId: null,
					} satisfies MapPickStateSnapshot;
				}
				const state = h.getPickState();
				return {
					active: state.active,
					sourcePlanetNumber:
						state.sourcePrimitiveId === null
							? null
							: Number(state.sourcePrimitiveId),
					reachableIds:
						state.reachableIds === null
							? []
							: Array.from(state.reachableIds).map((id) => Number(id)),
					hoveredId:
						state.hoveredId === null ? null : Number(state.hoveredId),
				} satisfies MapPickStateSnapshot;
			});
			const detachCamera = registerMapCameraProvider(() => {
				const h = handle;
				if (h === null) return null;
				const camera = h.getCamera();
				const viewport = h.getViewport();
				const rect = canvasEl?.getBoundingClientRect();
				return {
					camera,
					viewport,
					canvasOrigin: {
						x: rect?.left ?? 0,
						y: rect?.top ?? 0,
					},
				} satisfies MapCameraSnapshot;
			});
			detachDebugProviders = (): void => {
				detachPrim();
				detachPick();
				detachCamera();
			};
			mountedTurn = report.turn;
			mountedGameId = targetGameId;
			// Initial mount carries no extras yet; the post-mount
			// effect run pushes the current cargo-route lines via
			// `setExtraPrimitives` once `lastExtrasFingerprint`
			// disagrees with the freshly computed fingerprint.
			lastExtrasFingerprint = null;
			mountError = null;
			void routesFingerprint;
		} catch (err) {
			mountError = err instanceof Error ? err.message : String(err);
		}
	}

	// handleMapClick translates a renderer click into a selection
	// update. A click that misses every primitive (empty space) is a
	// deliberate no-op: the selection rule from Phase 13 is that only
	// the explicit close button on the mobile sheet clears the
	// current selection. The Phase 19 ship-group surface dispatches
	// through the same `hit-test` plumbing — the hitLookup map keyed
	// by primitive id resolves a hit back to either a planet or a
	// ship-group selection variant.
	function handleMapClick(cursorPx: { x: number; y: number }): void {
		if (handle === null || store?.report === undefined || store.report === null) {
			return;
		}
		if (selection === undefined) return;
		const hit = handle.hitAt(cursorPx);
		if (hit === null) return;
		const target = hitLookup.get(hit.primitive.id);
		if (target === undefined) return;
		switch (target.kind) {
			case "planet":
				if (hit.primitive.kind !== "point") return;
				selection.selectPlanet(target.number);
				break;
			case "shipGroup":
				if (hit.primitive.kind !== "point") return;
				selection.selectShipGroup(target.ref);
				break;
			case "battle": {
				const gameId = page.params.id ?? "";
				const turn = store?.report?.turn ?? 0;
				void goto(
					`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
				);
				break;
			}
			case "bombing": {
				const gameId = page.params.id ?? "";
				void goto(
					`/games/${gameId}/report#report-bombings`,
				).then(() => {
					if (typeof document === "undefined") return;
					const row = document.querySelector(
						`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
					);
					if (row && row.scrollIntoView) {
						row.scrollIntoView({ behavior: "smooth", block: "center" });
					}
				});
				break;
			}
		}
	}

	onMount(() => {
		mounted = true;
		onResize = (): void => {
			if (handle === null || containerEl === null) return;
			handle.resize(containerEl.clientWidth, containerEl.clientHeight);
		};
		window.addEventListener("resize", onResize);
		// In DEV the in-game shell mounts on a fresh document load
		// (`page.goto`), which discards anything the
		// `/__debug/store` route may have installed earlier in the
		// session. The renderer-side accessors are still useful for
		// e2e specs driving the map, so we install them here too.
		if (import.meta.env.DEV) {
			detachDebugSurface = installRendererDebugSurface();
		}
	});

	onDestroy(() => {
		mounted = false;
		if (onResize !== null) {
			window.removeEventListener("resize", onResize);
			onResize = null;
		}
		if (detachClick !== null) {
			detachClick();
			detachClick = null;
		}
		pickService?.bindResolver(null);
		if (detachDebugProviders !== null) {
			detachDebugProviders();
			detachDebugProviders = null;
		}
		if (detachDebugSurface !== null) {
			detachDebugSurface();
			detachDebugSurface = null;
		}
		if (handle !== null) {
			handle.dispose();
			handle = null;
		}
	});
</script>

<section class="active-view" data-testid="active-view-map" data-status={store?.status ?? "idle"}>
	{#if store?.status === "error"}
		<p class="overlay error" role="alert" data-testid="map-error">
			{store.error ?? "request failed"}
		</p>
	{:else if mountError !== null}
		<p class="overlay error" role="alert" data-testid="map-mount-error">
			{mountError}
		</p>
	{:else if store?.status !== "ready"}
		<p class="overlay" data-testid="map-loading">{i18n.t("common.loading")}</p>
	{/if}
	<div
		class="canvas-wrap"
		data-testid="map-canvas-wrap"
		data-planet-count={store?.report?.planets.length ?? 0}
		bind:this={containerEl}
	>
		<canvas bind:this={canvasEl}></canvas>
	</div>
</section>

<style>
	.active-view {
		position: relative;
		display: flex;
		flex-direction: column;
		height: 100%;
		min-height: 0;
	}
	.canvas-wrap {
		flex: 1;
		min-height: 0;
		position: relative;
		overflow: hidden;
		background: #0a0e1a;
	}
	canvas {
		display: block;
		width: 100%;
		height: 100%;
	}
	.overlay {
		position: absolute;
		top: 0.75rem;
		left: 50%;
		transform: translateX(-50%);
		padding: 0.4rem 0.9rem;
		background: rgba(20, 24, 42, 0.85);
		color: #e8eaf6;
		border: 1px solid #2a3150;
		border-radius: 6px;
		z-index: 10;
		font-family: system-ui, sans-serif;
		font-size: 0.9rem;
		margin: 0;
	}
	.overlay.error {
		background: #4a1820;
		border-color: #6d2530;
		color: #ffb4b4;
	}
</style>
