// Per-game runtime state owned by the in-game shell layout
// (`routes/games/[id]/+layout.svelte`). The store discovers the
// game's current turn through `lobby.my.games.list`, fetches the
// matching `user.games.report`, and exposes a TS-friendly `GameReport`
// snapshot to every consumer (header turn counter, map view,
// inspector tabs in later phases).
//
// Phase 11 covers planets only; later phases extend the report
// surface as their slice of state lands. Every consumer reads from
// the same store instance — instantiation per game guarantees the
// layout remount on `gameId` change reseeds the snapshot, while
// navigation between active views inside the same game keeps the
// instance alive (state-preservation rule, see ui/docs/navigation.md).

import {
	GameStateError,
	fetchGameReport,
	type GameReport,
} from "../api/game-state";
import { listMyGames, type GameSummary } from "../api/lobby";
import type { GalaxyClient } from "../api/galaxy-client";
import type { Cache } from "../platform/store/index";
import type { WrapMode } from "../map/world";

const PREF_NAMESPACE = "game-prefs";
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
	`${gameId}/last-viewed-turn`;

const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
	`${gameId}/turn/${turn}`;

const MAP_TOGGLES_NAMESPACE = "game-map-toggles";

/**
 * MapToggles is the per-game visibility state exposed by the Phase 29
 * gear popover. Every flip persists into `Cache` under
 * `MAP_TOGGLES_NAMESPACE/<gameId>` so the next visit to the game keeps
 * the user's choices; a new server-side turn force-resets the blob to
 * `DEFAULT_MAP_TOGGLES` so a hidden category never makes the player
 * miss what changed (see `GameStateStore.setGame` and
 * `advanceToPending`).
 *
 * Categories with no per-toggle entry are always visible: `local`
 * planets, in-orbit / on-planet ship groups (rendered by the planet
 * inspector, never on the map), and the pending-Send overlay.
 */
export interface MapToggles {
	hyperspaceGroups: boolean;
	incomingGroups: boolean;
	unidentifiedGroups: boolean;
	foreignPlanets: boolean;
	uninhabitedPlanets: boolean;
	unidentifiedPlanets: boolean;
	unreachablePlanets: boolean;
	cargoRoutes: boolean;
	battleMarkers: boolean;
	bombingMarkers: boolean;
	/**
	 * visibleHyperspace toggles the foggy overlay that darkens the
	 * world OUTSIDE the union of `VisibilityDistance` circles around
	 * LOCAL planets. The visible part of the map — the player's
	 * intelligence/scan coverage — stays in the regular background
	 * colour; everything else looks "foggy". Default ON.
	 */
	visibleHyperspace: boolean;
}

export const DEFAULT_MAP_TOGGLES: MapToggles = {
	hyperspaceGroups: true,
	incomingGroups: true,
	unidentifiedGroups: true,
	foreignPlanets: true,
	uninhabitedPlanets: true,
	unidentifiedPlanets: true,
	unreachablePlanets: true,
	cargoRoutes: true,
	battleMarkers: true,
	bombingMarkers: true,
	visibleHyperspace: true,
};

interface PersistedMapToggles {
	readonly toggles: MapToggles;
	readonly lastResetTurn: number;
}

/**
 * GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
 * layout uses to expose its `GameStateStore` instance to descendants.
 * Header / map / inspector children resolve the store via
 * `getContext(GAME_STATE_CONTEXT_KEY)`.
 */
export const GAME_STATE_CONTEXT_KEY = Symbol("game-state");

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

