import type { Action } from 'svelte/action';
import {
	clampPan,
	clampZoom,
	screenDeltaToSvg,
	zoomAtPoint,
	pinchZoom,
	stepInertia,
	FRAME_MS,
	INERTIA_DECAY,
	INERTIA_MIN_SPEED,
	ZOOM_STEP_KB,
	type PanZoomState
} from '$lib/person/genealogy/panZoom';

export interface PanZoomGesturesParams {
	/** The authoritative view state (re-synced at the start of each gesture). */
	state: PanZoomState;
	/** Base viewBox geometry at z=1 (includes the gutter) — see StammbaumTree. */
	baseW: number;
	baseH: number;
	baseCentreX: number;
	baseCentreY: number;
	/** When true, inertia is skipped and motion stops on release (REQ-PAN-005). */
	reducedMotion: boolean;
	onPanZoom: (state: PanZoomState) => void;
	/** Fired on the first pointer of a gesture (used to dismiss the affordance). */
	onGestureStart?: () => void;
}

/** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
const DRAG_THRESHOLD_PX = 4;

/**
 * Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue:
 * all geometry is delegated to the pure helpers in `panZoom.ts`. One-finger
 * drag and left-button drag pan; two-finger pinch and Ctrl+wheel zoom around the
 * gesture centroid; plain wheel pans. Pan is edge-clamped and a real drag
 * suppresses the trailing node click. Inertia decays after release unless the
 * user prefers reduced motion.
 */
