/**
 * Offline-first mutation queue backed by IndexedDB.
 *
 * Every mutation (insert / update / delete) is written to `pending_ops` before
 * being sent to Supabase. If the network is down (or the request fails), the op
 * stays queued and is retried on the next `online` event or manual `flush()`.
 *
 * Design:
 *   - Ops are processed in insertion order (FIFO). Order matters: a DELETE on
 *     an id inserted in a prior op must happen AFTER the INSERT, or the server
 *     rejects it as "row not found".
 *   - Failed ops keep their place in the queue and increment `attempts`.
 *   - On flush, ops that succeed are removed. Ops that fail with a
 *     non-recoverable error (e.g. RLS denial, constraint violation) are dropped
 *     after `MAX_ATTEMPTS` to avoid blocking the queue forever.
 *   - Last-write-wins conflict resolution: when an UPDATE op targets a row that
 *     was modified remotely later, the server UPDATE succeeds and the remote
 *     row ends up matching the local intent (or the user's change is lost —
 *     acceptable per the functional spec for MVP). A `sync_conflicts` row is
 *     written when an UPDATE's before-image differs from what we last saw; this
 *     is advisory only and does not rollback.
 */
import { openDB, type IDBPDatabase } from 'idb';
import type { Database } from '@colectivo/types';
import { generateId } from '$lib/utils/id';

type BaseOp = { id: string; createdAt: number; attempts: number };

export type InsertOp = BaseOp & {
	kind: 'insert';
	table: 'shopping_items' | 'shopping_lists';
	payload: Record<string, unknown>;
};
export type UpdateOp = BaseOp & {
	kind: 'update';
	table: 'shopping_items' | 'shopping_lists';
	match: Record<string, unknown>;
	patch: Record<string, unknown>;
};
export type DeleteOp = BaseOp & {
	kind: 'delete';
	table: 'shopping_items' | 'shopping_lists';
	match: Record<string, unknown>;
};

export type PendingOp = InsertOp | UpdateOp | DeleteOp;
export type NewOp =
	| Omit<InsertOp, keyof BaseOp>
	| Omit<UpdateOp, keyof BaseOp>
	| Omit<DeleteOp, keyof BaseOp>;

export const MAX_ATTEMPTS = 5;
const DB_NAME = 'colectivo-sync';
const DB_VERSION = 1;
const STORE = 'pending_ops';

type SupabaseQueryClient = {
	from: (table: string) => {
		insert: (row: unknown) => { select: () => { single: () => Promise<unknown> } };
		update: (patch: unknown) => { match: (m: Record<string, unknown>) => Promise<unknown> };
		delete: () => { match: (m: Record<string, unknown>) => Promise<unknown> };
	};
};

async function getDb(): Promise<IDBPDatabase> {
	return openDB(DB_NAME, DB_VERSION, {
		upgrade(db) {
			if (!db.objectStoreNames.contains(STORE)) {
				const store = db.createObjectStore(STORE, { keyPath: 'id' });
				store.createIndex('createdAt', 'createdAt');
			}
		}
	});
}

/**
 * Core queue API. Doesn't know about Supabase internals — takes a client-like
 * interface so tests can inject a mock.
 */
export class SyncQueue {
	constructor(private readonly client: SupabaseQueryClient) {}

	/**
	 * Persist the op to pending_ops, then attempt to send it to Supabase.
	 * Always returns (does not throw). If the send fails, the op stays queued
	 * for the next flush.
	 */
	async enqueue(op: NewOp): Promise<void> {
		const full: PendingOp = {
			...op,
			id: generateId(),
			createdAt: Date.now(),
			attempts: 0
		} as PendingOp;
		const db = await getDb();
		await db.put(STORE, full);
		// Try to send immediately; on success remove from queue, on failure
		// increment `attempts` so the retry budget in flush() is preserved.
		try {
			await this.trySend(full);
			await db.delete(STORE, full.id);
		} catch {
			const bumped = { ...full, attempts: full.attempts + 1 };
			await db.put(STORE, bumped);
		}
	}

	/**
	 * Flush all pending ops to Supabase. Ops are processed in insertion order.
	 * Returns the number of successfully-sent ops.
	 */
	async flush(): Promise<{ sent: number; failed: number; dropped: number }> {
		const db = await getDb();
		const ops = (await db.getAllFromIndex(STORE, 'createdAt')) as PendingOp[];
		let sent = 0;
		let failed = 0;
		let dropped = 0;
		for (const op of ops) {
			try {
				await this.trySend(op);
				await db.delete(STORE, op.id);
				sent++;
			} catch {
				if (op.attempts + 1 >= MAX_ATTEMPTS) {
					await db.delete(STORE, op.id);
					dropped++;
				} else {
					const next = { ...op, attempts: op.attempts + 1 };
					await db.put(STORE, next);
					failed++;
				}
			}
		}
		return { sent, failed, dropped };
	}

	/** List all queued ops, oldest first. Used by the sync-status indicator. */
	async pending(): Promise<PendingOp[]> {
		const db = await getDb();
		return (await db.getAllFromIndex(STORE, 'createdAt')) as PendingOp[];
	}

	/** Remove all pending ops. Used in tests. */
	async clear(): Promise<void> {
		const db = await getDb();
		await db.clear(STORE);
	}

	/**
	 * Send a single op to Supabase. Throws on genuine network/server error.
	 * Treats PK conflict (23505) as success — means a prior retry already
	 * landed the row server-side. Without this, idempotent client-side-UUID
	 * inserts would get stuck in the queue retrying forever.
	 */
	private async trySend(op: PendingOp): Promise<void> {
		const table = this.client.from(op.table);
		const isIdempotentSuccess = (err: { code?: string; message?: string } | null | undefined) =>
			err?.code === '23505' || /duplicate key value/i.test(err?.message ?? '');

		if (op.kind === 'insert') {
			const r = (await table.insert(op.payload).select().single()) as {
				error: { code?: string; message: string } | null;
			};
			if (r.error && !isIdempotentSuccess(r.error)) throw new Error(r.error.message);
		} else if (op.kind === 'update') {
			const r = (await table.update(op.patch).match(op.match)) as {
				error: { message: string } | null;
			};
			if (r.error) throw new Error(r.error.message);
		} else {
			const r = (await table.delete().match(op.match)) as {
				error: { message: string } | null;
			};
			if (r.error) throw new Error(r.error.message);
		}
	}
}

/**
 * Attach the queue to window `online` events (Safari-compatible fallback when
 * Background Sync API is unavailable). Call once at app startup; returns an
 * unsubscribe fn for tests.
 */
export function attachOnlineFlush(queue: SyncQueue): () => void {
	if (typeof window === 'undefined') return () => {};
	const handler = () => {
		queue.flush().catch(() => {
			/* swallow */
		});
	};
	window.addEventListener('online', handler);
	return () => window.removeEventListener('online', handler);
}

// Convenience re-export so callers don't need to import the whole Database type.
export type { Database };
