// Hit-test pass over the world primitives.
//
// Algorithm: convert the cursor to world coordinates, then walk every
// primitive computing its squared distance to the cursor in world
// units. The threshold for a hit is (visualRadius + slopWorld)²
// where slopWorld = slopPx / camera.scale, so the on-screen click
// margin stays constant regardless of zoom. Candidates are sorted by
// (-priority, distSq, kindOrder, id) and the best is returned.
//
// In torus mode, distance is measured along the toroidal shortest
// path on each axis. In no-wrap mode, distance is plain Euclidean
// and a primitive does not get matched through wrap copies.

import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
import { minScaleNoWrap } from "./no-wrap";
import {
	DEFAULT_HIT_SLOP_PX,
	displayLineEndpoints,
	displayPointRadiusWorld,
	KIND_ORDER,
	type Camera,
	type CirclePrim,
	type LinePrim,
	type PointPrim,
	type Primitive,
	type PrimitiveID,
	type Viewport,
	type World,
	type WrapMode,
} from "./world";

export interface Hit {
	primitive: Primitive;
	distSq: number; // in world units squared
	/**
	 * insideDisc is `true` when the cursor sits *inside* a planet's
	 * visible disc (point primitive, distance ≤ `visibleRadius`, no
	 * slop required). F8-12 / #4 follow-up uses it to break tie
	 * against overlapping route shafts or battle X-crosses: a click
	 * inside the disc always picks the planet, even though those
	 * other primitives carry a higher base `priority` (so they still
	 * win when the cursor is "near the line, outside any planet").
	 */
	insideDisc: boolean;
}

// hitTest returns the best-matching primitive under the cursor, or
// null if no primitive matches within its hit slop.
//
// `hiddenIds` (optional) is consulted before every primitive — ids in
// the set are skipped entirely, so a click on the area they used to
// cover falls through to the next visible primitive. The renderer's
// Phase 29 hide-by-id facility threads its current set in here so
// the click / hover paths stay in lock-step with the visible scene.
export function hitTest(
	world: World,
	camera: Camera,
	viewport: Viewport,
	cursorPx: { x: number; y: number },
	mode: WrapMode,
	hiddenIds?: ReadonlySet<PrimitiveID>,
): Hit | null {
	const cursor = screenToWorld(cursorPx, camera, viewport);
	const candidates: Hit[] = [];
	// The non-linear planet radius (F8-12 / #31) is softened relative
	// to the "whole world fits" reference scale. Computing it here
	// once keeps the visible disc and the click zone in lock-step.
	const scaleRef = minScaleNoWrap(viewport, world);

	for (const p of world.primitives) {
		if (hiddenIds !== undefined && hiddenIds.has(p.id)) continue;
		const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
		const slopWorld = slopPx / camera.scale;
		let result: number | null;
		let insideDisc = false;
		if (p.kind === "point") {
			const visibleRadius = displayPointRadiusWorld(
				p.style,
				camera.scale,
				scaleRef,
			);
			result = matchPoint(
				p,
				cursor,
				visibleRadius,
				slopWorld,
				mode === "torus" ? world : null,
			);
			if (result !== null) {
				insideDisc = result <= visibleRadius * visibleRadius;
			}
		} else if (p.kind === "circle") {
			result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
		} else {
			result = matchLine(
				p,
				cursor,
				slopWorld,
				camera.scale,
				scaleRef,
				mode === "torus" ? world : null,
			);
		}
		if (result !== null) {
			candidates.push({ primitive: p, distSq: result, insideDisc });
		}
	}

	if (candidates.length === 0) return null;
	candidates.sort(compareHits);
	return candidates[0];
}

function compareHits(a: Hit, b: Hit): number {
	// F8-12 / #4 follow-up: a click that sits *inside* a planet disc
	// always picks the planet, even when a route shaft or a battle
	// X-cross with a higher `priority` overlaps it. The base priority
	// tie-break still rules every "near a line, outside any disc"
	// case so battle markers / cargo arrows remain clickable in the
	// gap between planets.
	if (a.insideDisc !== b.insideDisc) {
		return a.insideDisc ? -1 : 1;
	}
	if (a.primitive.priority !== b.primitive.priority) {
		return b.primitive.priority - a.primitive.priority;
	}
	if (a.distSq !== b.distSq) return a.distSq - b.distSq;
	const ka = KIND_ORDER[a.primitive.kind];
	const kb = KIND_ORDER[b.primitive.kind];
	if (ka !== kb) return ka - kb;
	return a.primitive.id - b.primitive.id;
}

// torusDelta returns (cursor - origin) measured along the toroidal
// shortest path when world is non-null, otherwise plain Euclidean.
function torusDelta(
	originX: number,
	originY: number,
	cursorX: number,
	cursorY: number,
	world: World | null,
): { dx: number; dy: number } {
	if (world === null) {
		return { dx: cursorX - originX, dy: cursorY - originY };
	}
	return {
		dx: torusShortestDelta(originX, cursorX, world.width),
		dy: torusShortestDelta(originY, cursorY, world.height),
	};
}

function matchPoint(
	p: PointPrim,
	cursor: { x: number; y: number },
	visibleRadius: number,
	slopWorld: number,
	world: World | null,
): number | null {
	const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
	const distSq = dx * dx + dy * dy;
	// `visibleRadius` already reflects whichever sizing rule the
	// PointPrim uses (pixel-space for `pointRadiusPx`, softened world
	// units for `pointRadiusWorld`). The hit zone is that disc plus
	// a small ergonomic slop on top.
	const r = visibleRadius + slopWorld;
	if (distSq <= r * r) return distSq;
	return null;
}

function matchCircle(
	p: CirclePrim,
	cursor: { x: number; y: number },
	slopWorld: number,
	world: World | null,
): number | null {
	const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
	const distSq = dx * dx + dy * dy;
	const isFilled = p.style.fillColor !== undefined && (p.style.fillAlpha ?? 1) > 0;
	if (isFilled) {
		const r = p.radius + slopWorld;
		if (distSq <= r * r) return distSq;
		return null;
	}
	// Stroke-only ring: cursor must be within slop of the ring.
	const dist = Math.sqrt(distSq);
	if (Math.abs(dist - p.radius) <= slopWorld) {
		const ringGap = dist - p.radius;
		return ringGap * ringGap;
	}
	return null;
}

function matchLine(
	p: LinePrim,
	cursor: { x: number; y: number },
	slopWorld: number,
	cameraScale: number,
	scaleRef: number,
	world: World | null,
): number | null {
	// In torus mode the canonical line representation goes from
	// (x1,y1) to (x1 + dx, y1 + dy) where (dx,dy) is the torus-
	// shortest delta from end1 to end2. The cursor's distance is
	// then the perpendicular distance to this canonical segment,
	// using the torus-shortest cursor-to-end1 delta as the basis.
	const ends = displayLineEndpoints(
		p.style,
		p.x1,
		p.y1,
		p.x2,
		p.y2,
		cameraScale,
		scaleRef,
	);
	if (world === null) {
		const distSq = distSqPointToSegment(
			cursor.x,
			cursor.y,
			ends.x1,
			ends.y1,
			ends.x2,
			ends.y2,
		);
		if (distSq <= slopWorld * slopWorld) return distSq;
		return null;
	}
	const segDx = torusShortestDelta(ends.x1, ends.x2, world.width);
	const segDy = torusShortestDelta(ends.y1, ends.y2, world.height);
	const cur = torusDelta(ends.x1, ends.y1, cursor.x, cursor.y, world);
	const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy);
	if (distSq <= slopWorld * slopWorld) return distSq;
	return null;
}
