// Phase 9 end-to-end checks for the map renderer playground.
//
// Each Playwright project exercises a different rendering backend:
// chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile
// projects pick their default. The window.__galaxyMap surface
// (defined in src/routes/__debug/map/+page.svelte) lets the spec
// read the camera and viewport state without poking Pixi internals.

import { expect, test, type Page } from "@playwright/test";

interface DebugMapSurface {
	ready: true;
	getMode(): "torus" | "no-wrap";
	setMode(mode: "torus" | "no-wrap"): void;
	getCamera(): { centerX: number; centerY: number; scale: number };
	setCameraCenter(centerX: number, centerY: number): void;
	getViewport(): { widthPx: number; heightPx: number };
	getBackend(): string;
	getWorldSize(): { width: number; height: number };
	hitAt(x: number, y: number): number | null;
}

declare global {
	interface Window {
		__galaxyMap?: DebugMapSurface;
	}
}

function preferenceFor(projectName: string): "webgpu" | "webgl" | null {
	if (projectName === "chromium-desktop") return "webgpu";
	if (projectName === "webkit-desktop") return "webgl";
	return null;
}

async function bootMap(page: Page, preference: "webgpu" | "webgl" | null): Promise<void> {
	const url = preference !== null ? `/__debug/map?renderer=${preference}` : "/__debug/map";
	await page.goto(url);
	await page.waitForFunction(() => window.__galaxyMap?.ready === true, undefined, {
		timeout: 15_000,
	});
	await expect(page.getByTestId("backend")).toBeVisible();
}

test("map mounts in the requested backend and reports it via data-backend", async ({
	page,
}, testInfo) => {
	const pref = preferenceFor(testInfo.project.name);
	await bootMap(page, pref);
	const backend = await page.getByTestId("backend").getAttribute("data-backend");
	expect(backend).not.toBeNull();
	if (pref === null) {
		// Mobile projects auto-pick; just assert a real backend was chosen.
		expect(["webgl", "webgpu", "canvas"]).toContain(backend);
	} else {
		// The renderer should honour the requested preference unless the
		// runner lacks a working WebGPU adapter, in which case Pixi
		// falls back to WebGL. Both are acceptable.
		expect(["webgl", "webgpu"]).toContain(backend);
	}
});

test("wheel zoom-in increases camera scale", async ({ page }, testInfo) => {
	await bootMap(page, preferenceFor(testInfo.project.name));
	const before = await page.evaluate(() => window.__galaxyMap!.getCamera());
	const canvas = page.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;
	await page.mouse.move(cx, cy);
	for (let i = 0; i < 5; i++) {
		await page.mouse.wheel(0, -120);
		await page.waitForTimeout(40);
	}
	await page.waitForTimeout(100);
	const after = await page.evaluate(() => window.__galaxyMap!.getCamera());
	expect(after.scale).toBeGreaterThan(before.scale);
});

test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo) => {
	await bootMap(page, preferenceFor(testInfo.project.name));
	expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
	await page.getByTestId("mode-toggle").click();
	expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("no-wrap");
	await page.getByTestId("mode-toggle").click();
	expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
});

test("torus mode wraps the camera back into the central tile after a long pan", async ({
	page,
}, testInfo) => {
	await bootMap(page, preferenceFor(testInfo.project.name));
	expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
	const canvas = page.locator("canvas");
	const box = await canvas.boundingBox();
	expect(box).not.toBeNull();
	if (box === null) return;

	// Drive the renderer's `viewport.moveCenter` directly through the
	// debug surface — far past the world rectangle on both axes. The
	// `'moved'` listener installed by the renderer should snap the
	// camera back into `[0, W) × [0, H)`. Going through the debug
	// surface avoids the slow / flaky drag-and-decelerate path.
	const { cam, world } = await page.evaluate(() => {
		const m = window.__galaxyMap!;
		const w = m.getWorldSize();
		// Push camera 5.4 world widths to the right and 7.25 world
		// heights below — values chosen so neither ends up at a
		// rounded boundary that would mask a missing wrap.
		m.setCameraCenter(w.width * 5.4, w.height * 7.25);
		return { cam: m.getCamera(), world: w };
	});
	const tol = 1e-3;
	expect(cam.centerX).toBeGreaterThanOrEqual(-tol);
	expect(cam.centerX).toBeLessThan(world.width + tol);
	expect(cam.centerY).toBeGreaterThanOrEqual(-tol);
	expect(cam.centerY).toBeLessThan(world.height + tol);
	// The wrapped position must be congruent modulo world dimensions
	// to the requested 5.4 × W / 7.25 × H.
	expect(cam.centerX).toBeCloseTo(world.width * 0.4, 3);
	expect(cam.centerY).toBeCloseTo(world.height * 0.25, 3);
});

