/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const CACHE_VERSION = "rspace-v10";
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const HTML_CACHE = `${CACHE_VERSION}-html`;
const API_CACHE = `${CACHE_VERSION}-api`;
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
const TILE_CACHE = `${CACHE_VERSION}-tiles`;
const TILE_CACHE_MAX = 500;
const MODEL_CACHE = `${CACHE_VERSION}-models`;
const MODEL_CACHE_MAX = 20;

// Vite-hashed assets are immutable (content hash in filename)
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;

// Minimal fallback if manifest fetch fails
const FALLBACK_PRECACHE = ["/", "/canvas.html"];

// Max age for cached API GET responses (5 minutes)
const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;

interface PrecacheManifest {
	version: string;
	core: string[];    // shell assets + HTML pages — cached at install (with ?v=hash)
	modules: string[]; // module JS/CSS — lazy-cached after activation (with ?v=hash)
	hashes?: Record<string, string>; // content hashes for cache-busting
}

self.addEventListener("install", (event) => {
	event.waitUntil(
		(async () => {
			let manifest: PrecacheManifest | null = null;
			try {
				const res = await fetch(`/precache-manifest.json?v=${CACHE_VERSION}`);
				if (res.ok) manifest = await res.json();
			} catch { /* manifest unavailable — use fallback */ }

			const coreUrls = manifest?.core ?? FALLBACK_PRECACHE;

			// Precache core shell assets (blocking — required for cold offline start)
			const htmlCache = await caches.open(HTML_CACHE);
			const staticCache = await caches.open(STATIC_CACHE);

			const htmlUrls = coreUrls.filter((u) => u.endsWith(".html") || u === "/");
			const staticUrls = coreUrls.filter((u) => !u.endsWith(".html") && u !== "/");

			await Promise.all([
				htmlCache.addAll(htmlUrls),
				staticCache.addAll(staticUrls),
			]);
		})()
	);
	// Force activate immediately — ensures old PWAs always get the latest version.
	// The SKIP_WAITING message handler is kept as a fallback.
	self.skipWaiting();
});

self.addEventListener("activate", (event) => {
	event.waitUntil(
		(async () => {
			// Clean up old versioned caches
			const keys = await caches.keys();
			await Promise.all(
				keys
					.filter((key) => !key.startsWith(CACHE_VERSION) && key !== ECOSYSTEM_CACHE)
					.map((key) => caches.delete(key))
			);
			await self.clients.claim();

			// Lazy-cache module bundles in background (non-blocking)
			try {
				const res = await fetch(`/precache-manifest.json?v=${CACHE_VERSION}`);
				if (res.ok) {
					const manifest: PrecacheManifest = await res.json();
					if (manifest.modules?.length) {
						const cache = await caches.open(STATIC_CACHE);
						// Cache modules one-by-one to avoid overwhelming bandwidth
						for (const url of manifest.modules) {
							try {
								const existing = await cache.match(url);
								if (!existing) await cache.add(url);
							} catch { /* skip individual failures */ }
						}

						// Clean stale versioned entries (old ?v=oldhash URLs)
						const currentUrls = new Set([...manifest.core, ...manifest.modules]);
						const cachedRequests = await cache.keys();
						for (const req of cachedRequests) {
							const u = new URL(req.url);
							const isVersioned = u.searchParams.has("v") && (
								u.pathname.startsWith("/modules/") ||
								u.pathname === "/shell.js" ||
								u.pathname === "/shell.css" ||
								u.pathname === "/theme.css"
							);
							if (isVersioned && !currentUrls.has(u.pathname + u.search)) {
								await cache.delete(req);
							}
						}
					}
				}
			} catch { /* manifest unavailable */ }
		})()
	);
});

