/**
 * OfflineStore — IndexedDB persistence for Automerge documents and sync state.
 *
 * Stores per-community:
 * - docBinary: Automerge.save(doc) output (Uint8Array)
 * - syncStateBinary: Automerge.encodeSyncState() output (Uint8Array)
 * - lastSynced / lastModified timestamps
 */

export interface OfflineCacheEntry {
	slug: string;
	docBinary: Uint8Array;
	syncStateBinary: Uint8Array | null;
	lastSynced: number;
	lastModified: number;
}

export class OfflineStore {
	#db: IDBDatabase | null = null;
	#dbName = "rspace-offline";
	#storeName = "communities";
	#version = 1;
	#saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
	#pendingSaves = new Map<string, Uint8Array>();
	#saveDebounceMs = 2000;

	/**
	 * Open the IndexedDB database. Must be called before any other method.
	 * Safe to call multiple times (idempotent).
	 */
	async open(): Promise<void> {
		if (this.#db) return;

		return new Promise((resolve, reject) => {
			const request = indexedDB.open(this.#dbName, this.#version);

			request.onupgradeneeded = () => {
				const db = request.result;
				if (!db.objectStoreNames.contains(this.#storeName)) {
					db.createObjectStore(this.#storeName, { keyPath: "slug" });
				}
			};

			request.onsuccess = () => {
				this.#db = request.result;
				resolve();
			};

			request.onerror = () => {
				console.error("[OfflineStore] Failed to open IndexedDB:", request.error);
				reject(request.error);
			};
		});
	}

	/**
	 * Save Automerge document binary, debounced to avoid thrashing.
	 */
	saveDoc(slug: string, docBinary: Uint8Array): void {
		this.#pendingSaves.set(slug, docBinary);

		const existing = this.#saveTimers.get(slug);
		if (existing) clearTimeout(existing);

		this.#saveTimers.set(
			slug,
			setTimeout(() => {
				this.#saveTimers.delete(slug);
				this.#pendingSaves.delete(slug);
				this.#writeDoc(slug, docBinary).catch((e) => {
					console.error("[OfflineStore] Failed to save doc:", e);
				});
			}, this.#saveDebounceMs)
		);
	}

	/**
	 * Immediately save document binary (bypasses debounce).
	 * Used before page unload.
	 */
	async saveDocImmediate(slug: string, docBinary: Uint8Array): Promise<void> {
		// Cancel any pending debounced save for this slug
		const existing = this.#saveTimers.get(slug);
		if (existing) {
			clearTimeout(existing);
			this.#saveTimers.delete(slug);
		}
		this.#pendingSaves.delete(slug);

		// Belt-and-suspenders: also write to localStorage synchronously
		this.saveDocEmergency(slug, docBinary);

		await this.#writeDoc(slug, docBinary);
	}

	/**
	 * Synchronous emergency save to localStorage.
	 * Used as a fallback when IndexedDB may not complete (e.g. beforeunload).
	 * The data is base64-encoded to fit in localStorage's string-only storage.
	 */
	saveDocEmergency(slug: string, docBinary: Uint8Array): void {
		try {
			const key = `rspace-save-${slug}`;
			// Convert Uint8Array to base64 string
			let binary = "";
			for (let i = 0; i < docBinary.length; i++) {
				binary += String.fromCharCode(docBinary[i]);
			}
			const base64 = btoa(binary);
			localStorage.setItem(key, base64);
		} catch (e) {
			// localStorage may be full or unavailable — silently fail
			console.warn("[OfflineStore] Emergency save failed:", e);
		}
	}

	/**
	 * Load cached Automerge document binary.
	 * Checks for emergency localStorage save first, migrates it to IndexedDB if found.
	 */
	async loadDoc(slug: string): Promise<Uint8Array | null> {
		// Check for emergency localStorage fallback first
		const emergencyKey = `rspace-save-${slug}`;
		try {
			const base64 = localStorage.getItem(emergencyKey);
			if (base64) {
				console.log("[OfflineStore] Recovering from emergency localStorage save for", slug);
				const binary = atob(base64);
				const docBinary = new Uint8Array(binary.length);
				for (let i = 0; i < binary.length; i++) {
					docBinary[i] = binary.charCodeAt(i);
				}
				// Migrate to IndexedDB and clean up localStorage
				await this.#writeDoc(slug, docBinary);
				localStorage.removeItem(emergencyKey);
				return docBinary;
			}
		} catch (e) {
			console.warn("[OfflineStore] Failed to recover emergency save:", e);
		}

		const entry = await this.#getEntry(slug);
		return entry?.docBinary ?? null;
	}

	/**
	 * Save Automerge SyncState binary for incremental reconnection.
	 */
	async saveSyncState(slug: string, syncStateBinary: Uint8Array): Promise<void> {
		if (!this.#db) return;

		try {
			const entry = await this.#getEntry(slug);
			if (!entry) return; // No doc saved yet, skip sync state

			entry.syncStateBinary = syncStateBinary;
			await this.#putEntry(entry);
		} catch (e) {
			console.error("[OfflineStore] Failed to save sync state:", e);
		}
	}

	/**
	 * Load cached SyncState binary.
	 */
	async loadSyncState(slug: string): Promise<Uint8Array | null> {
		const entry = await this.#getEntry(slug);
		return entry?.syncStateBinary ?? null;
	}

	/**
	 * Update the lastSynced timestamp (called when server confirms sync).
	 */
	async markSynced(slug: string): Promise<void> {
		if (!this.#db) return;

		try {
			const entry = await this.#getEntry(slug);
			if (!entry) return;

			entry.lastSynced = Date.now();
			await this.#putEntry(entry);
		} catch (e) {
			console.error("[OfflineStore] Failed to mark synced:", e);
		}
	}

	/**
	 * Clear all cached data for a community.
	 */
	async clear(slug: string): Promise<void> {
		if (!this.#db) return;

		return new Promise((resolve, reject) => {
			const tx = this.#db!.transaction(this.#storeName, "readwrite");
			const store = tx.objectStore(this.#storeName);
			const request = store.delete(slug);

			request.onsuccess = () => resolve();
			request.onerror = () => reject(request.error);
		});
	}

	/**
	 * List all cached community slugs.
	 */
	async listCommunities(): Promise<string[]> {
		if (!this.#db) return [];

		return new Promise((resolve, reject) => {
			const tx = this.#db!.transaction(this.#storeName, "readonly");
			const store = tx.objectStore(this.#storeName);
			const request = store.getAllKeys();

			request.onsuccess = () => resolve(request.result as string[]);
			request.onerror = () => reject(request.error);
		});
	}

	/**
	 * Flush all pending debounced saves immediately.
	 * Call from beforeunload handler.
	 */
	async flush(): Promise<void> {
		const promises: Promise<void>[] = [];

		for (const [slug, binary] of this.#pendingSaves) {
			const timer = this.#saveTimers.get(slug);
			if (timer) clearTimeout(timer);
			this.#saveTimers.delete(slug);
			promises.push(this.#writeDoc(slug, binary));
		}
		this.#pendingSaves.clear();

		await Promise.all(promises);
	}

	// --- Private helpers ---

	async #writeDoc(slug: string, docBinary: Uint8Array): Promise<void> {
		if (!this.#db) return;

		const existing = await this.#getEntry(slug);
		const entry: OfflineCacheEntry = {
			slug,
			docBinary,
			syncStateBinary: existing?.syncStateBinary ?? null,
			lastSynced: existing?.lastSynced ?? 0,
			lastModified: Date.now(),
		};

		await this.#putEntry(entry);
	}

	#getEntry(slug: string): Promise<OfflineCacheEntry | null> {
		if (!this.#db) return Promise.resolve(null);

		return new Promise((resolve, reject) => {
			const tx = this.#db!.transaction(this.#storeName, "readonly");
			const store = tx.objectStore(this.#storeName);
			const request = store.get(slug);

			request.onsuccess = () => resolve(request.result ?? null);
			request.onerror = () => reject(request.error);
		});
	}

	#putEntry(entry: OfflineCacheEntry): Promise<void> {
		if (!this.#db) return Promise.resolve();

		return new Promise((resolve, reject) => {
			const tx = this.#db!.transaction(this.#storeName, "readwrite");
			const store = tx.objectStore(this.#storeName);
			const request = store.put(entry);

			request.onsuccess = () => resolve();
			request.onerror = () => reject(request.error);
		});
	}
}
