import { describe, it, expect } from 'vitest';
import {
	clampZoom,
	parsePanZoomParams,
	serializePanZoomParams,
	screenDeltaToSvg,
	zoomAtPoint,
	pinchZoom,
	stepInertia,
	recentreOn,
	recentreAbove,
	clampPan,
	cornerView,
	lerpView,
	DEFAULT_VIEW,
	DEFAULT_ZOOM,
	LEGIBLE_ZOOM,
	MIN_ZOOM,
	MAX_ZOOM
} from './panZoom';

describe('clampZoom', () => {
	it('returns the value unchanged when within range', () => {
		expect(clampZoom(1)).toBe(1);
		expect(clampZoom(0.5)).toBe(0.5);
		expect(clampZoom(2.75)).toBe(2.75);
	});

	it('clamps below MIN_ZOOM up to MIN_ZOOM', () => {
		expect(clampZoom(0.1)).toBe(MIN_ZOOM);
		expect(clampZoom(0)).toBe(MIN_ZOOM);
		expect(clampZoom(-5)).toBe(MIN_ZOOM);
	});

	it('clamps above MAX_ZOOM down to MAX_ZOOM', () => {
		expect(clampZoom(99)).toBe(MAX_ZOOM);
		expect(clampZoom(MAX_ZOOM + 0.0001)).toBe(MAX_ZOOM);
	});

	it('exposes the resolved zoom bounds', () => {
		expect(MIN_ZOOM).toBe(0.25);
		expect(MAX_ZOOM).toBe(10);
	});
});

describe('parsePanZoomParams', () => {
	it('parses well-formed cx/cy/z params', () => {
		expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({
			x: 120,
			y: -40,
			z: 1.5
		});
	});

	it('falls back to DEFAULT_VIEW when params are absent', () => {
		expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW);
		expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM });
	});

	it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => {
		expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM);
		expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM);
		expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW);
		expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0);
	});

	it('clamps an out-of-range zoom into the supported bounds', () => {
		expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM);
		expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM);
		expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM);
	});
});

describe('serializePanZoomParams', () => {
	it('produces string cx/cy/z keys', () => {
		expect(serializePanZoomParams({ x: 120, y: -40, z: 1.5 })).toEqual({
			cx: '120',
			cy: '-40',
			z: '1.5'
		});
	});

	it('round-trips through parsePanZoomParams', () => {
		const state = { x: 87.5, y: -12.25, z: 2.4 };
		expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
	});

	it('rounds noisy floats so shared URLs stay readable', () => {
		expect(serializePanZoomParams({ x: 457.8300882631206, y: 0, z: 1.2000000000000002 })).toEqual({
			cx: '457.83',
			cy: '0',
			z: '1.2'
		});
	});
});

describe('screenDeltaToSvg', () => {
	it('scales a pixel delta by the viewBox-to-element ratio per axis', () => {
		// viewBox is 2x the element in width, 2x in height → 1px == 2 SVG units.
		expect(screenDeltaToSvg(10, 5, 1000, 800, 500, 400)).toEqual({ dx: 20, dy: 10 });
	});

	it('is identity when the viewBox matches the element pixel size', () => {
		expect(screenDeltaToSvg(7, -3, 600, 600, 600, 600)).toEqual({ dx: 7, dy: -3 });
	});

	it('returns zero when the element has no measured size', () => {
		expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 });
	});
});

describe('zoomAtPoint', () => {
	// The anchor is expressed as an offset (in SVG units) from the base viewBox
	// centre. The fraction of the anchor across the viewBox must not change.
	const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => {
		const baseW = 1000;
		const w = baseW / state.z;
		const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre
		return centreOffset / w + 0.5;
	};

	it('keeps the canvas centre fixed when the anchor is the centre', () => {
		const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0);
		expect(next).toEqual({ x: 0, y: 0, z: 2 });
	});

	it('keeps an off-centre anchor at the same screen position across a zoom-in', () => {
		const before = { x: 0, y: 0, z: 1 };
		const after = zoomAtPoint(before, 2, 100, 50);
		expect(after.z).toBe(2);
		expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10);
	});

	it('clamps the target zoom and makes no move when already at the bound', () => {
		const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200);
		expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM });
	});
});

describe('pinchZoom', () => {
	it('scales zoom by the finger-distance ratio around the centroid', () => {
		// Fingers spread 100→200 → 2× zoom; centroid at canvas centre → no pan.
		expect(pinchZoom({ x: 0, y: 0, z: 1 }, 1, 100, 200, 0, 0)).toEqual({ x: 0, y: 0, z: 2 });
	});

	it('zooms out when fingers pinch together', () => {
		expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 200, 100, 0, 0).z).toBe(1);
	});

	it('clamps the scaled zoom into bounds', () => {
		expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 100, 1000, 0, 0).z).toBe(MAX_ZOOM);
	});

	it('treats a zero start distance as no zoom change', () => {
		expect(pinchZoom({ x: 5, y: 5, z: 1.5 }, 1.5, 0, 200, 0, 0).z).toBe(1.5);
	});
});