test("torus mode clamps zoom-out to minScale so the world is never smaller than the viewport", async ({
	page,
}, testInfo) => {
	await bootMap(page, preferenceFor(testInfo.project.name));
	expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
	const canvas = page.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;
	await page.mouse.move(cx, cy);
	// Wheel out aggressively. Without the torus-mode clampZoom the
	// camera scale would drop below `minScale = max(viewport/world)`,
	// exposing the 3×3 grid of wrap copies. With the fix in place the
	// scale floor is never crossed.
	for (let i = 0; i < 15; i++) {
		await page.mouse.wheel(0, 600);
		await page.waitForTimeout(40);
	}
	await page.waitForTimeout(200);
	const snapshot = await page.evaluate(() => ({
		cam: window.__galaxyMap!.getCamera(),
		vp: window.__galaxyMap!.getViewport(),
		world: window.__galaxyMap!.getWorldSize(),
	}));
	const minScale = Math.max(
		snapshot.vp.widthPx / snapshot.world.width,
		snapshot.vp.heightPx / snapshot.world.height,
	);
	const tol = 1e-3;
	expect(snapshot.cam.scale).toBeGreaterThanOrEqual(minScale - tol);
});

test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
	page,
}, testInfo) => {
	await bootMap(page, preferenceFor(testInfo.project.name));
	await page.evaluate(() => window.__galaxyMap!.setMode("no-wrap"));
	await page.waitForTimeout(50);

	const canvas = page.locator("canvas");
	const box = await canvas.boundingBox();
	expect(box).not.toBeNull();
	if (box === null) return;

	// Drag right-to-left across most of the canvas so the camera
	// would, without clamp, push past the right edge of the world.
	const startX = box.x + box.width * 0.85;
	const endX = box.x + box.width * 0.15;
	const y = box.y + box.height / 2;
	await page.mouse.move(startX, y);
	await page.mouse.down();
	for (let step = 1; step <= 20; step++) {
		const x = startX + ((endX - startX) * step) / 20;
		await page.mouse.move(x, y);
	}
	await page.mouse.up();
	await page.waitForTimeout(200);

	const { cam, vp, world } = await page.evaluate(() => ({
		cam: window.__galaxyMap!.getCamera(),
		vp: window.__galaxyMap!.getViewport(),
		world: window.__galaxyMap!.getWorldSize(),
	}));
	const halfSpanX = vp.widthPx / (2 * cam.scale);
	const tol = 1; // tolerance in world units; clamp is applied in pixels
	expect(cam.centerX).toBeGreaterThanOrEqual(halfSpanX - tol);
	expect(cam.centerX).toBeLessThanOrEqual(world.width - halfSpanX + tol);
});

test("hitAt returns a primitive id when the cursor is over the world centre", async ({
	page,
}, testInfo) => {
	await bootMap(page, preferenceFor(testInfo.project.name));
	const canvas = page.locator("canvas");
	const box = await canvas.boundingBox();
	expect(box).not.toBeNull();
	if (box === null) return;
	const cx = Math.round(box.width / 2);
	const cy = Math.round(box.height / 2);
	// The fixture world is dense (~950 stars in 4000×4000). Anywhere
	// within the canvas should land near at least one primitive.
	// We sweep a small grid around the centre to find any hit; the
	// goal is to confirm the hit-test plumbing works against the
	// live renderer, not to assert a specific id.
	const found = await page.evaluate(
		({ cx, cy }) => {
			const m = window.__galaxyMap!;
			for (let dy = -40; dy <= 40; dy += 8) {
				for (let dx = -40; dx <= 40; dx += 8) {
					const id = m.hitAt(cx + dx, cy + dy);
					if (id !== null) return id;
				}
			}
			return null;
		},
		{ cx, cy },
	);
	expect(found).not.toBeNull();
});
