import { describe, it, expect } from 'vitest';
import { createStore } from 'jotai';
import {
	zoomAtom,
	panAtom,
	viewportRectAtom,
	screenToWorldAtom,
	worldToScreenAtom,
	setZoomAtom,
	resetViewportAtom,
	fitToBoundsAtom,
	centerOnNodeAtom,
	ZOOM_TRANSITION_THRESHOLD,
	ZOOM_EXIT_THRESHOLD,
	zoomFocusNodeIdAtom,
	zoomTransitionProgressAtom,
	isZoomTransitioningAtom,
} from '../core/viewport-store';
import Graph from 'graphology';
import type { GraphNodeAttributes, GraphEdgeAttributes } from '../core/types';
import { graphAtom, graphOptions, currentGraphIdAtom } from '../core/graph-store';
import { addNodeToLocalGraphAtom } from '../core/graph-mutations';

function freshStore() {
	const store = createStore();
	store.set(graphAtom, new Graph<GraphNodeAttributes, GraphEdgeAttributes>(graphOptions));
	store.set(currentGraphIdAtom, 'test-graph');
	return store;
}
import { addNodesToSelectionAtom } from '../core/selection-store';
import type { DBGraphNode } from '../core/types';

function makeDbNode(id: string, x: number, y: number, width = 200, height = 100): DBGraphNode {
	return {
		id,
		graph_id: 'test-graph',
		label: `Node ${id}`,
		node_type: 'default',
		configuration: null,
		ui_properties: { x, y, width, height, size: 15, zIndex: 0 },
		data: null,
		created_at: '2024-01-01T00:00:00Z',
		updated_at: '2024-01-01T00:00:00Z',
	};
}

function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect {
	return {
		x, y, width, height,
		top: y,
		left: x,
		right: x + width,
		bottom: y + height,
		toJSON: () => ({}),
	};
}

