/**
 * 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>;
	/**
	 * Fase 9.3 — the row state the client believed when it queued the
	 * UPDATE, restricted to the fields being patched. Used to detect
	 * last-write-wins conflicts at send time. Optional so legacy ops
	 * (and tests not exercising the conflict path) keep working.
	 */
	preImage?: 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>;

/**
 * Maps an op's `table` to the entity_type value we persist in
 * sync_conflicts. Keeping a small lookup avoids hard-coding the rule
 * "strip the trailing s" which would break for irregular plurals.
 */
const ENTITY_TYPE_FOR_TABLE: Record<UpdateOp['table'], string> = {
	shopping_items: 'shopping_item',
	shopping_lists: 'shopping_list'
};

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> };
		/**
		 * Fase 9.3 — used to read the remote pre-image before sending an
		 * UPDATE so we can detect a last-write-wins conflict. Optional
		 * because legacy callers that never enqueue a `preImage` op also
		 * never reach this branch.
		 */
		select?: (cols?: string) => {
			match: (m: Record<string, unknown>) => {
				maybeSingle: () => Promise<{ data: Record<string, unknown> | null; error: { message: string } | null }>;
			};
		};
	};
};

/**
 * Optional context the queue uses for last-write-wins conflict logging.
 * Without `currentUserId` the queue skips conflict detection entirely —
 * RLS on sync_conflicts requires `user_id = auth.uid()` so anonymous
 * writes would fail anyway.
 */
export type QueueContext = {
	currentUserId?: string | null;
	collectiveId?: string | null;
};

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 {
	private readonly context: QueueContext;

	constructor(private readonly client: SupabaseQueryClient, context: QueueContext = {}) {
		this.context = context;
	}

	/** Replace the queue context (call when the user logs in / out). */
	setContext(context: QueueContext): void {
		this.context.currentUserId = context.currentUserId ?? null;
		this.context.collectiveId = context.collectiveId ?? null;
	}

	/**
	 * 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') {
			// Fase 9.3: best-effort last-write-wins conflict detection.
			// Read the remote row first (only fields we're patching) and
			// compare to the pre-image the caller captured when the op was
			// queued. Any mismatch on a patched field = remote moved
			// under us; log a sync_conflicts row and proceed with the
			// UPDATE (last-write-wins). A failure to fetch / log never
			// blocks the UPDATE — the conflict log is advisory.
			let remoteSnapshot: Record<string, unknown> | null = null;
			if (op.preImage && this.client.from(op.table).select && this.context.currentUserId) {
				try {
					const cols = Object.keys(op.preImage).join(', ');
					const sel = this.client.from(op.table).select?.(cols);
					if (sel) {
						const r = await sel.match(op.match).maybeSingle();
						if (!r.error) remoteSnapshot = r.data;
					}
				} catch {
					/* swallow — best effort */
				}
			}

			const r = (await table.update(op.patch).match(op.match)) as {
				error: { message: string } | null;
			};
			if (r.error) throw new Error(r.error.message);

			// After a successful UPDATE, fire-and-forget the conflict log.
			if (remoteSnapshot && op.preImage) {
				const diverged = Object.keys(op.preImage).some(
					(k) => !deepEqual(remoteSnapshot![k], op.preImage![k])
				);
				if (diverged) {
					await this.logConflict(op, remoteSnapshot);
				}
			}
		} else {
			const r = (await table.delete().match(op.match)) as {
				error: { message: string } | null;
			};
			if (r.error) throw new Error(r.error.message);
		}
	}

	/**
	 * Insert a sync_conflicts row for the given UPDATE op. Errors are
	 * swallowed — the table is append-only/best-effort and a logging
	 * failure must never break the user's sync.
	 */
	private async logConflict(op: UpdateOp, remoteSnapshot: Record<string, unknown>): Promise<void> {
		const userId = this.context.currentUserId;
		if (!userId) return;
		const entityType = ENTITY_TYPE_FOR_TABLE[op.table];
		const entityId = (op.match.id ?? op.match['id']) as string | undefined;
		if (!entityType || !entityId) return;
		try {
			await this.client
				.from('sync_conflicts')
				.insert({
					user_id: userId,
					collective_id: this.context.collectiveId ?? null,
					entity_type: entityType,
					entity_id: entityId,
					local_version: op.patch,
					remote_version: remoteSnapshot,
					resolution: 'remote_won'
				})
				.select()
				.single();
		} catch {
			/* swallow */
		}
	}
}

/** Local deep-equality good enough for primitive / JSON payloads. */
function deepEqual(a: unknown, b: unknown): boolean {
	if (a === b) return true;
	if (a === null || b === null) return false;
	if (typeof a !== typeof b) return false;
	if (typeof a !== 'object') return false;
	if (Array.isArray(a) !== Array.isArray(b)) return false;
	const ak = Object.keys(a as object);
	const bk = Object.keys(b as object);
	if (ak.length !== bk.length) return false;
	for (const k of ak) {
		if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) {
			return false;
		}
	}
	return true;
}

/**
 * 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 };