export class GameStateStore {
	gameId: string = $state("");
	/**
	 * gameName mirrors the lobby's `game_name` for the running game.
	 * Lifted from the lobby record on `setGame`; empty during boot
	 * and set once the lobby query resolves. Used by the header to
	 * compose the `<race> @ <game>, turn N` display.
	 */
	gameName: string = $state("");
	status: Status = $state("idle");
	report: GameReport | null = $state(null);
	wrapMode: WrapMode = $state("torus");
	/**
	 * mapToggles is the per-game visibility state surfaced by the
	 * Phase 29 gear popover. Every value defaults to `true` except for
	 * the negative `unreachablePlanets` flag (which is also `true` so
	 * the default view shows every reachable planet). The map view
	 * resolves the flags into a hide-by-id set on every effect run via
	 * `RendererHandle.setHiddenPrimitiveIds`.
	 */
	mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
	error: string | null = $state(null);
	/**
	 * currentTurn mirrors the engine's turn number for the running
	 * game (lifted from the lobby record on `setGame`). Phase 14
	 * exposes it so the layout can pass it to
	 * `OrderDraftStore.hydrateFromServer` after both stores boot;
	 * Phase 26 keeps the "authoritative server-side turn" meaning —
	 * only `setGame`, `advanceToPending`, and the visibility-listener
	 * lobby re-query update it. History navigation (`viewTurn`) leaves
	 * it alone so the "Return to current turn" affordance keeps a
	 * reliable target.
	 */
	currentTurn = $state(0);
	/**
	 * viewedTurn is the turn whose snapshot is currently displayed.
	 * In live mode it equals `currentTurn`. Phase 26 history mode
	 * decouples the two: `viewTurn(N)` flips this rune (and `report`)
	 * to N without touching `currentTurn` or `last-viewed-turn`.
	 */
	viewedTurn = $state(0);
	/**
	 * historyMode is the derived "user is viewing a past turn" rune
	 * consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
	 * history banner, the rendered-report overlay short-circuit, and
	 * the order-draft mutation gate. It depends only on the rune state
	 * above, so every consumer reacts to a single source of truth.
	 */
	historyMode = $derived(
		this.status === "ready" && this.viewedTurn < this.currentTurn,
	);
	/**
	 * synthetic is set by `initSynthetic` for DEV-only sessions backed
	 * by a hand-loaded report (lobby's "Load synthetic report"
	 * affordance). The flag travels through the layout so the order
	 * tab and any future server-bound features can short-circuit and
	 * stay local. The auto-sync pipeline already protects itself via
	 * the UUID guard on `OrderDraftStore.scheduleSync`, so flipping
	 * this flag is enough to keep the network silent.
	 */
	synthetic = $state(false);
	/**
	 * pendingTurn carries the latest server-side turn the user has not
	 * yet opened: it is `> currentTurn` whenever the server reports a
	 * new turn (either through a `game.turn.ready` push event after
	 * boot, or through the boot-time discovery that the persisted
	 * `lastViewedTurn` is behind the lobby's `current_turn`). The
	 * layout's `$effect` renders a toast/banner when it is non-null;
	 * `advanceToPending()` refreshes the store onto the new turn and
	 * clears the rune.
	 */
	pendingTurn: number | null = $state(null);

	private client: GalaxyClient | null = null;
	private cache: Cache | null = null;
	private destroyed = false;
	private visibilityListener: (() => void) | null = null;
	/**
	 * lastResetTurn is the turn at which `mapToggles` was last reset to
	 * defaults. Persisted alongside the toggle blob so the new-turn
	 * reset path can compare against `currentTurn` after a cross-
	 * session gap (browser closed at turn N, reopened at turn N + k).
	 */
	private lastResetTurn = 0;

	/**
	 * init kicks off the per-game lifecycle. The call is idempotent on
	 * the same `gameId`; calling with a different game forwards through
	 * `setGame` so the layout can hand off across navigations.
	 */
	async init(opts: {
		client: GalaxyClient;
		cache: Cache;
		gameId: string;
	}): Promise<void> {
		this.client = opts.client;
		this.cache = opts.cache;
		await this.setGame(opts.gameId);
		this.installVisibilityListener();
	}

