// Phase 29 end-to-end coverage for the gear popover. The spec mocks
// the gateway with a mixed-kind report (local + foreign + uninhabited
// + unidentified planets, a battle, a bombing, a cargo route, a
// non-zero drive tech for fog math), then walks the popover through
// the toggles and asserts the renderer state via the
// `__galaxyDebug` accessors:
//
//   * `getMapPrimitives()` — every primitive carries a `visible`
//     flag mirroring the renderer's hide set. The spec counts the
//     visible-foreign-planet primitives, etc.
//   * `getMapFog()` — the current visibility-fog circle list.
//   * `getMapCamera()` — the wrap-mode test reads the centre before
//     and after the flip to confirm camera preservation.
//   * `getMapRenderCount()` — painted-frame counter used by the
//     render-on-demand specs at the bottom of this file: an idle map
//     must not keep repainting, and a released drag must not coast
//     (the `decelerate` plugin was removed).

import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";

import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
	buildMyGamesListPayload,
	type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";

const SESSION_ID = "phase-29-map-toggles-session";
const GAME_ID = "29292929-2929-2929-2929-292929292929";
const RACE = "Earthlings";
// FlightDistance = driveTech * 40; pick drive=10 → reach 400.
// VisibilityDistance = driveTech * 30 → fog radius 300.
const DRIVE_TECH = 10;

interface MockOpts {
	currentTurn: number;
}

async function mockGateway(page: Page, opts: MockOpts): Promise<void> {
	const game: GameFixture = {
		gameId: GAME_ID,
		gameName: "Phase 29 Game",
		gameType: "private",
		status: "running",
		ownerUserId: "user-1",
		minPlayers: 2,
		maxPlayers: 8,
		enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
		createdAtMs: BigInt(Date.now() - 86_400_000),
		updatedAtMs: BigInt(Date.now()),
		currentTurn: opts.currentTurn,
	};

	await page.route(
		"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
		async (route) => {
			const reqText = route.request().postData();
			if (reqText === null) {
				await route.fulfill({ status: 400 });
				return;
			}
			const req = fromJson(
				ExecuteCommandRequestSchema,
				JSON.parse(reqText) as JsonValue,
			);

			let resultCode = "ok";
			let payload: Uint8Array;
			switch (req.messageType) {
				case "lobby.my.games.list":
					payload = buildMyGamesListPayload([game]);
					break;
				case "user.games.report": {
					GameReportRequest.getRootAsGameReportRequest(
						new ByteBuffer(req.payloadBytes),
					).gameId(new UUID());
					payload = buildReportPayload({
						turn: opts.currentTurn,
						mapWidth: 4000,
						mapHeight: 4000,
						race: RACE,
						players: [{ name: RACE, drive: DRIVE_TECH }],
						// Two LOCAL planets near the centre so the reach +
						// fog math has anchors. The foreign planet at
						// (1500, 1000) is 500 units from the closest LOCAL
						// — outside reach (400), so the unreachable filter
						// toggles flag this one when enabled.
						localPlanets: [
							{
								number: 1,
								name: "Earth",
								x: 1000,
								y: 1000,
								size: 1000,
								resources: 10,
							},
							{
								number: 2,
								name: "Mars",
								x: 1200,
								y: 1000,
								size: 1000,
								resources: 10,
							},
						],
						otherPlanets: [
							{
								number: 3,
								name: "Frontier",
								x: 1500,
								y: 1000,
								owner: "Federation",
								size: 800,
								resources: 5,
							},
						],
						uninhabitedPlanets: [
							{
								number: 4,
								name: "Rock",
								x: 1100,
								y: 1100,
								size: 500,
								resources: 1,
							},
						],
						unidentifiedPlanets: [
							{ number: 5, x: 2500, y: 1000 },
						],
						battles: [
							{ id: "8c0c1f64-b0f8-4e7d-8c2c-3e1d0a0b0001", planet: 3, shots: 4 },
						],
						bombings: [
							{
								planetNumber: 3,
								planet: "Frontier",
								owner: "Federation",
								attacker: RACE,
								production: "",
								industry: 100,
								population: 200,
								colonists: 50,
								attackPower: 5,
								wiped: false,
							},
						],
						routes: [
							{
								sourcePlanetNumber: 1,
								entries: [
									{ loadType: "COL", destinationPlanetNumber: 2 },
								],
							},
						],
					});
					break;
				}
				default:
					resultCode = "internal_error";
					payload = new Uint8Array();
			}

			const body = await forgeExecuteCommandResponseJson({
				requestId: req.requestId,
				timestampMs: BigInt(Date.now()),
				resultCode,
				payloadBytes: payload,
			});
			await route.fulfill({
				status: 200,
				contentType: "application/json",
				body,
			});
		},
	);

	// Keep the push stream open so the revocation watcher does not
	// sign the session out mid-test (same convention as
	// `game-shell-map.spec.ts`).
	await page.route(
		"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
		async () => {
			await new Promise<void>(() => {});
		},
	);
}