self.addEventListener("fetch", (event) => {
	const url = new URL(event.request.url);

	// Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached
	if (!url.protocol.startsWith("http")) return;

	// Skip WebSocket requests entirely
	if (
		event.request.url.startsWith("ws://") ||
		event.request.url.startsWith("wss://") ||
		url.pathname.startsWith("/ws/")
	) {
		return;
	}

	// API GET requests: stale-while-revalidate for offline browsing
	// Skip POST/PUT/DELETE — those must go to the server
	if (url.pathname.includes("/api/")) {
		if (event.request.method !== "GET") return;

		event.respondWith(
			caches.open(API_CACHE).then(async (cache) => {
				const cached = await cache.match(event.request);

				const fetchPromise = fetch(event.request)
					.then((response) => {
						if (response.ok) {
							// Store with a timestamp header for staleness checking
							const headers = new Headers(response.headers);
							headers.set("x-sw-cached-at", String(Date.now()));
							const timedResponse = new Response(response.clone().body, {
								status: response.status,
								statusText: response.statusText,
								headers,
							});
							cache.put(event.request, timedResponse).catch(() => {});
						}
						return response;
					})
					.catch(() => {
						// Offline — return cached if available
						if (cached) return cached;
						return new Response(
							JSON.stringify({ error: "offline", message: "You are offline and this data is not cached." }),
							{ status: 503, headers: { "Content-Type": "application/json" } }
						);
					});

				// Return cached immediately if fresh enough, revalidate in background
				if (cached) {
					const cachedAt = Number(cached.headers.get("x-sw-cached-at") || 0);
					if (Date.now() - cachedAt < API_CACHE_MAX_AGE_MS) {
						// Fresh cache — serve immediately, revalidate in background
						event.waitUntil(fetchPromise);
						return cached;
					}
				}

				// No cache or stale — wait for network
				return fetchPromise;
			})
		);
		return;
	}

	// Immutable hashed assets: cache-first (they never change)
	if (IMMUTABLE_PATTERN.test(url.pathname)) {
		event.respondWith(
			caches.match(event.request).then((cached) => {
				if (cached) return cached;
				return fetch(event.request).then((response) => {
					if (response.ok) {
						const clone = response.clone();
						caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone).catch(() => {}));
					}
					return response;
				});
			})
		);
		return;
	}

	// HTML pages: network-first with cache fallback + offline page
	if (
		event.request.mode === "navigate" ||
		event.request.headers.get("accept")?.includes("text/html")
	) {
		event.respondWith(
			fetch(event.request)
				.then((response) => {
					if (response.ok) {
						const clone = response.clone();
						caches.open(HTML_CACHE).then((cache) => cache.put(event.request, clone).catch(() => {}));
					}
					return response;
				})
				.catch(() => {
					return caches
						.match(event.request)
						.then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise<Response>;
				})
		);
		return;
	}

	// OSM map tiles: cache-first with LRU eviction
	if (url.hostname === "tile.openstreetmap.org" && url.pathname.endsWith(".png")) {
		event.respondWith(
			caches.open(TILE_CACHE).then(async (cache) => {
				const cached = await cache.match(event.request);
				if (cached) return cached;

				const response = await fetch(event.request);
				if (response.ok) {
					const clone = response.clone();
					// Put tile in cache, then trim if needed
					cache.put(event.request, clone).then(async () => {
						const keys = await cache.keys();
						if (keys.length > TILE_CACHE_MAX) {
							// Evict oldest entries (FIFO — approximation of LRU)
							const toDelete = keys.length - TILE_CACHE_MAX;
							for (let i = 0; i < toDelete; i++) {
								await cache.delete(keys[i]);
							}
						}
					}).catch(() => {});
				}
				return response;
			}).catch(() => new Response("Tile unavailable", { status: 503 }))
		);
		return;
	}

	// 3D model files: cache-first with LRU eviction
	const MODEL_PATTERN = /\.(glb|ply|splat|spz)(\?|$)/;
	const isModelPath = url.pathname.includes("/data/files/generated/") || url.pathname.includes("/rsplat/api/splats/");
	if (MODEL_PATTERN.test(url.pathname) && isModelPath) {
		event.respondWith(
			caches.open(MODEL_CACHE).then(async (cache) => {
				const cached = await cache.match(event.request);
				if (cached) return cached;

				const response = await fetch(event.request);
				if (response.ok) {
					const clone = response.clone();
					cache.put(event.request, clone).then(async () => {
						const keys = await cache.keys();
						if (keys.length > MODEL_CACHE_MAX) {
							const toDelete = keys.length - MODEL_CACHE_MAX;
							for (let i = 0; i < toDelete; i++) {
								await cache.delete(keys[i]);
							}
						}
					}).catch(() => {});
				}
				return response;
			}).catch(() => new Response("Model unavailable", { status: 503 }))
		);
		return;
	}

	// Other assets (images, fonts, etc.): stale-while-revalidate
	event.respondWith(
		caches.match(event.request).then((cached) => {
			const fetchPromise = fetch(event.request)
				.then((response) => {
					if (response.ok) {
						const clone = response.clone();
						caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone).catch(() => {}));
					}
					return response;
				})
				.catch(() => cached || new Response("Offline", { status: 503 }));
			return cached || fetchPromise;
		})
	);
});