	/**
	 * setGame switches the store to the supplied game id, fetches the
	 * matching lobby record to discover `current_turn`, then loads the
	 * report. Failure paths surface through `status === "error"` and
	 * the matching `error` string (already localised by the caller).
	 */
	async setGame(gameId: string): Promise<void> {
		if (this.client === null || this.cache === null) {
			throw new Error("game-state: setGame called before init");
		}
		// Only forget the pending indicator when the consumer is
		// actually switching games. On the initial `setGame` after
		// `init` the previous `gameId` is the empty string, and a
		// concurrent `markPendingTurn` from a push event arriving
		// while we were still bootstrapping must not be erased.
		if (this.gameId !== "" && this.gameId !== gameId) {
			this.pendingTurn = null;
		}
		this.gameId = gameId;
		this.status = "loading";
		this.error = null;
		this.report = null;

		this.wrapMode = await readWrapMode(this.cache, gameId);
		const lastViewed = await readLastViewedTurn(this.cache, gameId);
		const persistedToggles = await readMapToggles(this.cache, gameId);

		try {
			const summary = await this.findGame(gameId);
			if (summary === null) {
				this.status = "error";
				this.error = `game ${gameId} is not in your list`;
				return;
			}
			this.gameName = summary.gameName;
			this.currentTurn = summary.currentTurn;
			// New-turn reset: if the persisted blob is older than the
			// server-side `currentTurn`, drop user overrides and write
			// the fresh `{defaults, currentTurn}` back to cache so a
			// subsequent reload sees the same baseline. The cross-
			// session gap counts here too — a player who closed the
			// tab at turn N and returns at turn N + k still gets the
			// defaults on first map mount.
			if (persistedToggles.lastResetTurn < summary.currentTurn) {
				this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
				this.lastResetTurn = summary.currentTurn;
				await writeMapToggles(
					this.cache,
					gameId,
					this.mapToggles,
					this.lastResetTurn,
				);
			} else {
				this.mapToggles = { ...persistedToggles.toggles };
				this.lastResetTurn = persistedToggles.lastResetTurn;
			}
			// If the persisted last-viewed turn is older than the
			// server-side current turn, open the user on their last-seen
			// snapshot and surface the gap through `pendingTurn` so the
			// shell can render a "new turn available" affordance instead
			// of silently auto-advancing. After Phase 26 the same gap
			// also flips `historyMode` to true (viewedTurn < currentTurn),
			// so the read-only banner appears alongside the toast.
			if (
				lastViewed !== null &&
				lastViewed >= 0 &&
				lastViewed < summary.currentTurn
			) {
				this.pendingTurn = summary.currentTurn;
				await this.loadTurn(lastViewed, { isCurrent: false });
			} else {
				await this.loadTurn(summary.currentTurn, { isCurrent: true });
			}
		} catch (err) {
			if (this.destroyed) return;
			this.status = "error";
			this.error = describe(err);
		}
	}

	/**
	 * markPendingTurn records a server-reported new turn (typically
	 * delivered through `game.turn.ready`). Values that are not
	 * strictly ahead of the latest known turn (current or already
	 * pending) are ignored so a replayed event cannot regress the
	 * indicator.
	 */
	markPendingTurn(turn: number): void {
		const latest = this.pendingTurn ?? this.currentTurn;
		if (turn > latest) {
			this.pendingTurn = turn;
		}
	}

	/**
	 * advanceToPending re-queries the lobby record and loads the
	 * report at the server's latest `current_turn`, then clears the
	 * pending indicator. Unlike `setGame`, this skips the
	 * `lastViewedTurn` lookup — the user has explicitly asked to
	 * jump to the new turn, so any persisted bookmark from the
	 * previous session is irrelevant. Failures keep the indicator
	 * set so the user can retry from the same affordance.
	 */
	async advanceToPending(): Promise<void> {
		if (this.pendingTurn === null || this.client === null) {
			return;
		}
		this.status = "loading";
		this.error = null;
		try {
			const summary = await this.findGame(this.gameId);
			if (summary === null) {
				this.status = "error";
				this.error = `game ${this.gameId} is not in your list`;
				return;
			}
			this.gameName = summary.gameName;
			this.currentTurn = summary.currentTurn;
			await this.loadTurn(summary.currentTurn, { isCurrent: true });
			this.pendingTurn = null;
			// Phase 29: a successful jump onto the new server turn
			// drops user-set map-visibility overrides so the next
			// frame surfaces every category. `viewTurn` is the
			// history-mode path and intentionally leaves toggles
			// alone — the single shared state stays put across
			// in-game time-travel.
			await this.resetMapTogglesForTurn(summary.currentTurn);
		} catch (err) {
			if (this.destroyed) return;
			this.status = "error";
			this.error = describe(err);
		}
	}

