import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync } from 'svelte';

const { createTimelineDrag } = await import('./useTimelineDrag.svelte');

type EmitCall = { start: number; end: number; includeZoom: boolean };

function makeOpts(overrides: Partial<Parameters<typeof createTimelineDrag>[0]> = {}) {
	const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label
	const calls: EmitCall[] = [];
	const opts = {
		indexFromClientX: vi.fn(() => 0),
		labelAt: (i: number) => labels[i],
		isYearLabel: (l: string) => l.length === 4,
		emit: (start: number, end: number, includeZoom: boolean) => {
			calls.push({ start, end, includeZoom });
		},
		...overrides
	};
	return { opts, calls };
}

function pointerDownEvent(button = 0): PointerEvent {
	return { button } as unknown as PointerEvent;
}

describe('createTimelineDrag', () => {
	beforeEach(() => {
		// Reset listeners attached to the JSDOM document between tests
		document.removeEventListener('pointermove', () => undefined);
	});
	afterEach(() => {
		vi.restoreAllMocks();
	});

	it('starts with no drag in progress and null low/high indices', () => {
		const { opts } = makeOpts();
		const drag = createTimelineDrag(opts);
		expect(drag.isDragging).toBe(false);
		expect(drag.lowIndex).toBeNull();
		expect(drag.highIndex).toBeNull();
	});

	it('pointerDown on a primary button enters drag state at that index', () => {
		const { opts } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 1);
		flushSync();
		expect(drag.isDragging).toBe(true);
		expect(drag.lowIndex).toBe(1);
		expect(drag.highIndex).toBe(1);
	});

	it('pointerDown on a non-primary button is ignored', () => {
		const { opts } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(2), 1);
		flushSync();
		expect(drag.isDragging).toBe(false);
	});

	it('pointerEnter during drag widens the range high boundary', () => {
		const { opts } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 0);
		drag.pointerEnter(2);
		flushSync();
		expect(drag.lowIndex).toBe(0);
		expect(drag.highIndex).toBe(2);
	});

	it('pointerEnter outside drag is a no-op', () => {
		const { opts } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.pointerEnter(2);
		flushSync();
		expect(drag.isDragging).toBe(false);
		expect(drag.lowIndex).toBeNull();
	});

	it('click on a month bar emits filter only (no zoom)', () => {
		const { opts, calls } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.click(0);
		expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
	});

	it('click on a year bar emits filter + zoom atomically', () => {
		const { opts, calls } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.click(3); // labels[3] = '1915' (year label)
		expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]);
	});

	it('range drag commits emit with zoom in ascending order', async () => {
		const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) });
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 0);
		document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 }));
		document.dispatchEvent(new PointerEvent('pointerup'));
		flushSync();
		expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]);
		expect(drag.isDragging).toBe(false);
	});

	it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => {
		const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 2);
		document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
		document.dispatchEvent(new PointerEvent('pointerup'));
		flushSync();
		// emit receives raw start/end — orchestrator's emit() handles ordering
		expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]);
	});

	it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => {
		const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 0);
		document.dispatchEvent(new PointerEvent('pointerup'));
		flushSync();
		expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
	});

	it('pointercancel resets state without emitting', () => {
		const { opts, calls } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 1);
		document.dispatchEvent(new PointerEvent('pointercancel'));
		flushSync();
		expect(calls).toEqual([]);
		expect(drag.isDragging).toBe(false);
	});

	it('click after pointerup is suppressed (no double emit)', async () => {
		const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 0);
		document.dispatchEvent(new PointerEvent('pointerup'));
		flushSync();
		drag.click(0); // synthesized click after pointerup
		expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click
	});

	it('cleanup removes document pointer listeners', () => {
		const removeSpy = vi.spyOn(document, 'removeEventListener');
		const { opts } = makeOpts();
		const drag = createTimelineDrag(opts);
		drag.pointerDown(pointerDownEvent(0), 0);
		drag.cleanup();
		const removed = removeSpy.mock.calls.map((c) => c[0]);
		expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']));
	});
});