// ============================================================================
// ECOSYSTEM MODULE CACHING (AC#8)
// ============================================================================
// EcosystemBridge sends postMessage to cache external ecosystem module JS
// for offline access. The module URL is fetched via our server proxy and
// stored in the ecosystem cache.

self.addEventListener("message", (event) => {
	const msg = event.data;
	if (!msg || typeof msg !== "object") return;

	// Client requests this SW to activate immediately (user clicked "Update")
	if (msg.type === "SKIP_WAITING") {
		self.skipWaiting();
		return;
	}

	if (msg.type === "cache-ecosystem-module" && msg.moduleUrl) {
		event.waitUntil(
			(async () => {
				try {
					const cache = await caches.open(ECOSYSTEM_CACHE);
					const existing = await cache.match(msg.moduleUrl);
					if (existing) return; // Already cached

					// Fetch via server proxy to avoid CORS
					// Only cache under the canonical proxy URL — never under
					// client-supplied msg.moduleUrl (cache poisoning risk)
					const proxyUrl = `/api/ecosystem/${encodeURIComponent(msg.appId)}/module`;
					const res = await fetch(proxyUrl);
					if (res.ok) {
						await cache.put(proxyUrl, res.clone());
					}
				} catch {
					// Non-critical — module will be fetched on demand
				}
			})()
		);
	}

	// Allow client to request ecosystem cache cleanup
	if (msg.type === "clear-ecosystem-cache") {
		event.waitUntil(caches.delete(ECOSYSTEM_CACHE));
	}

	// rMaps: save room state to IndexedDB for offline persistence
	if (msg.type === "SAVE_ROOM_STATE" && msg.roomSlug && msg.state) {
		event.waitUntil(
			openRmapsDB().then((db) => {
				const tx = db.transaction("rooms", "readwrite");
				tx.objectStore("rooms").put({ slug: msg.roomSlug, state: msg.state, savedAt: Date.now() });
				return new Promise<void>((resolve) => { tx.oncomplete = () => resolve(); });
			}).catch(() => {})
		);
	}

	// rMaps: get room state from IndexedDB
	if (msg.type === "GET_ROOM_STATE" && msg.roomSlug) {
		event.waitUntil(
			openRmapsDB().then(async (db) => {
				return new Promise<void>((resolve) => {
					const tx = db.transaction("rooms", "readonly");
					const req = tx.objectStore("rooms").get(msg.roomSlug);
					req.onsuccess = () => {
						event.source?.postMessage({
							type: "ROOM_STATE_RESULT",
							roomSlug: msg.roomSlug,
							state: req.result?.state || null,
						});
						resolve();
					};
					req.onerror = () => {
						event.source?.postMessage({ type: "ROOM_STATE_RESULT", roomSlug: msg.roomSlug, state: null });
						resolve();
					};
				});
			}).catch(() => {
				event.source?.postMessage({ type: "ROOM_STATE_RESULT", roomSlug: msg.roomSlug, state: null });
			})
		);
	}
});

// ============================================================================
// WEB PUSH HANDLERS
// ============================================================================