async function bootSession(page: Page): Promise<void> {
	await page.goto("/__debug/store");
	await expect(page.getByTestId("debug-store-ready")).toBeVisible();
	await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
	await page.evaluate(() => window.__galaxyDebug!.clearSession());
	await page.evaluate(
		(id) => window.__galaxyDebug!.setDeviceSessionId(id),
		SESSION_ID,
	);
}

async function openGame(page: Page): Promise<void> {
	await page.goto(`/games/${GAME_ID}/map`);
	await expect(page.getByTestId("active-view-map")).toHaveAttribute(
		"data-status",
		"ready",
	);
	// Wait for the renderer's debug accessor to register so the
	// `getMapPrimitives` call below picks up real data instead of an
	// empty stub. The renderer registers it inside
	// `runSerializedMount`, which awaits Pixi init.
	await page.waitForFunction(() => {
		const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
		return prims.length > 0;
	});
}

interface PrimitiveLite {
	id: number;
	visible: boolean;
}

async function visiblePlanets(page: Page): Promise<number[]> {
	return await page.evaluate(() => {
		const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
		// Planet primitive ids are the engine planet numbers — small
		// positive integers ≤ planetCount. Other categories use either
		// signed-negative high-bit-prefix ids (cargo route 0x80…, battle
		// 0xa0…, bombing 0xc0…) or large positive offsets (ship groups
		// at 1e8+). The `0 < id < 1e7` window covers the planet range
		// and excludes both.
		return prims
			.filter((p) => p.visible && p.id > 0 && p.id < 10_000_000)
			.map((p) => p.id)
			.sort((a, b) => a - b);
	});
}

async function visibleHighBitCount(
	page: Page,
	prefix: number,
): Promise<number> {
	// JS bitwise `&` always returns a signed int32. Convert both
	// sides to uint32 via `>>> 0` AFTER the mask so the comparison
	// is well-defined for high-bit-prefix ids that arrive as
	// negative Numbers (cargo route 0x80…, battle 0xa0…, bombing
	// 0xc0…) as well as for the positive `prefix` literal passed in.
	return await page.evaluate((p: number) => {
		const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
		const expected = p >>> 0;
		return prims.filter(
			(prim) =>
				prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected,
		).length;
	}, prefix);
}

test("gear popover toggles a planet kind off and cascades onto its markers", async ({
	page,
}) => {
	await mockGateway(page, { currentTurn: 1 });
	await bootSession(page);
	await openGame(page);

	// Baseline — every planet shows up, plus the battle X-cross (2
	// LinePrim) and the bombing ring on the foreign planet.
	expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]);
	expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2);
	expect(await visibleHighBitCount(page, 0xc0000000)).toBe(1);

	await page.getByTestId("map-toggles-trigger").click();
	await expect(page.getByTestId("map-toggles-surface")).toBeVisible();
	await page.getByTestId("map-toggles-foreign-planets").click();

	// The cascade applies asynchronously through the Svelte effect;
	// wait for the foreign planet to drop out of the visible set
	// before asserting on the markers — both updates happen in the
	// same effect tick so once the planet is gone the markers are
	// too.
	await page.waitForFunction(() => {
		const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly {
			id: number;
			visible: boolean;
		}[];
		const planet3 = prims.find((p) => p.id === 3);
		return planet3 !== undefined && planet3.visible === false;
	});

	expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]);
	expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
	expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
});

