/**
 * Pan/zoom geometry for the Stammbaum canvas (#692).
 *
 * The Stammbaum renders zoom by deriving the SVG `viewBox` rather than applying
 * a CSS transform (see `StammbaumTree.svelte`). This module is the single source
 * of truth for the zoom bounds, the view-state shape, and every pure geometry
 * helper used by the gesture action, the URL serialiser, and the page. Keeping
 * the math here (and free of DOM access) makes it unit-testable in the node
 * project. See ADR-027 for why this is custom rather than a third-party library.
 */

/**
 * Zoom bounds. OQ-001 originally resolved the ceiling to 3.0, but because zoom
 * is normalised to the whole tree, z=3 still shows too much of a wide tree to be
 * legible on a phone — so the ceiling was raised to 10 (product-owner revision,
 * #692). SVG stays vector-crisp at any zoom, so a generous max is harmless.
 */
export const MIN_ZOOM = 0.25;
export const MAX_ZOOM = 10;
export const DEFAULT_ZOOM = 1;

/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */
export const LEGIBLE_ZOOM = 1;

/** Fixed zoom increment per keyboard `+`/`-` press and per control-button click (OQ-002). */
export const ZOOM_STEP_KB = 0.1;

/**
 * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre
 * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the
 * whole tree (fit-to-screen) because the base viewBox already encloses the
 * layout bounding box at z=1.
 */
export type PanZoomState = { x: number; y: number; z: number };

/** Fit-to-screen target — frames the whole tree at z=1 (US-PAN-004). */
export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };

/**
 * Landing zoom for a fresh visit (no URL state). Higher than fit so node tiles
 * and generation labels are legible on arrival; the fit-to-screen control
 * (DEFAULT_VIEW, z=1) zooms back out to the whole tree.
 */
export const INITIAL_ZOOM = 3;
export const INITIAL_VIEW: PanZoomState = { x: 0, y: 0, z: INITIAL_ZOOM };

/** Clamp a zoom factor into the supported range. */
export function clampZoom(z: number): number {
	return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
}

/** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */
function finiteOr(raw: string | null | undefined, fallback: number): number {
	if (raw == null) return fallback;
	const n = Number(raw);
	return Number.isFinite(n) ? n : fallback;
}

/**
 * Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003).
 *
 * Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`),
 * and absent values degrade to the default for that axis, and the zoom is
 * clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link
 * (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692).
 */
export function parsePanZoomParams(raw: {
	cx?: string | null;
	cy?: string | null;
	z?: string | null;
}): PanZoomState {
	return {
		x: finiteOr(raw.cx, DEFAULT_VIEW.x),
		y: finiteOr(raw.cy, DEFAULT_VIEW.y),
		z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM))
	};
}

/** Format a number with at most `dp` decimals, dropping trailing zeros. */
function round(n: number, dp: number): string {
	return String(Number(n.toFixed(dp)));
}

/**
 * Serialise a view state into URL query params (the inverse of
 * {@link parsePanZoomParams}). Pan is rounded to 2 decimals and zoom to 3 so
 * shared links stay readable (no `cx=457.8300882631206` float noise).
 */
export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } {
	return { cx: round(state.x, 2), cy: round(state.y, 2), z: round(state.z, 3) };
}

/**
 * Convert a pointer delta in CSS pixels into SVG user units, using the current
 * viewBox-to-element ratio per axis. This is the distance the pointer traversed
 * expressed in the tree's coordinate space; the gesture handler subtracts it
 * from the pan offset so the canvas follows the finger (US-PAN-001).
 */
export function screenDeltaToSvg(
	dxPx: number,
	dyPx: number,
	viewBoxW: number,
	viewBoxH: number,
	elPxW: number,
	elPxH: number
): { dx: number; dy: number } {
	return {
		dx: elPxW > 0 ? dxPx * (viewBoxW / elPxW) : 0,
		dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0
	};
}

/**
 * Zoom to `newZoom` while keeping a given anchor point fixed on screen
 * (pinch-centroid zoom — US-PAN-002 AC1 / US-PAN-003 AC1).
 *
 * `anchorX`/`anchorY` are the anchor point expressed as an offset, in SVG units,
 * from the base viewBox centre. Because the viewBox width scales as `1/z`, the
 * ratio of old-to-new width is exactly `z / newZoom` independent of the base
 * size, so the new pan offset that preserves the anchor's screen fraction is
 * `anchor - (anchor - pan) * (z / newZoom)`.
 */
export function zoomAtPoint(
	state: PanZoomState,
	newZoom: number,
	anchorX: number,
	anchorY: number
): PanZoomState {
	const z = clampZoom(newZoom);
	const ratio = state.z / z;
	return {
		x: anchorX - (anchorX - state.x) * ratio,
		y: anchorY - (anchorY - state.y) * ratio,
		z
	};
}

