// Pure-state coverage for the pick-mode overlay helper. The
// renderer owns the Pixi side (`render.ts.openPickMode`); this file
// asserts that `computePickOverlay` produces the correct draw spec
// for every meaningful input combination — Pixi-free, so it stays
// fast and stable against renderer plumbing changes.

import { describe, expect, test } from "vitest";

import {
	ANCHOR_PADDING_WORLD,
	HOVER_PADDING_PX,
	computePickOverlay,
	type PickModeOptions,
} from "../src/map/pick-mode";
import {
	DEFAULT_POINT_RADIUS_PX,
	type PointPrim,
	type PrimitiveID,
} from "../src/map/world";

function makePoint(
	id: PrimitiveID,
	x: number,
	y: number,
	pointRadiusPx?: number,
): PointPrim {
	return {
		kind: "point",
		id,
		priority: 0,
		hitSlopPx: 0,
		x,
		y,
		style: pointRadiusPx === undefined ? {} : { pointRadiusPx },
	};
}

function makeOptions(
	overrides: Partial<PickModeOptions> = {},
): PickModeOptions {
	return {
		sourcePrimitiveId: 1,
		sourceX: 100,
		sourceY: 100,
		reachableIds: new Set([2, 3]),
		onPick: () => {},
		...overrides,
	};
}