self.addEventListener("push", (event) => {
	if (!event.data) return;

	let payload: { title: string; body?: string; icon?: string; badge?: string; tag?: string; data?: any; type?: string };
	try {
		payload = event.data.json();
	} catch {
		payload = { title: event.data.text() || "rSpace" };
	}

	// rMaps location request push — notify all clients to share location
	if (payload.type === "location_request") {
		event.waitUntil(
			(async () => {
				// Show notification
				await self.registration.showNotification(payload.title || "Location Requested", {
					body: payload.body || "Someone is asking for your location",
					icon: "/icons/icon-192.png",
					badge: "/icons/icon-192.png",
					tag: "rmaps-location-request",
					requireInteraction: true,
					vibrate: [200, 100, 200],
					data: { ...payload.data, type: "location_request" },
				} as NotificationOptions);

				// Notify open clients to auto-share if enabled
				const clients = await self.clients.matchAll({ type: "window" });
				if (clients.length > 0) {
					for (const client of clients) {
						client.postMessage({ type: "LOCATION_REQUEST", data: payload.data });
					}
				} else {
					// No open clients — try to return last-known location from IndexedDB
					const roomSlug = payload.data?.roomSlug;
					if (roomSlug) {
						try {
							const db = await openRmapsDB();
							const tx = db.transaction("rooms", "readonly");
							const req = tx.objectStore("rooms").get(roomSlug);
							await new Promise<void>((resolve) => {
								req.onsuccess = async () => {
									const savedState = req.result?.state;
									if (savedState) {
										// Post last-known state back to sync server so the pinger sees it
										try {
											await fetch("/rmaps/api/push/last-known-location", {
												method: "POST",
												headers: { "Content-Type": "application/json" },
												body: JSON.stringify({
													roomSlug,
													state: savedState,
													savedAt: req.result.savedAt,
												}),
											});
										} catch { /* best effort */ }
									}
									resolve();
								};
								req.onerror = () => resolve();
							});
						} catch { /* IndexedDB unavailable */ }
					}
				}
			})()
		);
		return;
	}

	event.waitUntil(
		self.registration.showNotification(payload.title, {
			body: payload.body,
			icon: payload.icon || "/icons/icon-192.png",
			badge: payload.badge || "/icons/icon-192.png",
			tag: payload.tag,
			data: payload.data,
		}),
	);
});

self.addEventListener("notificationclick", (event) => {
	event.notification.close();
	const url = event.notification.data?.url || "/";

	event.waitUntil(
		self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
			// Focus existing tab if found
			for (const client of clients) {
				if (new URL(client.url).pathname === url && "focus" in client) {
					return client.focus();
				}
			}
			// Otherwise open new window
			return self.clients.openWindow(url);
		}),
	);
});

// ============================================================================
// RMAPS OFFLINE DB
// ============================================================================

const RMAPS_DB_NAME = "rmaps-offline";
const RMAPS_DB_VERSION = 1;

function openRmapsDB(): Promise<IDBDatabase> {
	return new Promise((resolve, reject) => {
		const req = indexedDB.open(RMAPS_DB_NAME, RMAPS_DB_VERSION);
		req.onupgradeneeded = () => {
			const db = req.result;
			if (!db.objectStoreNames.contains("rooms")) {
				db.createObjectStore("rooms", { keyPath: "slug" });
			}
		};
		req.onsuccess = () => resolve(req.result);
		req.onerror = () => reject(req.error);
	});
}

/** Minimal offline fallback page when nothing is cached. */
function offlineFallbackPage(): Response {
	const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Offline - rSpace</title>
  <style>
    body { font-family: system-ui, sans-serif; display: flex; align-items: center;
           justify-content: center; min-height: 100vh; margin: 0;
           background: #0a0a0f; color: #e0e0e0; }
    .offline { text-align: center; max-width: 400px; padding: 2rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    p { color: #999; line-height: 1.5; }
    button { margin-top: 1rem; padding: 8px 20px; border-radius: 6px;
             border: 1px solid #333; background: #1a1a2e; color: #e0e0e0;
             cursor: pointer; font-size: 0.9rem; }
    button:hover { background: #2a2a3e; }
  </style>
</head>
<body>
  <div class="offline">
    <h1>You're offline</h1>
    <p>rSpace can't reach the server right now. Previously visited pages and
       locally cached data are still available.</p>
    <button onclick="location.reload()">Try Again</button>
  </div>
</body>
</html>`;
	return new Response(html, {
		status: 503,
		headers: { "Content-Type": "text/html; charset=utf-8" },
	});
}