describe('viewport-store', () => {
	// --- Core Atoms ---

	describe('initial state', () => {
		it('zoom starts at 1', () => {
			const store = freshStore();
			expect(store.get(zoomAtom)).toBe(1);
		});

		it('pan starts at (0,0)', () => {
			const store = freshStore();
			expect(store.get(panAtom)).toEqual({ x: 0, y: 0 });
		});

		it('viewportRect starts null', () => {
			const store = freshStore();
			expect(store.get(viewportRectAtom)).toBeNull();
		});
	});

	// --- Set Zoom ---

	describe('setZoomAtom', () => {
		it('sets zoom level', () => {
			const store = freshStore();
			store.set(setZoomAtom, { zoom: 2 });
			expect(store.get(zoomAtom)).toBe(2);
		});

		it('clamps zoom to min 0.1', () => {
			const store = freshStore();
			store.set(setZoomAtom, { zoom: 0.01 });
			expect(store.get(zoomAtom)).toBe(0.1);
		});

		it('clamps zoom to max 5', () => {
			const store = freshStore();
			store.set(setZoomAtom, { zoom: 10 });
			expect(store.get(zoomAtom)).toBe(5);
		});

		it('zooms toward center point when provided', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 800, 600));
			store.set(panAtom, { x: 0, y: 0 });
			store.set(zoomAtom, 1);

			// Zoom to 2x centered at (400, 300)
			store.set(setZoomAtom, { zoom: 2, centerX: 400, centerY: 300 });

			expect(store.get(zoomAtom)).toBe(2);
			const pan = store.get(panAtom);
			// Pan should adjust to keep world point under cursor stationary
			expect(pan.x).toBe(-400);
			expect(pan.y).toBe(-300);
		});
	});

	// --- Pan ---

	describe('panAtom', () => {
		it('can be set directly', () => {
			const store = freshStore();
			store.set(panAtom, { x: 100, y: -50 });
			expect(store.get(panAtom)).toEqual({ x: 100, y: -50 });
		});
	});

	// --- Reset ---

	describe('resetViewportAtom', () => {
		it('resets zoom and pan', () => {
			const store = freshStore();
			store.set(zoomAtom, 3);
			store.set(panAtom, { x: 500, y: -200 });

			store.set(resetViewportAtom);

			expect(store.get(zoomAtom)).toBe(1);
			expect(store.get(panAtom)).toEqual({ x: 0, y: 0 });
		});
	});

	// --- Coordinate Conversion ---

	describe('screenToWorldAtom', () => {
		it('converts with no viewport (identity)', () => {
			const store = freshStore();
			const convert = store.get(screenToWorldAtom);
			expect(convert(100, 200)).toEqual({ x: 100, y: 200 });
		});

		it('converts with zoom and pan', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 800, 600));
			store.set(panAtom, { x: 100, y: 50 });
			store.set(zoomAtom, 2);

			const convert = store.get(screenToWorldAtom);
			// worldX = (screenX - left - panX) / zoom = (200 - 0 - 100) / 2 = 50
			// worldY = (screenY - top - panY) / zoom = (100 - 0 - 50) / 2 = 25
			expect(convert(200, 100)).toEqual({ x: 50, y: 25 });
		});

		it('accounts for viewport offset', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(50, 30, 800, 600));
			store.set(panAtom, { x: 0, y: 0 });
			store.set(zoomAtom, 1);

			const convert = store.get(screenToWorldAtom);
			// worldX = (150 - 50 - 0) / 1 = 100
			expect(convert(150, 130)).toEqual({ x: 100, y: 100 });
		});
	});

	describe('worldToScreenAtom', () => {
		it('converts with no viewport (identity)', () => {
			const store = freshStore();
			const convert = store.get(worldToScreenAtom);
			expect(convert(100, 200)).toEqual({ x: 100, y: 200 });
		});

		it('converts with zoom and pan', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 800, 600));
			store.set(panAtom, { x: 100, y: 50 });
			store.set(zoomAtom, 2);

			const convert = store.get(worldToScreenAtom);
			// screenX = worldX * zoom + panX + left = 50 * 2 + 100 + 0 = 200
			// screenY = worldY * zoom + panY + top = 25 * 2 + 50 + 0 = 100
			expect(convert(50, 25)).toEqual({ x: 200, y: 100 });
		});
	});

	describe('screenToWorld ↔ worldToScreen roundtrip', () => {
		it('round-trips correctly', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(10, 20, 1000, 800));
			store.set(panAtom, { x: -150, y: 80 });
			store.set(zoomAtom, 1.5);

			const toWorld = store.get(screenToWorldAtom);
			const toScreen = store.get(worldToScreenAtom);

			const world = toWorld(500, 400);
			const back = toScreen(world.x, world.y);
			expect(back.x).toBeCloseTo(500, 5);
			expect(back.y).toBeCloseTo(400, 5);
		});
	});

	// --- Fit to Bounds ---

	describe('fitToBoundsAtom', () => {
		it('does nothing without viewport rect', () => {
			const store = freshStore();
			store.set(addNodeToLocalGraphAtom, makeDbNode('n1', 0, 0));
			const oldZoom = store.get(zoomAtom);
			store.set(fitToBoundsAtom, { mode: 'graph' });
			expect(store.get(zoomAtom)).toBe(oldZoom);
		});

		it('fits graph bounds into viewport', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 1000, 800));
			store.set(addNodeToLocalGraphAtom, makeDbNode('n1', 0, 0, 200, 100));
			store.set(addNodeToLocalGraphAtom, makeDbNode('n2', 500, 400, 200, 100));

			store.set(fitToBoundsAtom, { mode: 'graph', padding: 0 });

			// Should zoom to fit 700x500 content into 1000x800
			const zoom = store.get(zoomAtom);
			expect(zoom).toBeGreaterThan(0);
			expect(zoom).toBeLessThanOrEqual(2);
		});

		it('fits selection bounds', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 1000, 800));
			store.set(addNodeToLocalGraphAtom, makeDbNode('n1', 0, 0, 200, 100));
			store.set(addNodeToLocalGraphAtom, makeDbNode('n2', 1000, 1000, 200, 100));
			store.set(addNodeToLocalGraphAtom, makeDbNode('n3', 100, 100, 200, 100));

			store.set(addNodesToSelectionAtom, ['n1', 'n3']);
			store.set(fitToBoundsAtom, { mode: 'selection', padding: 0 });

			// Zoom should be based on selection (n1+n3), not full graph (which includes n2 far away)
			const zoom = store.get(zoomAtom);
			// Selection bounds are roughly 300x200, viewport is 1000x800 → zoom should be relatively high
			expect(zoom).toBeGreaterThan(1);
		});
	});

	// --- Center on Node ---

	describe('centerOnNodeAtom', () => {
		it('centers viewport on a node', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 800, 600));
			store.set(addNodeToLocalGraphAtom, makeDbNode('n1', 500, 300, 200, 100));

			store.set(centerOnNodeAtom, 'n1');

			const pan = store.get(panAtom);
			// Center of node = 500+100=600, 300+50=350
			// Pan should place this at viewport center (400, 300)
			// pan.x = 400 - 600*zoom, pan.y = 300 - 350*zoom (zoom=1)
			expect(pan.x).toBe(-200);
			expect(pan.y).toBe(-50);
		});

		it('does nothing for non-existent node', () => {
			const store = freshStore();
			store.set(viewportRectAtom, makeDOMRect(0, 0, 800, 600));
			const oldPan = store.get(panAtom);

			store.set(centerOnNodeAtom, 'nonexistent');
			expect(store.get(panAtom)).toEqual(oldPan);
		});
	});

	// --- Zoom Transition State ---

	describe('zoom transition state', () => {
		it('has correct threshold constants', () => {
			expect(ZOOM_TRANSITION_THRESHOLD).toBe(3.5);
			expect(ZOOM_EXIT_THRESHOLD).toBe(2.0);
		});

		it('isZoomTransitioning when progress between 0 and 1', () => {
			const store = freshStore();
			store.set(zoomTransitionProgressAtom, 0);
			expect(store.get(isZoomTransitioningAtom)).toBe(false);

			store.set(zoomTransitionProgressAtom, 0.5);
			expect(store.get(isZoomTransitioningAtom)).toBe(true);

			store.set(zoomTransitionProgressAtom, 1);
			expect(store.get(isZoomTransitioningAtom)).toBe(false);
		});

		it('zoomFocusNodeId starts null', () => {
			const store = freshStore();
			expect(store.get(zoomFocusNodeIdAtom)).toBeNull();
		});
	});
});