	/**
	 * viewTurn loads the historical snapshot for `turn` and switches the
	 * UI into history mode (Phase 26). The current turn is untouched —
	 * `historyMode` flips on automatically through the derived rune, and
	 * the `last-viewed-turn` cache is only refreshed when the caller
	 * happens to ask for the currentTurn (e.g. `returnToCurrent`). A
	 * cache hit on `game-history/{gameId}/turn/{N}` skips the network;
	 * past turns are immutable so the cache never goes stale.
	 */
	async viewTurn(turn: number): Promise<void> {
		if (this.client === null) return;
		if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
			return;
		}
		this.status = "loading";
		this.error = null;
		try {
			await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
		} catch (err) {
			if (this.destroyed) return;
			this.status = "error";
			this.error = describe(err);
		}
	}

	/**
	 * returnToCurrent jumps back to the server's current turn after a
	 * history excursion. Thin wrapper around `viewTurn(currentTurn)` so
	 * the banner / popover share the same call site.
	 */
	returnToCurrent(): Promise<void> {
		return this.viewTurn(this.currentTurn);
	}

	/**
	 * refresh is fired from the `visibilitychange` listener. In live
	 * mode it re-fetches the report at the current turn so the map and
	 * the counter catch up after the user returns to the tab. In
	 * history mode it is a no-op: the user is intentionally viewing a
	 * past turn, push events (Phase 24) deliver new-turn notifications
	 * asynchronously, and forcing a reload would silently bump the
	 * user out of history mode.
	 */
	async refresh(): Promise<void> {
		if (this.client === null) return;
		if (this.historyMode) return;
		try {
			await this.loadTurn(this.currentTurn, { isCurrent: true });
		} catch (err) {
			if (this.destroyed) return;
			console.warn("game-state: refresh failed", err);
		}
	}

	/**
	 * setWrapMode persists the per-game preference into Cache so the
	 * next visit to the game restores it. Phase 29 wires the toggle UI;
	 * Phase 11 only reads through `wrapMode` and writes via this method.
	 */
	async setWrapMode(mode: WrapMode): Promise<void> {
		this.wrapMode = mode;
		if (this.cache !== null) {
			await this.cache.put(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(this.gameId), mode);
		}
	}

	/**
	 * setMapToggle flips one entry of the `mapToggles` rune and
	 * persists the whole blob (alongside the unchanged
	 * `lastResetTurn`). Mutating the rune in place keeps subscribers
	 * reactive without requiring object identity changes.
	 */
	async setMapToggle<K extends keyof MapToggles>(
		key: K,
		value: MapToggles[K],
	): Promise<void> {
		this.mapToggles[key] = value;
		if (this.cache !== null) {
			await writeMapToggles(
				this.cache,
				this.gameId,
				this.mapToggles,
				this.lastResetTurn,
			);
		}
	}

	private async resetMapTogglesForTurn(turn: number): Promise<void> {
		this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
		this.lastResetTurn = turn;
		if (this.cache !== null) {
			await writeMapToggles(
				this.cache,
				this.gameId,
				this.mapToggles,
				this.lastResetTurn,
			);
		}
	}

	/**
	 * failBootstrap is used by the layout to surface errors that
	 * happen *before* `init` could be reached (missing keypair, missing
	 * gateway public key, core/store load failure). It does not need
	 * `init` to have run first.
	 */
	failBootstrap(message: string): void {
		this.status = "error";
		this.error = message;
	}

	/**
	 * initSynthetic seeds the store from a pre-loaded `GameReport`
	 * without touching the network. Used by the lobby's DEV-only
	 * "Load synthetic report" affordance: the layout invokes this
	 * instead of `init` when the route id is in the synthetic id
	 * range. The store ends up in `ready` immediately; no polling,
	 * no visibility-driven refresh, no client / cache-of-server
	 * binding.
	 */
	async initSynthetic(opts: {
		cache: Cache;
		gameId: string;
		report: GameReport;
	}): Promise<void> {
		this.cache = opts.cache;
		this.gameId = opts.gameId;
		this.synthetic = true;
		this.gameName = "Synthetic";
		this.error = null;
		this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
		// Synthetic sessions skip the lobby query, so the new-turn
		// reset check uses the report's own turn as the reference. A
		// reload on the same synthetic id restores user overrides;
		// switching to a synthetic report with a higher turn resets
		// them.
		const persistedToggles = await readMapToggles(opts.cache, opts.gameId);
		if (persistedToggles.lastResetTurn < opts.report.turn) {
			this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
			this.lastResetTurn = opts.report.turn;
			await writeMapToggles(
				opts.cache,
				opts.gameId,
				this.mapToggles,
				this.lastResetTurn,
			);
		} else {
			this.mapToggles = { ...persistedToggles.toggles };
			this.lastResetTurn = persistedToggles.lastResetTurn;
		}
		this.report = opts.report;
		this.currentTurn = opts.report.turn;
		this.viewedTurn = opts.report.turn;
		this.status = "ready";
	}

	dispose(): void {
		this.destroyed = true;
		if (this.visibilityListener !== null && typeof document !== "undefined") {
			document.removeEventListener("visibilitychange", this.visibilityListener);
		}
		this.visibilityListener = null;
		this.client = null;
		this.cache = null;
	}

	private async findGame(gameId: string): Promise<GameSummary | null> {
		if (this.client === null) return null;
		const games = await listMyGames(this.client);
		return games.find((g) => g.gameId === gameId) ?? null;
	}

	private async loadTurn(
		turn: number,
		opts: { isCurrent: boolean },
	): Promise<void> {
		if (this.client === null) return;
		const report = await this.readReport(turn, opts.isCurrent);
		if (this.destroyed) return;
		this.report = report;
		this.viewedTurn = turn;
		this.status = "ready";
		if (this.cache === null) return;
		if (opts.isCurrent) {
			// Persist last-viewed-turn only when the user is caught up
			// on the live snapshot. Historical excursions are ephemeral
			// (Phase 26 decision): the resume-on-open affordance from
			// Phase 11 must keep meaning "the latest turn this player
			// was caught up on", not "wherever they last clicked".
			await this.cache.put(
				PREF_NAMESPACE,
				PREF_KEY_LAST_VIEWED_TURN(this.gameId),
				turn,
			);
			return;
		}
		// Past turns are immutable, so the snapshot is safe to cache
		// for fast re-entry. The current-turn snapshot deliberately
		// skips the cache — it is mutable until the next tick.
		await this.cache.put(
			HISTORY_NAMESPACE,
			HISTORY_KEY_TURN(this.gameId, turn),
			report,
		);
	}

	private async readReport(
		turn: number,
		isCurrent: boolean,
	): Promise<GameReport> {
		if (this.client === null) {
			throw new Error("game-state: readReport called without client");
		}
		if (!isCurrent && this.cache !== null) {
			const cached = await this.cache.get<GameReport>(
				HISTORY_NAMESPACE,
				HISTORY_KEY_TURN(this.gameId, turn),
			);
			if (cached !== undefined && cached.turn === turn) {
				return cached;
			}
		}
		return await fetchGameReport(this.client, this.gameId, turn);
	}

	private installVisibilityListener(): void {
		if (typeof document === "undefined") return;
		const listener = (): void => {
			if (document.visibilityState === "visible" && this.status === "ready") {
				void this.refresh();
			}
		};
		this.visibilityListener = listener;
		document.addEventListener("visibilitychange", listener);
	}
}

