/**
 * Unit tests for the offline mutation queue (Fase 2b.2).
 *
 * Uses fake-indexeddb (installed via vitest.setup.ts). A hand-rolled mock
 * Supabase client lets us control success/failure per op without any network.
 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncQueue, attachOnlineFlush, MAX_ATTEMPTS, type PendingOp } from './queue.js';

// In-memory Supabase-like client. Each call records the invocation and
// returns whatever the caller pushed into `responses` (FIFO).
type ClientCall = { table: string; kind: string; payload?: unknown; match?: unknown; patch?: unknown };

function makeMockClient(responses: Array<{ error: { message: string } | null }>) {
	const calls: ClientCall[] = [];
	const take = () => responses.shift() ?? { error: null };
	const client = {
		from: (table: string) => ({
			insert: (payload: unknown) => ({
				select: () => ({
					single: async () => {
						calls.push({ table, kind: 'insert', payload });
						return take();
					}
				})
			}),
			update: (patch: unknown) => ({
				match: async (m: Record<string, unknown>) => {
					calls.push({ table, kind: 'update', patch, match: m });
					return take();
				}
			}),
			delete: () => ({
				match: async (m: Record<string, unknown>) => {
					calls.push({ table, kind: 'delete', match: m });
					return take();
				}
			})
		})
	};
	return { client, calls };
}

async function clearIdb() {
	const { openDB } = await import('idb');
	const db = await openDB('colectivo-sync', 1, {
		upgrade(db) {
			if (!db.objectStoreNames.contains('pending_ops')) {
				db.createObjectStore('pending_ops', { keyPath: 'id' }).createIndex(
					'createdAt',
					'createdAt'
				);
			}
		}
	});
	await db.clear('pending_ops');
	db.close();
}

beforeEach(async () => {
	await clearIdb();
});

afterEach(async () => {
	await clearIdb();
});

describe('SyncQueue — pending_ops contract', () => {
	it('Q-01: enqueue writes the op to pending_ops BEFORE calling Supabase', async () => {
		let pendingDuringSend: PendingOp[] = [];
		// Mock that snapshots the queue during the Supabase call. Holds a
		// forward reference to `queue` so the handler can query it.
		let queueRef!: SyncQueue;
		const client = {
			from: () => ({
				insert: (_payload: unknown) => ({
					select: () => ({
						single: async () => {
							pendingDuringSend = await queueRef.pending();
							return { error: null };
						}
					})
				}),
				update: () => ({ match: async () => ({ error: null }) }),
				delete: () => ({ match: async () => ({ error: null }) })
			})
		};
		queueRef = new SyncQueue(client);
		await queueRef.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'Milk' }
		});

		// During the send, the op MUST have been persisted.
		expect(pendingDuringSend).toHaveLength(1);
		expect(pendingDuringSend[0].kind).toBe('insert');
		// And after success, the queue is empty.
		expect(await queueRef.pending()).toHaveLength(0);
	});

	it('Q-02: successful send removes the op from pending_ops', async () => {
		const { client } = makeMockClient([{ error: null }]);
		const queue = new SyncQueue(client);
		await queue.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'Bread' }
		});
		expect(await queue.pending()).toHaveLength(0);
	});

	it('Q-03: failed send keeps the op and increments attempts', async () => {
		// First call fails, second succeeds
		const { client } = makeMockClient([
			{ error: { message: 'network' } },
			{ error: null }
		]);
		const queue = new SyncQueue(client);

		await queue.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'Eggs' }
		});

		let pending = await queue.pending();
		expect(pending).toHaveLength(1);
		expect(pending[0].attempts).toBe(1);

		// Flush should retry and succeed this time
		const result = await queue.flush();
		expect(result.sent).toBe(1);
		expect(await queue.pending()).toHaveLength(0);
	});

	it('Q-04: ops are processed in insertion order', async () => {
		const { client, calls } = makeMockClient([
			{ error: null },
			{ error: null },
			{ error: null }
		]);
		const queue = new SyncQueue(client);

		await queue.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'A' }
		});
		await queue.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'B' }
		});
		await queue.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'C' }
		});

		expect(
			calls
				.filter((c) => c.kind === 'insert')
				.map((c) => (c.payload as { name: string }).name)
		).toEqual(['A', 'B', 'C']);
	});

	it('Q-05: ops are dropped after MAX_ATTEMPTS failures', async () => {
		const { client } = makeMockClient(
			Array.from({ length: MAX_ATTEMPTS + 2 }, () => ({ error: { message: 'nope' } }))
		);
		const queue = new SyncQueue(client);

		await queue.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'X' }
		});
		// enqueue made 1 attempt; flush repeatedly until dropped
		for (let i = 0; i < MAX_ATTEMPTS; i++) {
			await queue.flush();
		}
		expect(await queue.pending()).toHaveLength(0);
	});
});

describe('attachOnlineFlush — window online event triggers flush', () => {
	it('F-01: dispatching "online" calls flush() within a tick', async () => {
		const { client } = makeMockClient([{ error: null }]);
		const queue = new SyncQueue(client);
		const flushSpy = vi.spyOn(queue, 'flush');

		// Enqueue with a failing mock so the op stays queued
		await clearIdb();
		const failing = makeMockClient([{ error: { message: 'offline' } }]);
		const q2 = new SyncQueue(failing.client);
		await q2.enqueue({
			kind: 'insert',
			table: 'shopping_items',
			payload: { name: 'Q' }
		});
		expect(await q2.pending()).toHaveLength(1);

		// Attach and fire the event
		const detach = attachOnlineFlush(queue);
		window.dispatchEvent(new Event('online'));
		await new Promise((r) => setTimeout(r, 50));
		expect(flushSpy).toHaveBeenCalled();
		detach();
	});
});
