// Data model for the map renderer.
//
// World coordinates are TypeScript numbers (float64). The world is a
// rectangle [0, W) × [0, H). When wrap mode is 'torus', the world
// behaves toroidally — primitives near the right edge are visible at
// the left edge once the camera scrolls past, etc. When wrap mode is
// 'no-wrap', the world is a bounded plane and the camera is clamped
// at its edges.
//
// The algorithm specification for hit-test, torus wrap, and no-wrap
// camera behaviour lives in ui/docs/renderer.md. See that document
// before changing the contract of the types in this file.

export type PrimitiveID = number;

export type WrapMode = "torus" | "no-wrap";

// Style describes the visual appearance of a primitive. Any field may
// be omitted; missing fields fall back to the active theme defaults.
//
// `strokeWidthPx` / `pointRadiusPx` are honest screen-pixel sizes
// since F8-12 (#28): the renderer divides them by the current camera
// scale before drawing, and rebuilds the affected `Graphics` whenever
// the camera zooms. This keeps thin lines crisp and small markers
// readable across the whole zoom range — the camera-relative
// thickening that the old contract promised but never delivered is
// gone.
//
// `pointRadiusBasePx` is the opposite intent: a planet's known
// `size` produces a base on-screen pixel radius at the "whole world
// fits" reference zoom, and the renderer grows it sub-linearly with
// the camera scale through `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31).
// When `pointRadiusBasePx` is set on a `PointPrim`, `pointRadiusPx`
// is ignored.
export interface Style {
	fillColor?: number; // 0xRRGGBB
	fillAlpha?: number; // 0..1
	strokeColor?: number; // 0xRRGGBB
	strokeAlpha?: number; // 0..1
	strokeWidthPx?: number; // screen pixels at any zoom
	pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point')
	pointRadiusBasePx?: number; // screen pixels at scaleRef, softened by PLANET_SIZE_ZOOM_ALPHA
	// strokeDashPx — when set on a `LinePrim`, the line is rendered as
	// a dashed pattern whose dash and gap are both this length. When
	// unset (or zero), the stroke is solid. Interpreted in world-unit
	// space — the dash spacing scales with the camera. Phase 19 uses
	// this for the IncomingGroup trajectory line; ignored on point
	// and circle primitives.
	strokeDashPx?: number;
	// softLengthAnchor — when set on a `LinePrim`, the renderer treats
	// the world-coord endpoints as the line length "at the reference
	// scale" and grows / shrinks them with `PLANET_SIZE_ZOOM_ALPHA`
	// the same way planet discs do. `'center'` scales both endpoints
	// around the segment midpoint (used by battle X-crosses anchored
	// on the planet centre); `'start'` keeps `(x1, y1)` fixed and
	// only scales `(x2, y2)` along the original direction (used by
	// cargo-route arrowhead wings anchored at the destination).
	softLengthAnchor?: "center" | "start";
}

// PrimitiveBase carries the fields shared by every primitive kind.
//
// priority is used for deterministic ordering during hit-test: higher
// priority wins ties. hitSlopPx is an optional per-primitive override
// of the kind default, in screen pixels.
export interface PrimitiveBase {
	id: PrimitiveID;
	priority: number;
	style: Style;
	hitSlopPx: number; // 0 = use kind default
}

export interface PointPrim extends PrimitiveBase {
	kind: "point";
	x: number;
	y: number;
}

export interface CirclePrim extends PrimitiveBase {
	kind: "circle";
	x: number;
	y: number;
	radius: number; // world units
}

export interface LinePrim extends PrimitiveBase {
	kind: "line";
	x1: number;
	y1: number;
	x2: number;
	y2: number;
}

export type Primitive = PointPrim | CirclePrim | LinePrim;

export type PrimitiveKind = Primitive["kind"];

// Default hit slop in screen pixels per primitive kind. Added on top
// of the visible footprint of each primitive — for points, the
// effective hit radius is `pointRadiusPx + slopPx`. Chosen for touch
// ergonomics; per-primitive `hitSlopPx` overrides the default.
export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
	point: 4,
	circle: 6,
	line: 6,
};