describe("computePickOverlay", () => {
	const points = new Map<PrimitiveID, PointPrim>([
		[1, makePoint(1, 100, 100, 6)],
		[2, makePoint(2, 200, 100, 5)],
		[3, makePoint(3, 100, 200)],
		[4, makePoint(4, 300, 300, 4)],
	]);
	const allIds: PrimitiveID[] = [1, 2, 3, 4];

	test("anchor radius equals source pointRadiusPx + ANCHOR_PADDING_WORLD", () => {
		const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
		expect(spec.anchor.x).toBe(100);
		expect(spec.anchor.y).toBe(100);
		expect(spec.anchor.radius).toBe(6 + ANCHOR_PADDING_WORLD);
	});

	test("anchor radius falls back to default when source has no pointRadiusPx", () => {
		const sourceless = new Map(points);
		sourceless.set(1, makePoint(1, 100, 100));
		const spec = computePickOverlay(
			makeOptions(),
			null,
			null,
			sourceless,
			allIds,
		);
		expect(spec.anchor.radius).toBe(
			DEFAULT_POINT_RADIUS_PX + ANCHOR_PADDING_WORLD,
		);
	});

	test("dimmedIds covers everything outside source + reachable", () => {
		const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
		expect(Array.from(spec.dimmedIds).sort()).toEqual([4]);
	});

	test("dimmedIds is empty when every primitive is either source or reachable", () => {
		const spec = computePickOverlay(
			makeOptions({ reachableIds: new Set([2, 3, 4]) }),
			null,
			null,
			points,
			allIds,
		);
		expect(spec.dimmedIds.size).toBe(0);
	});

	test("line is null while the cursor is off-canvas", () => {
		const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
		expect(spec.line).toBeNull();
	});

	test("line endpoints follow the cursor when present (no-wrap)", () => {
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 250, y: 320 },
			null,
			points,
			allIds,
		);
		expect(spec.line).toEqual({
			x1: 100,
			y1: 100,
			x2: 250,
			y2: 320,
		});
	});

	test("torus line endpoint uses torusShortestDelta — short path matches direct cursor", () => {
		// Source at (100,100), cursor at (150,120). Wrap world 201×201
		// — the direct delta is already the shortest, so the endpoint
		// equals the cursor.
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 150, y: 120 },
			null,
			points,
			allIds,
			{ width: 201, height: 201 },
		);
		expect(spec.line).toEqual({ x1: 100, y1: 100, x2: 150, y2: 120 });
	});

	test("torus line endpoint wraps when the short path crosses the seam (x axis)", () => {
		// Issue #50 repro: source A3 near the left edge (x=1.44) of a
		// 201-wide world, cursor over a wrap copy of an A1-like planet
		// at world x ≈ -15 (the user is panned to the left side, seeing
		// the wrap copy that A1's canonical x=184 produces at x=184-201
		// = -17 in the screen-space the renderer paints in the -W tile).
		// torusShortestDelta(1.44, -15, 201) is -16.44 — the line endpoint
		// is the source + that delta, extending left past x=0.
		const spec = computePickOverlay(
			makeOptions({ sourceX: 1.44, sourceY: 146 }),
			{ x: -15, y: 137 },
			null,
			points,
			allIds,
			{ width: 201, height: 201 },
		);
		expect(spec.line).not.toBeNull();
		expect(spec.line!.x1).toBe(1.44);
		expect(spec.line!.y1).toBe(146);
		expect(spec.line!.x2).toBeCloseTo(-15);
		expect(spec.line!.y2).toBe(137);
	});

	test("torus line endpoint wraps the long-way cursor back through the seam (x axis)", () => {
		// Source at x=184 (canonical A1), cursor at x=200 (just inside
		// the [0, W) tile near the right edge). Direct delta is +16
		// (no wrap), but if the cursor sits in a wrap copy of an
		// A3-like planet at world x ≈ 202.44, the torus-shortest delta
		// from 184 is +18.44 and the endpoint extends past the seam.
		const spec = computePickOverlay(
			makeOptions({ sourceX: 184, sourceY: 137 }),
			{ x: 202.44, y: 146 },
			null,
			points,
			allIds,
			{ width: 201, height: 201 },
		);
		expect(spec.line!.x1).toBe(184);
		expect(spec.line!.x2).toBeCloseTo(202.44);
	});

	test("torus line endpoint wraps when the short path crosses the seam (y axis)", () => {
		// Symmetric coverage on the y axis — source near the bottom,
		// cursor pulled across the seam via the negative-y wrap.
		const spec = computePickOverlay(
			makeOptions({ sourceX: 50, sourceY: 3 }),
			{ x: 50, y: -10 },
			null,
			points,
			allIds,
			{ width: 100, height: 50 },
		);
		expect(spec.line!.y1).toBe(3);
		expect(spec.line!.y2).toBeCloseTo(-10);
		expect(spec.line!.x1).toBe(50);
		expect(spec.line!.x2).toBe(50);
	});

	test("torus mode preserves canonical anchor and hover-outline coords", () => {
		// The renderer paints the overlay into every torus copy with
		// its own offset, so the spec stays in canonical coords; the
		// torus argument must only affect the cursor line. This test
		// guards the contract for the future-careless reader.
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 250, y: 320 },
			2,
			points,
			allIds,
			{ width: 400, height: 400 },
		);
		expect(spec.anchor).toEqual({ x: 100, y: 100, radius: 6 + ANCHOR_PADDING_WORLD });
		expect(spec.hoverOutline).toEqual({
			x: 200,
			y: 100,
			radius: 5 + HOVER_PADDING_PX,
		});
	});

	test("hoverOutline is null when nothing is hovered", () => {
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 1, y: 1 },
			null,
			points,
			allIds,
		);
		expect(spec.hoverOutline).toBeNull();
	});

	test("hoverOutline is null when the hover targets a non-reachable primitive", () => {
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 1, y: 1 },
			4,
			points,
			allIds,
		);
		expect(spec.hoverOutline).toBeNull();
	});

	test("hoverOutline is null when the hover targets the source planet", () => {
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 1, y: 1 },
			1,
			points,
			allIds,
		);
		expect(spec.hoverOutline).toBeNull();
	});

	test("hoverOutline reflects the reachable target with HOVER_PADDING_PX", () => {
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 1, y: 1 },
			2,
			points,
			allIds,
		);
		expect(spec.hoverOutline).toEqual({
			x: 200,
			y: 100,
			radius: 5 + HOVER_PADDING_PX,
		});
	});

	test("hoverOutline radius falls back to default radius for default-style points", () => {
		const spec = computePickOverlay(
			makeOptions(),
			{ x: 1, y: 1 },
			3,
			points,
			allIds,
		);
		expect(spec.hoverOutline?.radius).toBe(
			DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_PX,
		);
	});
});
