// Vitest coverage for the per-game runes store
// (`lib/game-state.svelte.ts`). The test stubs `lobby.my.games.list`
// and `user.games.report` at module level and drives the store
// through its lifecycle: init → ready → error → viewTurn → wrap-mode
// persistence. Phase 26 adds coverage for history-mode (current vs.
// viewed turn split, cache-backed re-entry, visibility-refresh
// short-circuit, resume-from-stale-bookmark flips historyMode on).

import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import {
	afterEach,
	beforeEach,
	describe,
	expect,
	test,
	vi,
} from "vitest";
import { Builder } from "flatbuffers";

import { GameStateStore } from "../src/lib/game-state.svelte";
import type { GalaxyClient } from "../src/api/galaxy-client";
import type { Cache } from "../src/platform/store/index";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { IDBPDatabase } from "idb";
import { UUID } from "../src/proto/galaxy/fbs/common";
import { ByteBuffer } from "flatbuffers";
import {
	GameReportRequest,
	LocalPlanet,
	Report,
	ShipClass,
} from "../src/proto/galaxy/fbs/report";

function decodeRequestedTurn(payload: Uint8Array): number {
	const req = GameReportRequest.getRootAsGameReportRequest(
		new ByteBuffer(payload),
	);
	return req.turn();
}

const listMyGamesSpy = vi.fn();
vi.mock("../src/api/lobby", async () => {
	const actual = await vi.importActual<typeof import("../src/api/lobby")>(
		"../src/api/lobby",
	);
	return {
		...actual,
		listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
	};
});

let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;

beforeEach(async () => {
	dbName = `galaxy-game-state-test-${crypto.randomUUID()}`;
	db = await openGalaxyDB(dbName);
	cache = new IDBCache(db);
	listMyGamesSpy.mockReset();
});

afterEach(async () => {
	db.close();
	await new Promise<void>((resolve) => {
		const req = indexedDB.deleteDatabase(dbName);
		req.onsuccess = () => resolve();
		req.onerror = () => resolve();
		req.onblocked = () => resolve();
	});
});

const GAME_ID = "11111111-2222-3333-4444-555555555555";

function makeGameSummary(currentTurn: number): {
	gameId: string;
	gameName: string;
	gameType: string;
	status: string;
	ownerUserId: string;
	minPlayers: number;
	maxPlayers: number;
	enrollmentEndsAt: Date;
	createdAt: Date;
	updatedAt: Date;
	currentTurn: number;
} {
	return {
		gameId: GAME_ID,
		gameName: "Test Game",
		gameType: "private",
		status: "running",
		ownerUserId: "owner-1",
		minPlayers: 2,
		maxPlayers: 8,
		enrollmentEndsAt: new Date(),
		createdAt: new Date(),
		updatedAt: new Date(),
		currentTurn,
	};
}

interface PlanetFixture {
	number: number;
	name: string;
	x: number;
	y: number;
}

interface ShipClassFixture {
	name: string;
	drive?: number;
	armament?: number;
	weapons?: number;
	shields?: number;
	cargo?: number;
}