export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (node, params) => {
	let p = params;
	let current = p.state;

	const pointers = new Map<number, { x: number; y: number }>();
	let dragging = false;
	let moved = false;
	let lastX = 0;
	let lastY = 0;
	let lastTime = 0;
	let velX = 0;
	let velY = 0;
	let pinchStartDist = 0;
	let pinchStartZoom = 1;
	let inertiaFrame = 0;
	let suppressClick = false;

	const emit = (next: PanZoomState) => {
		current = clampPan(next, p.baseW, p.baseH);
		p.onPanZoom(current);
	};

	const viewBoxW = () => p.baseW / current.z;
	const viewBoxH = () => p.baseH / current.z;

	// Convert a client point to its anchor offset from the base viewBox centre.
	const anchorOffset = (clientX: number, clientY: number) => {
		const rect = node.getBoundingClientRect();
		const w = viewBoxW();
		const h = viewBoxH();
		const fracX = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
		const fracY = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
		const svgX = p.baseCentreX + current.x - w / 2 + fracX * w;
		const svgY = p.baseCentreY + current.y - h / 2 + fracY * h;
		return { x: svgX - p.baseCentreX, y: svgY - p.baseCentreY };
	};

	const distance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
		Math.hypot(a.x - b.x, a.y - b.y);

	const cancelInertia = () => {
		if (inertiaFrame) cancelAnimationFrame(inertiaFrame);
		inertiaFrame = 0;
	};

	const runInertia = () => {
		if (p.reducedMotion) return;
		if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
		const step = () => {
			const before = current;
			emit(stepInertia(current, velX, velY, FRAME_MS));
			velX *= INERTIA_DECAY;
			velY *= INERTIA_DECAY;
			const stalled = current.x === before.x && current.y === before.y;
			if (!stalled && Math.hypot(velX, velY) >= INERTIA_MIN_SPEED) {
				inertiaFrame = requestAnimationFrame(step);
			} else {
				inertiaFrame = 0;
			}
		};
		inertiaFrame = requestAnimationFrame(step);
	};

	const onPointerDown = (e: PointerEvent) => {
		cancelInertia();
		// NB: do NOT capture the pointer here — capturing on pointerdown makes the
		// browser dispatch the trailing `click` at this element instead of the
		// node under the pointer, which silently breaks node selection (a tap must
		// still reach the node's onclick). Capture is deferred to the first move
		// that crosses the drag threshold (see onPointerMove).
		pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
		p.onGestureStart?.();

		if (pointers.size === 1) {
			current = p.state; // re-sync from the authoritative state
			dragging = true;
			moved = false;
			lastX = e.clientX;
			lastY = e.clientY;
			lastTime = performance.now();
			velX = 0;
			velY = 0;
		} else if (pointers.size === 2) {
			const [a, b] = [...pointers.values()];
			pinchStartDist = distance(a, b) || 1;
			pinchStartZoom = current.z;
			dragging = false;
		}
	};

	const onPointerMove = (e: PointerEvent) => {
		if (!pointers.has(e.pointerId)) return;
		pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });

		if (pointers.size >= 2) {
			const [a, b] = [...pointers.values()];
			const dist = distance(a, b) || 1;
			const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
			const anchor = anchorOffset(centroid.x, centroid.y);
			emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, anchor.x, anchor.y));
			moved = true;
			return;
		}

		if (!dragging) return;
		const dxPx = e.clientX - lastX;
		const dyPx = e.clientY - lastY;
		if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) {
			// A real drag has started — now capture so we keep receiving move/up
			// even if the pointer leaves the canvas. (Deferred from pointerdown so
			// taps still select nodes.)
			moved = true;
			try {
				node.setPointerCapture(e.pointerId);
			} catch {
				/* pointer not capturable (e.g. synthetic event) — drag still works */
			}
		}

		const { dx, dy } = screenDeltaToSvg(
			dxPx,
			dyPx,
			viewBoxW(),
			viewBoxH(),
			node.clientWidth,
			node.clientHeight
		);
		const now = performance.now();
		const dt = Math.max(1, now - lastTime);
		velX = dx / dt;
		velY = dy / dt;
		lastTime = now;
		lastX = e.clientX;
		lastY = e.clientY;
		emit({ ...current, x: current.x - dx, y: current.y - dy });
	};

	const onPointerUp = (e: PointerEvent) => {
		pointers.delete(e.pointerId);
		try {
			if (node.hasPointerCapture?.(e.pointerId)) node.releasePointerCapture(e.pointerId);
		} catch {
			/* nothing to release */
		}

		if (pointers.size === 0) {
			if (dragging && moved) {
				suppressClick = true;
				runInertia();
			}
			dragging = false;
		} else if (pointers.size === 1) {
			// Dropped from pinch to a single pointer — resume a clean drag.
			const [only] = [...pointers.entries()];
			dragging = true;
			moved = true;
			lastX = only[1].x;
			lastY = only[1].y;
			lastTime = performance.now();
		}
	};

	// A drag ends with a synthetic click on the node underneath; swallow it so a
	// pan does not also select a person (US-PAN-001).
	const onClickCapture = (e: MouseEvent) => {
		if (suppressClick) {
			e.stopPropagation();
			e.preventDefault();
			suppressClick = false;
		}
	};

	const onWheel = (e: WheelEvent) => {
		e.preventDefault();
		if (e.ctrlKey) {
			const factor = e.deltaY < 0 ? 1 + ZOOM_STEP_KB : 1 / (1 + ZOOM_STEP_KB);
			const anchor = anchorOffset(e.clientX, e.clientY);
			emit(zoomAtPoint(current, clampZoom(current.z * factor), anchor.x, anchor.y));
			return;
		}
		const { dx, dy } = screenDeltaToSvg(
			e.deltaX,
			e.deltaY,
			viewBoxW(),
			viewBoxH(),
			node.clientWidth,
			node.clientHeight
		);
		emit({ ...current, x: current.x + dx, y: current.y + dy });
	};

	node.style.touchAction = 'none';
	node.addEventListener('pointerdown', onPointerDown);
	node.addEventListener('pointermove', onPointerMove);
	node.addEventListener('pointerup', onPointerUp);
	node.addEventListener('pointercancel', onPointerUp);
	node.addEventListener('click', onClickCapture, true);
	node.addEventListener('wheel', onWheel, { passive: false });

	return {
		update(next: PanZoomGesturesParams) {
			p = next;
			if (!dragging && pointers.size === 0 && !inertiaFrame) current = next.state;
		},
		destroy() {
			cancelInertia();
			node.removeEventListener('pointerdown', onPointerDown);
			node.removeEventListener('pointermove', onPointerMove);
			node.removeEventListener('pointerup', onPointerUp);
			node.removeEventListener('pointercancel', onPointerUp);
			node.removeEventListener('click', onClickCapture, true);
			node.removeEventListener('wheel', onWheel);
		}
	};
};