// Default world-unit radius drawn for a `PointPrim` when its
// `style.pointRadiusPx` is unset. Shared between the renderer
// (`render.ts.drawPoint`) and the hit-test
// (`hit-test.ts.matchPoint`) so the click target always covers the
// visible disc.
export const DEFAULT_POINT_RADIUS_PX = 3;

// kindOrder is the deterministic tie-break order used during hit-test
// when two primitives match a cursor at identical priority and
// distance. Smaller value wins.
export const KIND_ORDER: Record<PrimitiveKind, number> = {
	point: 0,
	line: 1,
	circle: 2,
};

// Camera describes the world point at the centre of the viewport and
// the scale (pixels per world unit). Pan/zoom mutate this struct;
// `pixi-viewport` keeps its own internal state and we mirror it here
// for hit-test and for tests that read camera state directly.
export interface Camera {
	centerX: number;
	centerY: number;
	scale: number;
}

export interface Viewport {
	widthPx: number;
	heightPx: number;
}

// World is the immutable container of primitives plus the toroidal
// dimensions. The renderer reindexes nothing — the brute-force
// hit-test walks all primitives on every pointer event, which is
// adequate for the ~1000-primitive Phase 9 budget.
export class World {
	readonly width: number;
	readonly height: number;
	readonly primitives: Primitive[];

	constructor(width: number, height: number, primitives: Primitive[] = []) {
		if (!(width > 0) || !(height > 0)) {
			throw new Error(`World: width and height must be positive, got ${width}×${height}`);
		}
		this.width = width;
		this.height = height;
		this.primitives = primitives;
	}
}

// Theme is the renderer's colour palette. It carries both the generic
// fallbacks used when a primitive's `style` omits a colour and the
// semantic colours every primitive builder paints with (planets, ship
// groups, cargo routes, battle / bombing markers, reach + selection
// rings, pending-Send tracks, and the pick-mode overlay). Two concrete
// palettes are shipped — `DARK_THEME` and `LIGHT_THEME` — and the map
// view selects between them from the resolved app theme
// (`$lib/theme/theme.svelte.ts`), so the canvas follows the user's
// light / dark choice like the rest of the chrome.
//
// Only colours live here: per-primitive alphas, widths, and radii are
// emphasis / geometry, not theme, and stay as constants in the builder
// modules. The light palette mirrors the dark one role-for-role but
// darkens / saturates each hue so it reads against a light background;
// the incoming-group, battle, and bombing accents stay deliberately
// vivid in both palettes.
export interface Theme {
	// Canvas background and the visibility-fog veil drawn over
	// unscanned hyperspace.
	background: number;
	fog: number;
	// Generic fallbacks for primitives whose `style` omits a colour.
	pointFill: number;
	circleStroke: number;
	lineStroke: number;
	// Planet glyphs, one colour per `ReportPlanet.kind`.
	planetLocal: number;
	planetOther: number;
	planetUninhabited: number;
	planetUnidentified: number;
	// Ship groups. The in-space track reuses `shipLocal` and the
	// incoming trajectory line reuses `shipIncoming`.
	shipLocal: number;
	shipOther: number;
	shipIncoming: number;
	shipUnidentified: number;
	// Cargo-route arrows, one colour per load type.
	routeCol: number;
	routeCap: number;
	routeMat: number;
	routeEmp: number;
	// Battle X-crosses and the bombing accent (damaged vs wiped). The
	// bombing accent is now drawn as the planet's outline rather than a
	// separate ring (F8-12 / issue #55, п.30).
	battleMarker: number;
	bombingDamaged: number;
	bombingWiped: number;
	// Reach rings, the selection accent (planet outline + label frame),
	// and pending-Send tracks. `selectionRing` is kept around for the
	// soon-to-be-removed `selection-ring.ts` and the test that locks
	// the colour; both lines disappear once the label-driven selection
	// lands.
	reachCircle: number;
	selectionRing: number;
	selectionAccent: number;
	pendingSend: number;
	// Planet label colours. `labelText` paints the primary line
	// (planet name when the toggle is on), `labelMuted` paints the
	// `#N` companion line. The inverse pair fills the rounded frame
	// drawn around the selected planet's label (background = the
	// selection accent, text = the canvas background colour).
	labelText: number;
	labelMuted: number;
	labelInverseText: number;
	labelInverseBackground: number;
	// Pick-mode overlay: the anchor / cursor-line / hover highlight
	// colour and the multiply tint applied to non-reachable primitives.
	pickHighlight: number;
	pickDimTint: number;
}