function buildReportPayload(opts: {
	turn: number;
	width?: number;
	height?: number;
	planets?: PlanetFixture[];
	shipClasses?: ShipClassFixture[];
}): Uint8Array {
	const builder = new Builder(256);
	const planetOffsets = (opts.planets ?? []).map((planet) => {
		const name = builder.createString(planet.name);
		LocalPlanet.startLocalPlanet(builder);
		LocalPlanet.addNumber(builder, BigInt(planet.number));
		LocalPlanet.addX(builder, planet.x);
		LocalPlanet.addY(builder, planet.y);
		LocalPlanet.addName(builder, name);
		LocalPlanet.addSize(builder, 10);
		LocalPlanet.addResources(builder, 0.5);
		return LocalPlanet.endLocalPlanet(builder);
	});
	const shipClassOffsets = (opts.shipClasses ?? []).map((cls) => {
		const name = builder.createString(cls.name);
		ShipClass.startShipClass(builder);
		ShipClass.addName(builder, name);
		ShipClass.addDrive(builder, cls.drive ?? 0);
		ShipClass.addArmament(builder, BigInt(cls.armament ?? 0));
		ShipClass.addWeapons(builder, cls.weapons ?? 0);
		ShipClass.addShields(builder, cls.shields ?? 0);
		ShipClass.addCargo(builder, cls.cargo ?? 0);
		return ShipClass.endShipClass(builder);
	});
	const localPlanetVec =
		planetOffsets.length === 0
			? null
			: Report.createLocalPlanetVector(builder, planetOffsets);
	const localShipClassVec =
		shipClassOffsets.length === 0
			? null
			: Report.createLocalShipClassVector(builder, shipClassOffsets);

	Report.startReport(builder);
	Report.addTurn(builder, BigInt(opts.turn));
	Report.addWidth(builder, opts.width ?? 4000);
	Report.addHeight(builder, opts.height ?? 4000);
	Report.addPlanetCount(builder, planetOffsets.length);
	if (localPlanetVec !== null) {
		Report.addLocalPlanet(builder, localPlanetVec);
	}
	if (localShipClassVec !== null) {
		Report.addLocalShipClass(builder, localShipClassVec);
	}
	const reportOff = Report.endReport(builder);
	builder.finish(reportOff);
	return builder.asUint8Array();
}

function makeFakeClient(
	executeCommand: (
		messageType: string,
		payload: Uint8Array,
	) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
): GalaxyClient {
	return { executeCommand } as unknown as GalaxyClient;
}