test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({
	page,
}) => {
	await mockGateway(page, { currentTurn: 1 });
	await bootSession(page);
	await openGame(page);

	// Defaults: fog on; one circle per LOCAL planet, radius
	// `30 * driveTech = 300`.
	const initialFog = await page.evaluate(
		() => window.__galaxyDebug!.getMapFog!().circles,
	);
	expect(initialFog.length).toBe(2);
	expect(initialFog[0].radius).toBe(300);
	expect(initialFog[1].radius).toBe(300);

	await page.getByTestId("map-toggles-trigger").click();
	await page.getByTestId("map-toggles-visible-hyperspace").click();

	// The effect re-run is async; wait for the fog payload to clear
	// instead of reading it on the next tick.
	await page.waitForFunction(
		() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
	);

	// Toggling back on rebuilds the fog circles for the same planets.
	await page.getByTestId("map-toggles-visible-hyperspace").click();
	await page.waitForFunction(
		() => window.__galaxyDebug!.getMapFog!().circles.length === 2,
	);
});

test("wrap mode radios flip the renderer and the camera centre survives", async ({
	page,
}) => {
	await mockGateway(page, { currentTurn: 1 });
	await bootSession(page);
	await openGame(page);

	// Confirm the renderer starts in torus mode.
	await page.waitForFunction(
		() => window.__galaxyDebug?.getMapMode?.() === "torus",
	);
	const initial = await page.evaluate(() =>
		window.__galaxyDebug!.getMapCamera!(),
	);
	expect(initial).not.toBeNull();
	const startCentre = initial!.camera;

	await page.getByTestId("map-toggles-trigger").click();
	await page.getByTestId("map-toggles-wrap-no-wrap").click();

	// `setWrapMode` triggers a full Pixi remount; wait for the
	// renderer to settle into the new mode and the debug surface to
	// re-register before reading the camera. The mode provider is
	// re-bound inside `runSerializedMount` after `createRenderer`
	// resolves, so observing `getMapMode() === "no-wrap"` is the
	// canonical "remount complete" signal.
	await page.waitForFunction(
		() => window.__galaxyDebug?.getMapMode?.() === "no-wrap",
	);

	const after = await page.evaluate(() =>
		window.__galaxyDebug!.getMapCamera!(),
	);
	expect(after).not.toBeNull();
	expect(
		Math.abs(after!.camera.centerX - startCentre.centerX),
	).toBeLessThanOrEqual(1);
	expect(
		Math.abs(after!.camera.centerY - startCentre.centerY),
	).toBeLessThanOrEqual(1);
});

test("toggle state persists across a page reload", async ({ page }) => {
	await mockGateway(page, { currentTurn: 1 });
	await bootSession(page);
	await openGame(page);

	await page.getByTestId("map-toggles-trigger").click();
	await page.getByTestId("map-toggles-battle-markers").click();
	await page.getByTestId("map-toggles-bombing-markers").click();
	// Independent flips: turning battle off must not touch bombing.
	expect(
		await page.getByTestId("map-toggles-battle-markers").isChecked(),
	).toBe(false);
	expect(
		await page.getByTestId("map-toggles-bombing-markers").isChecked(),
	).toBe(false);

	await page.reload();
	await expect(page.getByTestId("active-view-map")).toHaveAttribute(
		"data-status",
		"ready",
	);
	await page.waitForFunction(() => {
		const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
		return prims.length > 0;
	});

	await page.getByTestId("map-toggles-trigger").click();
	expect(
		await page.getByTestId("map-toggles-battle-markers").isChecked(),
	).toBe(false);
	expect(
		await page.getByTestId("map-toggles-bombing-markers").isChecked(),
	).toBe(false);
	// Battle X-cross and bombing ring are hidden in the renderer.
	expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
	expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
});