/**
 * PLANET_SIZE_ZOOM_ALPHA is the exponent that softens the on-screen
 * growth of known-size planets with the camera scale (F8-12 / п.31).
 * `α = 1` keeps the historical linear-with-zoom behaviour; `α = 0`
 * would make planets fully zoom-invariant. 0.33 (cube-root soft scaling
 * relative to `scale_ref`) is the owner-approved starting point — it
 * gives a noticeable, but moderated, growth as the user zooms in. The
 * constant lives next to the themes so the tuning knob is in one
 * obvious place.
 */
export const PLANET_SIZE_ZOOM_ALPHA = 0.33;

/**
 * displayPointRadiusWorld returns the world-space radius the renderer
 * should draw a `PointPrim` with at the current camera scale. When
 * `style.pointRadiusBasePx` is set (known-size planets), the radius
 * is the base pixel size at `scaleRef`, grown by
 * `(scale / scaleRef)^α` and converted back into world units —
 * `α = PLANET_SIZE_ZOOM_ALPHA`. At `scale = scaleRef` the visible
 * pixel size equals the base; a 10× zoom-in only grows it ~2.15×.
 * Otherwise the radius collapses to `pointRadiusPx / cameraScale` so
 * the on-screen disc stays the same pixel size regardless of zoom.
 *
 * Used by both the renderer (`render.ts:drawPoint`) and the hit-test
 * (`hit-test.ts:matchPoint`) so the visible disc and the click zone
 * always agree.
 */
export function displayPointRadiusWorld(
	style: Style,
	cameraScale: number,
	scaleRef: number,
): number {
	if (cameraScale <= 0) {
		return style.pointRadiusBasePx ?? style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
	}
	if (style.pointRadiusBasePx !== undefined) {
		const refScale = scaleRef > 0 ? scaleRef : cameraScale;
		const screenPx =
			style.pointRadiusBasePx *
			Math.pow(cameraScale / refScale, PLANET_SIZE_ZOOM_ALPHA);
		return screenPx / cameraScale;
	}
	const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
	return px / cameraScale;
}

/**
 * displayStrokeWidthWorld converts `style.strokeWidthPx` (a screen-pixel
 * thickness, F8-12 / #28) into the world-space width the renderer
 * passes to `g.stroke({...})`. The renderer redraws strokes on every
 * `viewport.zoomed` so the on-screen thickness stays constant.
 */
export function displayStrokeWidthWorld(
	style: Style,
	cameraScale: number,
): number {
	const px = style.strokeWidthPx ?? 1;
	if (cameraScale <= 0) return px;
	return px / cameraScale;
}

/**
 * softLengthFactor returns the multiplier that scales a line's
 * length when `style.softLengthAnchor` is set. The factor matches
 * the planet-radius softening rule: at `scale = scaleRef` it equals
 * `1` (the recorded geometry is the reference length); zooming in
 * shrinks the world-space length so the on-screen length grows by
 * `(scale / scaleRef)^α`. `displayLineEndpoints` is the convenience
 * wrapper that applies it to a line's `(x1, y1)–(x2, y2)` pair
 * given the configured anchor.
 */
export function softLengthFactor(
	cameraScale: number,
	scaleRef: number,
): number {
	if (cameraScale <= 0 || scaleRef <= 0) return 1;
	return Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1);
}

/**
 * displayLineEndpoints returns the world-space endpoints the
 * renderer should draw a `LinePrim` between, honouring
 * `style.softLengthAnchor` if set. Used by both the renderer and
 * the hit-test so the click zone always matches the visible stroke.
 */