describe("GameStateStore", () => {
	test("init transitions through loading and ready when both calls succeed", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);

		const calls: Array<{ messageType: string; payload: Uint8Array }> = [];
		const client = makeFakeClient(async (messageType, payload) => {
			calls.push({ messageType, payload });
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({
					turn: 7,
					planets: [{ number: 1, name: "Home", x: 100, y: 100 }],
				}),
			};
		});

		const store = new GameStateStore();
		expect(store.status).toBe("idle");
		await store.init({ client, cache, gameId: GAME_ID });

		expect(listMyGamesSpy).toHaveBeenCalledTimes(1);
		expect(calls.length).toBe(1);
		expect(calls[0]?.messageType).toBe("user.games.report");
		expect(store.status).toBe("ready");
		expect(store.report).not.toBeNull();
		expect(store.report?.turn).toBe(7);
		expect(store.report?.planets.length).toBe(1);
		expect(store.report?.planets[0]?.kind).toBe("local");

		store.dispose();
	});

	test("init surfaces an error when the game is missing from lobby", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean));
		// Replace the helper above's awkward filter with an explicit
		// mismatched id so the lookup miss is unambiguous.
		listMyGamesSpy.mockResolvedValue([
			{ ...makeGameSummary(2), gameId: "different-game-id" },
		]);

		const client = makeFakeClient(async () => ({
			resultCode: "ok",
			payloadBytes: buildReportPayload({ turn: 0 }),
		}));

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });

		expect(store.status).toBe("error");
		expect(store.error).toMatch(/not in your list/);
		expect(store.report).toBeNull();
		store.dispose();
	});

	test("init surfaces error when user.games.report returns a non-ok result", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(0)]);
		const client = makeFakeClient(async () => ({
			resultCode: "forbidden",
			payloadBytes: new TextEncoder().encode(
				JSON.stringify({ code: "forbidden", message: "no membership" }),
			),
		}));

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		expect(store.status).toBe("error");
		expect(store.error).toMatch(/no membership/);
		store.dispose();
	});

	test("viewTurn loads a historical snapshot without touching currentTurn", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
		const requestedTurns: number[] = [];
		const client = makeFakeClient(async (_messageType, payload) => {
			const turn = decodeRequestedTurn(payload);
			requestedTurns.push(turn);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		expect(store.report?.turn).toBe(3);
		expect(store.currentTurn).toBe(3);
		expect(store.viewedTurn).toBe(3);
		expect(store.historyMode).toBe(false);

		await store.viewTurn(1);
		expect(store.status).toBe("ready");
		expect(store.report?.turn).toBe(1);
		expect(store.viewedTurn).toBe(1);
		expect(store.currentTurn).toBe(3);
		expect(store.historyMode).toBe(true);

		// Phase 26: historical snapshots do not move the
		// last-viewed-turn cache forward — that resumes-on-open
		// bookmark must keep meaning "last current turn caught up on",
		// not "last clicked".
		const lastViewed = await cache.get<number>(
			"game-prefs",
			`${GAME_ID}/last-viewed-turn`,
		);
		expect(lastViewed).toBe(3);

		store.dispose();
	});

	test("returnToCurrent restores the live snapshot and clears historyMode", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(4)]);
		const client = makeFakeClient(async (_messageType, payload) => {
			const turn = decodeRequestedTurn(payload);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		await store.viewTurn(2);
		expect(store.historyMode).toBe(true);

		await store.returnToCurrent();
		expect(store.viewedTurn).toBe(4);
		expect(store.currentTurn).toBe(4);
		expect(store.historyMode).toBe(false);
		expect(store.report?.turn).toBe(4);

		store.dispose();
	});

	test("viewTurn rejects out-of-range turns without touching state", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(2)]);
		const client = makeFakeClient(async (_messageType, payload) => {
			const turn = decodeRequestedTurn(payload);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		expect(store.viewedTurn).toBe(2);

		await store.viewTurn(-1);
		expect(store.viewedTurn).toBe(2);
		await store.viewTurn(99);
		expect(store.viewedTurn).toBe(2);
		await store.viewTurn(Number.NaN);
		expect(store.viewedTurn).toBe(2);

		store.dispose();
	});

	test("viewTurn serves repeated historical reads from the game-history cache", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
		let calls = 0;
		const client = makeFakeClient(async (_messageType, payload) => {
			calls += 1;
			const turn = decodeRequestedTurn(payload);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		expect(calls).toBe(1); // boot fetch at turn 5

		await store.viewTurn(1);
		expect(calls).toBe(2);
		await store.viewTurn(5);
		// Returning to the live turn always hits the network — the
		// current snapshot is mutable until the next tick.
		expect(calls).toBe(3);
		await store.viewTurn(1);
		// Second visit to turn 1 reads from `game-history` cache —
		// past turns are immutable.
		expect(calls).toBe(3);

		store.dispose();
	});

	test("setWrapMode persists across instances through Cache", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(0)]);
		const client = makeFakeClient(async () => ({
			resultCode: "ok",
			payloadBytes: buildReportPayload({ turn: 0 }),
		}));

		const a = new GameStateStore();
		await a.init({ client, cache, gameId: GAME_ID });
		expect(a.wrapMode).toBe("torus");
		await a.setWrapMode("no-wrap");
		expect(a.wrapMode).toBe("no-wrap");
		a.dispose();

		const b = new GameStateStore();
		await b.init({ client, cache, gameId: GAME_ID });
		expect(b.wrapMode).toBe("no-wrap");
		b.dispose();
	});

	test("failBootstrap moves the store into the error state with the given message", () => {
		const store = new GameStateStore();
		store.failBootstrap("device session missing");
		expect(store.status).toBe("error");
		expect(store.error).toBe("device session missing");
	});

	test("setGame opens last-viewed turn and surfaces pendingTurn when server is ahead", async () => {
		await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 4);
		listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);
		const requestedTurns: number[] = [];
		const client = makeFakeClient(async (_messageType, payload) => {
			const turn = decodeRequestedTurn(payload);
			requestedTurns.push(turn);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });

		expect(requestedTurns).toEqual([4]);
		expect(store.report?.turn).toBe(4);
		// Phase 26 splits the runes: `currentTurn` mirrors the lobby's
		// authoritative `current_turn` (7), `viewedTurn` is the
		// snapshot actually loaded (4, the last-viewed bookmark from
		// the previous session). The gap also flips `historyMode` on
		// so the read-only banner appears alongside the pending-turn
		// toast.
		expect(store.currentTurn).toBe(7);
		expect(store.viewedTurn).toBe(4);
		expect(store.historyMode).toBe(true);
		expect(store.pendingTurn).toBe(7);
		store.dispose();
	});

	test("markPendingTurn records server-side advance without a network call", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
		let calls = 0;
		const client = makeFakeClient(async () => {
			calls += 1;
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn: 3 }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		expect(store.pendingTurn).toBeNull();

		const before = calls;
		store.markPendingTurn(4);
		expect(store.pendingTurn).toBe(4);
		store.markPendingTurn(3); // not strictly ahead → ignored
		expect(store.pendingTurn).toBe(4);
		store.markPendingTurn(6);
		expect(store.pendingTurn).toBe(6);
		store.markPendingTurn(5); // not ahead of pending=6 → ignored
		expect(store.pendingTurn).toBe(6);
		expect(calls).toBe(before);

		store.dispose();
	});

	test("advanceToPending refetches and clears the pending indicator", async () => {
		await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 2);
		const summaries = [makeGameSummary(5), makeGameSummary(5)];
		let listCalls = 0;
		listMyGamesSpy.mockImplementation(() => {
			const out = summaries[listCalls] ?? summaries.at(-1)!;
			listCalls += 1;
			return Promise.resolve([out]);
		});

		const requestedTurns: number[] = [];
		const client = makeFakeClient(async (_messageType, payload) => {
			const turn = decodeRequestedTurn(payload);
			requestedTurns.push(turn);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		// `currentTurn` is the server's view (5); the user is held on
		// the bookmarked turn 2 with the pending-turn affordance.
		expect(store.currentTurn).toBe(5);
		expect(store.viewedTurn).toBe(2);
		expect(store.pendingTurn).toBe(5);

		await store.advanceToPending();
		expect(store.currentTurn).toBe(5);
		expect(store.viewedTurn).toBe(5);
		expect(store.historyMode).toBe(false);
		expect(store.pendingTurn).toBeNull();
		expect(requestedTurns).toEqual([2, 5]);

		store.dispose();
	});

	test("refresh in history mode does not touch report or viewedTurn", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
		let calls = 0;
		const client = makeFakeClient(async (_messageType, payload) => {
			calls += 1;
			const turn = decodeRequestedTurn(payload);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		await store.viewTurn(2);
		expect(store.historyMode).toBe(true);
		const callsBefore = calls;

		await store.refresh();
		// History mode keeps the displayed report frozen — push events
		// (Phase 24) carry new-turn notifications asynchronously; the
		// visibility-driven refresh would otherwise silently kick the
		// user out of history.
		expect(calls).toBe(callsBefore);
		expect(store.viewedTurn).toBe(2);
		expect(store.currentTurn).toBe(5);
		expect(store.historyMode).toBe(true);

		store.dispose();
	});

	test("refresh in live mode refetches the current turn", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
		let calls = 0;
		const client = makeFakeClient(async (_messageType, payload) => {
			calls += 1;
			const turn = decodeRequestedTurn(payload);
			return {
				resultCode: "ok",
				payloadBytes: buildReportPayload({ turn }),
			};
		});

		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		const callsBefore = calls;
		await store.refresh();
		expect(calls).toBe(callsBefore + 1);
		expect(store.viewedTurn).toBe(3);
		expect(store.currentTurn).toBe(3);

		store.dispose();
	});

	test("decodeReport surfaces the localShipClass projection with full attributes", async () => {
		listMyGamesSpy.mockResolvedValue([makeGameSummary(1)]);
		const client = makeFakeClient(async () => ({
			resultCode: "ok",
			payloadBytes: buildReportPayload({
				turn: 1,
				planets: [{ number: 1, name: "Earth", x: 100, y: 100 }],
				shipClasses: [
					{
						name: "Scout",
						drive: 1,
						armament: 0,
						weapons: 0,
						shields: 0,
						cargo: 0,
					},
					{
						name: "Destroyer",
						drive: 6,
						armament: 1,
						weapons: 8,
						shields: 4,
						cargo: 0,
					},
				],
			}),
		}));
		const store = new GameStateStore();
		await store.init({ client, cache, gameId: GAME_ID });
		expect(store.report?.localShipClass).toEqual([
			{ name: "Scout", drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0 },
			{
				name: "Destroyer",
				drive: 6,
				armament: 1,
				weapons: 8,
				shields: 4,
				cargo: 0,
			},
		]);
		store.dispose();
	});
});