/** Assumed milliseconds per animation frame, used to scale inertia velocity. */
export const FRAME_MS = 16;
/** Per-frame velocity decay for pan inertia (OQ-004). */
export const INERTIA_DECAY = 0.92;
/** Inertia stops once the velocity (svg units per ms) drops below this. */
export const INERTIA_MIN_SPEED = 0.02;

/**
 * Pinch zoom around the gesture centroid (US-PAN-002/003). The new zoom is the
 * start zoom scaled by the finger-distance ratio (clamped); the anchor offset
 * keeps the centroid fixed via {@link zoomAtPoint}.
 */
export function pinchZoom(
	state: PanZoomState,
	startZoom: number,
	startDist: number,
	currentDist: number,
	anchorX: number,
	anchorY: number
): PanZoomState {
	const ratio = startDist > 0 ? currentDist / startDist : 1;
	return zoomAtPoint(state, clampZoom(startZoom * ratio), anchorX, anchorY);
}

/**
 * Advance the pan by one inertia frame: continue the release velocity (svg units
 * per ms) in the drag direction, scaled by the frame duration. Zoom is untouched.
 */
export function stepInertia(
	state: PanZoomState,
	velX: number,
	velY: number,
	frameMs: number = FRAME_MS
): PanZoomState {
	return { x: state.x - velX * frameMs, y: state.y - velY * frameMs, z: state.z };
}

/** Linearly interpolate between two view states (drives fit/recentre tweening). */
export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState {
	return {
		x: from.x + (to.x - from.x) * t,
		y: from.y + (to.y - from.y) * t,
		z: from.z + (to.z - from.z) * t
	};
}

/**
 * Clamp the pan offset so the canvas cannot be dragged off the edge (US-PAN-001
 * AC4 — no infinite scroll). The pannable range on each axis is half the
 * difference between the base frame and the (smaller) zoomed viewBox; when the
 * whole tree fits (z ≤ 1) the range collapses to zero, so the view stays centred.
 */
export function clampPan(state: PanZoomState, baseW: number, baseH: number): PanZoomState {
	const clampAxis = (pan: number, base: number) => {
		const limit = Math.max(0, (base - base / state.z) / 2);
		return Math.min(limit, Math.max(-limit, pan)) || 0; // normalise -0 → 0
	};
	return { x: clampAxis(state.x, baseW), y: clampAxis(state.y, baseH), z: state.z };
}

/**
 * The view whose viewBox top-left lands on the SVG point (`targetX`, `targetY`)
 * at zoom `z` — used to anchor a fresh visit to the tree's content corner.
 * Pass the content bounding-box top-left (not the padded frame corner) so the
 * first row sits near the top with no empty slack above it.
 */
export function cornerView(
	targetX: number,
	targetY: number,
	baseCentreX: number,
	baseCentreY: number,
	baseW: number,
	baseH: number,
	z: number
): PanZoomState {
	return {
		x: targetX - baseCentreX + baseW / z / 2,
		y: targetY - baseCentreY + baseH / z / 2,
		z
	};
}

/**
 * Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox
 * centre is `baseCentre + pan` independent of zoom, centring is a pure pan:
 * `pan = nodeCentre - baseCentre`. When `autoZoom` is set, a zoomed-out view is
 * snapped up to {@link LEGIBLE_ZOOM} so the focal node's text is readable
 * (OQ-005); an already-legible zoom is preserved.
 */
export function recentreOn(
	nodeCentre: { x: number; y: number },
	baseCentre: { x: number; y: number },
	state: PanZoomState,
	autoZoom: boolean
): PanZoomState {
	return {
		x: nodeCentre.x - baseCentre.x,
		y: nodeCentre.y - baseCentre.y,
		z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z
	};
}

/**
 * Recentre on a node but lift it above the viewBox centre by `biasFraction` of
 * the zoomed viewBox height, so on a phone the tapped anchor lands in the band
 * above the bottom sheet rather than behind it (#703 AC8). The bias is measured
 * against the auto-zoomed height ({@link recentreOn} may snap zoom up to
 * {@link LEGIBLE_ZOOM}), and a zero bias is exactly a legible recentre.
 */
export function recentreAbove(
	nodeCentre: { x: number; y: number },
	baseCentre: { x: number; y: number },
	state: PanZoomState,
	baseH: number,
	biasFraction: number
): PanZoomState {
	const centred = recentreOn(nodeCentre, baseCentre, state, true);
	return { ...centred, y: centred.y + biasFraction * (baseH / centred.z) };
}