export function displayLineEndpoints(
	style: Style,
	x1: number,
	y1: number,
	x2: number,
	y2: number,
	cameraScale: number,
	scaleRef: number,
): { x1: number; y1: number; x2: number; y2: number } {
	if (style.softLengthAnchor === undefined) {
		return { x1, y1, x2, y2 };
	}
	const factor = softLengthFactor(cameraScale, scaleRef);
	if (factor === 1) return { x1, y1, x2, y2 };
	if (style.softLengthAnchor === "start") {
		return {
			x1,
			y1,
			x2: x1 + (x2 - x1) * factor,
			y2: y1 + (y2 - y1) * factor,
		};
	}
	const cx = (x1 + x2) / 2;
	const cy = (y1 + y2) / 2;
	return {
		x1: cx + (x1 - cx) * factor,
		y1: cy + (y1 - cy) * factor,
		x2: cx + (x2 - cx) * factor,
		y2: cy + (y2 - cy) * factor,
	};
}

export const DARK_THEME: Theme = {
	background: 0x0a0e1a,
	fog: 0x12162a,
	pointFill: 0xe8eaf6,
	circleStroke: 0x4fc3f7,
	lineStroke: 0xa5d6a7,
	planetLocal: 0x6dd2ff,
	planetOther: 0xff8a65,
	planetUninhabited: 0xb0bec5,
	planetUnidentified: 0x546e7a,
	shipLocal: 0xfff176,
	shipOther: 0xff6f40,
	shipIncoming: 0xff5252,
	shipUnidentified: 0x9aa3a8,
	routeCol: 0x4fc3f7,
	routeCap: 0xffb74d,
	routeMat: 0x81c784,
	routeEmp: 0x90a4ae,
	battleMarker: 0xffd400,
	bombingDamaged: 0xffd400,
	bombingWiped: 0xff3030,
	reachCircle: 0x6d8cff,
	selectionRing: 0x6d8cff,
	selectionAccent: 0x6d8cff,
	pendingSend: 0x66bb6a,
	labelText: 0xc7d2e0,
	labelMuted: 0x90a4ae,
	labelInverseText: 0x0a0e1a,
	labelInverseBackground: 0x6d8cff,
	pickHighlight: 0xffe082,
	pickDimTint: 0x303841,
};

// LIGHT_THEME mirrors DARK_THEME role-for-role. The background matches
// the app's light shell background (`--color-bg` in `tokens.css`) so
// the canvas blends into the surrounding chrome instead of reading as a
// dark rectangle; the fog is a faint darkening over the lighter base.
// Hues are darkened / saturated relative to the dark palette so small
// glyphs and thin strokes stay legible on a light surface, while the
// incoming (red), battle (amber), and bombing (amber / red) accents are
// kept vivid. Values are a first pass meant to be refined during the
// owner's F8 manual-QA loop.
export const LIGHT_THEME: Theme = {
	background: 0xf3f5fb,
	fog: 0xe2e7f1,
	pointFill: 0x1a2138,
	circleStroke: 0x1565c0,
	lineStroke: 0x2e7d32,
	planetLocal: 0x1565c0,
	planetOther: 0xe64a19,
	planetUninhabited: 0x78909c,
	planetUnidentified: 0x90a4ae,
	shipLocal: 0xc79100,
	shipOther: 0xd84315,
	shipIncoming: 0xd50000,
	shipUnidentified: 0x607d8b,
	routeCol: 0x0288d1,
	routeCap: 0xef6c00,
	routeMat: 0x2e7d32,
	routeEmp: 0x607d8b,
	battleMarker: 0xf57f17,
	bombingDamaged: 0xf57f17,
	bombingWiped: 0xc62828,
	reachCircle: 0x3949ab,
	selectionRing: 0x3949ab,
	selectionAccent: 0x3949ab,
	pendingSend: 0x388e3c,
	labelText: 0x263240,
	labelMuted: 0x5a6d8a,
	labelInverseText: 0xf3f5fb,
	labelInverseBackground: 0x3949ab,
	pickHighlight: 0xef6c00,
	pickDimTint: 0xaeb6c4,
};