async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
	const stored = await cache.get<string>(PREF_NAMESPACE, PREF_KEY_WRAP_MODE(gameId));
	if (stored === "no-wrap") return "no-wrap";
	return "torus";
}

/**
 * readMapToggles loads the persisted `{toggles, lastResetTurn}` blob.
 * Missing entries (cleared site data, fresh game) return the defaults
 * with `lastResetTurn === -1`, guaranteeing the `setGame` reset path
 * runs on the very first visit. Per-field fallback to defaults keeps
 * forward-compat with future toggle additions: an older blob
 * persisted before a new flag landed loses nothing but the missing
 * flag, which gets the default value.
 */
async function readMapToggles(
	cache: Cache,
	gameId: string,
): Promise<PersistedMapToggles> {
	const stored = await cache.get<Partial<PersistedMapToggles>>(
		MAP_TOGGLES_NAMESPACE,
		gameId,
	);
	if (stored === undefined || stored === null || typeof stored !== "object") {
		return { toggles: { ...DEFAULT_MAP_TOGGLES }, lastResetTurn: -1 };
	}
	const partial =
		stored.toggles !== undefined &&
		stored.toggles !== null &&
		typeof stored.toggles === "object"
			? stored.toggles
			: {};
	const toggles: MapToggles = { ...DEFAULT_MAP_TOGGLES };
	for (const k of Object.keys(DEFAULT_MAP_TOGGLES) as (keyof MapToggles)[]) {
		const candidate = (partial as Partial<MapToggles>)[k];
		if (typeof candidate === "boolean") {
			toggles[k] = candidate;
		}
	}
	const turn =
		typeof stored.lastResetTurn === "number" &&
		Number.isFinite(stored.lastResetTurn)
			? stored.lastResetTurn
			: -1;
	return { toggles, lastResetTurn: turn };
}

async function writeMapToggles(
	cache: Cache,
	gameId: string,
	toggles: MapToggles,
	lastResetTurn: number,
): Promise<void> {
	await cache.put<PersistedMapToggles>(MAP_TOGGLES_NAMESPACE, gameId, {
		toggles: { ...toggles },
		lastResetTurn,
	});
}

async function readLastViewedTurn(
	cache: Cache,
	gameId: string,
): Promise<number | null> {
	const stored = await cache.get<number>(
		PREF_NAMESPACE,
		PREF_KEY_LAST_VIEWED_TURN(gameId),
	);
	if (typeof stored !== "number" || !Number.isFinite(stored)) {
		return null;
	}
	return stored;
}

function describe(err: unknown): string {
	if (err instanceof GameStateError) {
		return err.message;
	}
	if (err instanceof Error) {
		return err.message;
	}
	return "request failed";
}