describe('stepInertia', () => {
	it('advances the pan by velocity × frame duration in the drag direction', () => {
		expect(stepInertia({ x: 100, y: 50, z: 1 }, 0.5, 0.25, 16)).toEqual({ x: 92, y: 46, z: 1 });
	});

	it('leaves zoom untouched', () => {
		expect(stepInertia({ x: 0, y: 0, z: 2.5 }, 1, 1, 16).z).toBe(2.5);
	});
});

describe('recentreOn', () => {
	const node = { x: 300, y: 200 };
	const base = { x: 100, y: 100 };

	it('pans so the node sits at the viewBox centre, keeping the current zoom', () => {
		expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 });
	});

	it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => {
		const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true);
		expect(next.z).toBe(LEGIBLE_ZOOM);
		expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 });
	});

	it('does not reduce an already-legible zoom when auto-zooming', () => {
		expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2);
	});

	it('leaves zoom untouched when auto-zoom is off', () => {
		expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
	});
});

describe('recentreAbove', () => {
	const node = { x: 300, y: 200 };
	const base = { x: 100, y: 100 };
	const baseH = 800;

	it('matches the auto-zooming recentre when the bias is zero', () => {
		expect(recentreAbove(node, base, { x: 0, y: 0, z: 2 }, baseH, 0)).toEqual(
			recentreOn(node, base, { x: 0, y: 0, z: 2 }, true)
		);
	});

	it('lifts the node up by a fraction of the zoomed viewBox height (clears the bottom sheet)', () => {
		// recentreOn centres the node (pan.y = 100) at z=2; the bias adds
		// 0.3 * (baseH / z) = 0.3 * 400 = 120, so the node sits ~20% from the top.
		const next = recentreAbove(node, base, { x: 0, y: 0, z: 2 }, baseH, 0.3);
		expect(next.x).toBe(200);
		expect(next.z).toBe(2);
		expect(next.y).toBeCloseTo(100 + 0.3 * (baseH / 2), 6);
	});

	it('measures the bias against the auto-zoomed height when starting zoomed out', () => {
		// z=0.4 auto-zooms up to LEGIBLE_ZOOM (1); the bias then uses baseH / 1.
		const next = recentreAbove(node, base, { x: 0, y: 0, z: 0.4 }, baseH, 0.3);
		expect(next.z).toBe(LEGIBLE_ZOOM);
		expect(next.y).toBeCloseTo(100 + 0.3 * baseH, 6);
	});
});

describe('clampPan', () => {
	// Base frame is 1000 x 800.
	it('forbids panning when the whole tree fits (z <= 1)', () => {
		expect(clampPan({ x: 200, y: -100, z: 1 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 1 });
		expect(clampPan({ x: 50, y: 50, z: 0.5 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 0.5 });
	});

	it('allows panning up to the edge when zoomed in (no infinite scroll)', () => {
		// At z=2 the viewBox is 500 wide → limit = (1000 - 500) / 2 = 250.
		expect(clampPan({ x: 1000, y: 0, z: 2 }, 1000, 800).x).toBe(250);
		expect(clampPan({ x: -1000, y: 0, z: 2 }, 1000, 800).x).toBe(-250);
		// Vertical limit at z=2: (800 - 400) / 2 = 200.
		expect(clampPan({ x: 0, y: 999, z: 2 }, 1000, 800).y).toBe(200);
	});

	it('leaves an in-range pan untouched', () => {
		expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 });
	});
});

describe('cornerView', () => {
	// Frame 0..1000 × 0..800, centre (500, 400).
	it('puts the viewBox top-left on the target SVG point', () => {
		// Target = content top-left at (100, 80), z=2 → viewBox is 500×400.
		expect(cornerView(100, 80, 500, 400, 1000, 800, 2)).toEqual({ x: -150, y: -120, z: 2 });
	});

	it('reduces to the frame corner when the target is the frame top-left', () => {
		// Target = frame top-left (0, 0) → most-negative (corner) pan.
		const v = cornerView(0, 0, 500, 400, 1000, 800, 3);
		expect(clampPan(v, 1000, 800)).toEqual(v); // on the clamp boundary
	});
});

describe('lerpView', () => {
	const from = { x: 0, y: 0, z: 1 };
	const to = { x: 100, y: -40, z: 2 };

	it('returns the start at t=0 and the end at t=1', () => {
		expect(lerpView(from, to, 0)).toEqual(from);
		expect(lerpView(from, to, 1)).toEqual(to);
	});

	it('interpolates each axis linearly at t=0.5', () => {
		expect(lerpView(from, to, 0.5)).toEqual({ x: 50, y: -20, z: 1.5 });
	});
});
