import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { createOcrJob } from './useOcrJob.svelte';

afterEach(() => {
	vi.restoreAllMocks();
});

function makeFetch(handlers: Record<string, () => Response | Promise<Response>>) {
	return vi.fn(async (url: RequestInfo | URL) => {
		const u = url.toString();
		for (const [match, fn] of Object.entries(handlers)) {
			if (u.includes(match)) return fn();
		}
		return new Response('not found', { status: 404 });
	});
}

describe('createOcrJob — initial state', () => {
	it('starts not running with empty progress and error', () => {
		const job = createOcrJob({ documentId: () => 'doc-1' });
		expect(job.running).toBe(false);
		expect(job.progressMessage).toBe('');
		expect(job.errorMessage).toBe('');
		expect(job.skippedPages).toBe(0);
	});
});

describe('createOcrJob.triggerOcr', () => {
	it('sets running=true and starts polling on 200 with jobId', async () => {
		const fetchImpl = makeFetch({
			'/ocr': () =>
				new Response(JSON.stringify({ jobId: 'job-7' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				}),
			'/ocr/jobs/job-7': () =>
				new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'WORKING' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('KURRENT', false);

		expect(job.running).toBe(true);
		expect(job.errorMessage).toBe('');
		expect(fetchImpl).toHaveBeenCalledWith(
			'/api/documents/doc-1/ocr',
			expect.objectContaining({ method: 'POST' })
		);

		job.destroy();
	});

	it('sets errorMessage with generic message on 500', async () => {
		const fetchImpl = makeFetch({
			'/ocr': () => new Response('boom', { status: 500 })
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('KURRENT', false);

		expect(job.running).toBe(false);
		expect(job.errorMessage).toBeTruthy();
		job.destroy();
	});

	it('extracts backend error code from 4xx body', async () => {
		const fetchImpl = makeFetch({
			'/ocr': () =>
				new Response(JSON.stringify({ code: 'OCR_DISABLED' }), {
					status: 400,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('KURRENT', false);

		expect(job.running).toBe(false);
		expect(job.errorMessage).toBeTruthy();
		// errorMessage is localized — at minimum non-empty
		job.destroy();
	});

	it('handles non-JSON 4xx body gracefully', async () => {
		const fetchImpl = makeFetch({
			'/ocr': () => new Response('not json', { status: 400 })
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('KURRENT', false);

		expect(job.running).toBe(false);
		expect(job.errorMessage).toBeTruthy();
		job.destroy();
	});

	it('handles fetch network error', async () => {
		const fetchImpl = vi.fn(async () => {
			throw new Error('network down');
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('KURRENT', false);

		expect(job.running).toBe(false);
		expect(job.errorMessage).toBeTruthy();
		job.destroy();
	});

	it('passes useExistingAnnotations=true in the request body', async () => {
		const fetchImpl = makeFetch({
			'/ocr': () =>
				new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				}),
			'/jobs/job-1': () =>
				new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('LATIN', true);

		const triggerCall = fetchImpl.mock.calls.find(
			(c) => c[0].toString().includes('/ocr') && !c[0].toString().includes('jobs')
		);
		expect(triggerCall).toBeDefined();
		const init = (triggerCall as unknown as [string, RequestInit])[1];
		const body = JSON.parse(init.body as string);
		expect(body).toEqual({ scriptType: 'LATIN', useExistingAnnotations: true });

		job.destroy();
	});
});

describe('createOcrJob — CSRF on mutating requests', () => {
	afterEach(() => {
		document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
	});

	it('injects X-XSRF-TOKEN on the trigger POST', async () => {
		document.cookie = 'XSRF-TOKEN=tok-123; path=/';
		const fetchImpl = makeFetch({
			'/ocr': () =>
				new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.triggerOcr('KURRENT', false);
		job.destroy();

		const postCall = fetchImpl.mock.calls.find(
			([url, init]) =>
				url.toString().includes('/ocr') && (init as RequestInit | undefined)?.method === 'POST'
		);
		expect(postCall).toBeDefined();
		const headers = new Headers((postCall![1] as RequestInit).headers);
		expect(headers.get('X-XSRF-TOKEN')).toBe('tok-123');
	});
});

describe('createOcrJob.checkStatus', () => {
	it('starts polling when status is RUNNING with a jobId', async () => {
		const fetchImpl = makeFetch({
			'ocr-status': () =>
				new Response(JSON.stringify({ status: 'RUNNING', jobId: 'job-9' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				}),
			'/ocr/jobs/job-9': () =>
				new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.checkStatus();

		expect(job.running).toBe(true);
		job.destroy();
	});

	it('starts polling when status is PENDING with a jobId', async () => {
		const fetchImpl = makeFetch({
			'ocr-status': () =>
				new Response(JSON.stringify({ status: 'PENDING', jobId: 'job-9' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.checkStatus();

		expect(job.running).toBe(true);
		job.destroy();
	});

	it('does not start polling when status is DONE', async () => {
		const fetchImpl = makeFetch({
			'ocr-status': () =>
				new Response(JSON.stringify({ status: 'DONE', jobId: null }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.checkStatus();

		expect(job.running).toBe(false);
		job.destroy();
	});

	it('does not start polling when no jobId present', async () => {
		const fetchImpl = makeFetch({
			'ocr-status': () =>
				new Response(JSON.stringify({ status: 'RUNNING', jobId: null }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.checkStatus();

		expect(job.running).toBe(false);
		job.destroy();
	});

	it('is a no-op when documentId() returns empty', async () => {
		const fetchImpl = vi.fn();
		const job = createOcrJob({ documentId: () => '', fetchImpl });
		await job.checkStatus();
		expect(fetchImpl).not.toHaveBeenCalled();
		job.destroy();
	});

	it('handles 5xx ocr-status gracefully', async () => {
		const fetchImpl = makeFetch({
			'ocr-status': () => new Response('boom', { status: 500 })
		});
		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.checkStatus();
		expect(job.running).toBe(false);
		job.destroy();
	});

	it('handles network error gracefully', async () => {
		const fetchImpl = vi.fn(async () => {
			throw new Error('network');
		});
		const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
		await job.checkStatus();
		expect(job.running).toBe(false);
		job.destroy();
	});
});

describe('createOcrJob — polling loop (fake timers)', () => {
	beforeEach(() => {
		vi.useFakeTimers();
	});
	afterEach(() => {
		vi.useRealTimers();
	});

	// const wait used to live here; replaced by vi.advanceTimersByTimeAsync below.

	it('updates progressMessage from translated job code', async () => {
		const fetchImpl = makeFetch({
			'/api/documents/doc-1/ocr': () =>
				new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				}),
			'/api/ocr/jobs/job-1': () =>
				new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'PREPARING' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			pollIntervalMs: 20
		});
		await job.triggerOcr('KURRENT', false);
		await vi.advanceTimersByTimeAsync(50);

		expect(job.progressMessage).not.toBe('');
		job.destroy();
	});

	it('captures skippedPages from job result', async () => {
		const fetchImpl = makeFetch({
			'/api/documents/doc-1/ocr': () =>
				new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				}),
			'/api/ocr/jobs/job-1': () =>
				new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'SKIPPED:5' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				})
		});

		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			pollIntervalMs: 20
		});
		await job.triggerOcr('KURRENT', false);
		await vi.advanceTimersByTimeAsync(50);

		expect(job.skippedPages).toBeGreaterThanOrEqual(0);
		job.destroy();
	});

	it('calls onJobFinished("DONE") when polling sees status=DONE', async () => {
		const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
			const u = url.toString();
			if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
				return new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				});
			}
			return new Response(JSON.stringify({ status: 'DONE', progressMessage: '' }), {
				status: 200,
				headers: { 'Content-Type': 'application/json' }
			});
		});

		const onJobFinished = vi.fn().mockResolvedValue(undefined);
		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			onJobFinished,
			pollIntervalMs: 20,
			resetDelayMs: 10
		});
		await job.triggerOcr('KURRENT', false);
		await vi.advanceTimersByTimeAsync(100);

		expect(onJobFinished).toHaveBeenCalledWith('DONE');
		job.destroy();
	});

	it('sets errorMessage and calls onJobFinished("FAILED") when polling sees status=FAILED', async () => {
		const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
			const u = url.toString();
			if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
				return new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				});
			}
			return new Response(JSON.stringify({ status: 'FAILED', progressMessage: '' }), {
				status: 200,
				headers: { 'Content-Type': 'application/json' }
			});
		});

		const onJobFinished = vi.fn().mockResolvedValue(undefined);
		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			onJobFinished,
			pollIntervalMs: 20,
			resetDelayMs: 10
		});
		await job.triggerOcr('KURRENT', false);
		await vi.advanceTimersByTimeAsync(100);

		expect(onJobFinished).toHaveBeenCalledWith('FAILED');
		expect(job.errorMessage).toBeTruthy();
		job.destroy();
	});

	it('ignores non-OK polling responses', async () => {
		const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
			const u = url.toString();
			if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
				return new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				});
			}
			return new Response('boom', { status: 500 });
		});

		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			pollIntervalMs: 20
		});
		await job.triggerOcr('KURRENT', false);
		await vi.advanceTimersByTimeAsync(50);

		expect(job.running).toBe(true);
		job.destroy();
	});

	it('swallows polling fetch network errors', async () => {
		let triggered = false;
		const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
			const u = url.toString();
			if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
				triggered = true;
				return new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				});
			}
			if (triggered) throw new Error('network');
			return new Response('', { status: 200 });
		});

		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			pollIntervalMs: 20
		});
		await job.triggerOcr('KURRENT', false);
		await vi.advanceTimersByTimeAsync(50);

		expect(job.running).toBe(true);
		job.destroy();
	});
});

describe('createOcrJob.destroy', () => {
	it('returns undefined and is safe to call without an active job', () => {
		const job = createOcrJob({ documentId: () => 'doc-1' });
		// destroy() is a void function — call it directly. If it threw, the test would fail.
		expect(job.destroy()).toBeUndefined();
	});

	it('stops the polling interval when called mid-poll', async () => {
		vi.useFakeTimers();
		const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
			const u = url.toString();
			if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
				return new Response(JSON.stringify({ jobId: 'job-1' }), {
					status: 200,
					headers: { 'Content-Type': 'application/json' }
				});
			}
			return new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
				status: 200,
				headers: { 'Content-Type': 'application/json' }
			});
		});

		const job = createOcrJob({
			documentId: () => 'doc-1',
			fetchImpl,
			pollIntervalMs: 20
		});
		await job.triggerOcr('KURRENT', false);
		job.destroy();

		const callsAtDestroy = fetchImpl.mock.calls.length;
		await vi.advanceTimersByTimeAsync(100);
		// No additional fetch calls after destroy
		expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy);
		vi.useRealTimers();
	});
});