// settledRenderCount waits out the mount/resize paint burst and returns
// the painted-frame count once it stops advancing. The renderer runs
// render-on-demand, so the count goes flat as soon as the scene is
// static; the loop bails after a fixed number of samples so a renderer
// that never settles fails the spec instead of hanging.
async function settledRenderCount(page: Page): Promise<number> {
	await page.waitForFunction(
		() => (window.__galaxyDebug?.getMapRenderCount?.() ?? 0) > 0,
	);
	return await page.evaluate(async () => {
		const read = (): number =>
			window.__galaxyDebug!.getMapRenderCount!() ?? 0;
		let prev = read();
		for (let i = 0; i < 20; i++) {
			await new Promise((r) => setTimeout(r, 150));
			const cur = read();
			if (cur === prev) return cur;
			prev = cur;
		}
		return prev;
	});
}

test("render-on-demand: an idle map does not repaint, a content mutation does", async ({
	page,
}) => {
	await mockGateway(page, { currentTurn: 1 });
	await bootSession(page);
	await openGame(page);

	const settled = await settledRenderCount(page);

	// Idle window: no pointer interaction, no toggle. A continuous
	// auto-render loop would add ~40 frames over 700ms at 60fps; render
	// -on-demand adds none. The +2 slack tolerates a lone stray frame
	// (e.g. a late layout settle) while still failing hard if the
	// always-on loop ever comes back.
	await page.waitForTimeout(700);
	const afterIdle = await page.evaluate(
		() => window.__galaxyDebug!.getMapRenderCount!(),
	);
	expect(afterIdle).toBeLessThanOrEqual(settled + 2);

	// Toggling the fog mutates the scene graph and must repaint.
	await page.getByTestId("map-toggles-trigger").click();
	await page.getByTestId("map-toggles-visible-hyperspace").click();
	await page.waitForFunction(
		() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
	);
	// The repaint lands on the next shared-ticker frame after the fog
	// input changed, so poll for the counter to advance rather than
	// reading it synchronously (the timing of that frame is racy).
	await page.waitForFunction(
		(baseline) => window.__galaxyDebug!.getMapRenderCount!() > baseline,
		afterIdle,
	);
});

test("pan stops immediately on release: no inertia tail after a drag", async ({
	page,
}) => {
	await mockGateway(page, { currentTurn: 1 });
	await bootSession(page);
	await openGame(page);

	await settledRenderCount(page);

	const canvas = page.getByTestId("active-view-map").locator("canvas");
	const box = await canvas.boundingBox();
	expect(box).not.toBeNull();
	if (box === null) return;
	const cx = box.x + box.width / 2;
	const cy = box.y + box.height / 2;

	// Decisive drag with intermediate steps so pixi-viewport's drag
	// plugin clears its movement threshold.
	await page.mouse.move(cx, cy);
	await page.mouse.down();
	for (let step = 1; step <= 16; step++) {
		await page.mouse.move(cx - (160 * step) / 16, cy - (120 * step) / 16);
	}
	await page.mouse.up();

	// Let the final drag frame flush, then snapshot the camera centre
	// and confirm it does not drift over the next ~500ms. Without the
	// `decelerate` plugin the viewport freezes the instant the drag
	// ends, so the centre is identical; a re-introduced inertia tail
	// would coast it by many world units. (If the synthetic drag never
	// registered the centre is also static, so the spec never
	// false-fails — it only catches a returning inertia tail.)
	await page.waitForTimeout(120);
	const atRelease = await page.evaluate(
		() => window.__galaxyDebug!.getMapCamera!()!.camera,
	);
	await page.waitForTimeout(500);
	const later = await page.evaluate(
		() => window.__galaxyDebug!.getMapCamera!()!.camera,
	);
	expect(Math.abs(later.centerX - atRelease.centerX)).toBeLessThan(1);
	expect(Math.abs(later.centerY - atRelease.centerY)).toBeLessThan(1);
});
