/* ============================================================================
 * sw.js — YT Player Service Worker
 *
 * Strategy:
 *   App shell (HTML/CSS/JS)    → cache-first, versioned cache
 *   /api/* requests            → network-only (never cache yt-dlp results)
 *   /api/download/*            → network-only (streamed binary, never cache)
 *   YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
 *   Everything else            → network, fallback to cache
 *
 * Auto-update flow:
 *   1. New SW installs alongside the old one and waits (skipWaiting() is NOT
 *      called automatically — see the install handler below).
 *   2. Client (app.js registerServiceWorker()) notices the waiting worker
 *      and shows the "Update ready" banner.
 *   3. User clicks "Reload now" → client (applyUpdate() in sw-update.js)
 *      posts SKIP_WAITING to the waiting worker and waits for it to take
 *      control (controllerchange) before reloading — it does NOT unregister
 *      or wipe caches, so the reload doesn't force a brand-new install.
 *   4. activate: if an older *versioned shell cache* is found (i.e. this
 *      activation is genuinely replacing a previous deploy, not just the
 *      first-ever install of a freshly (re)registered worker), broadcast
 *      SW_UPDATE_AVAILABLE to all clients as a secondary/fallback signal.
 * ========================================================================== */

// BUILD_TAG is injected by the server at request time (GET /sw.js).
// It changes on every deploy/restart so the cache is busted automatically
// without any manual version bump.
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
const CACHE   = 'ytplayer-' + VERSION;

// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
// match this — they aren't versioned and must survive every activate.
const SHELL_CACHE_PREFIX = 'ytplayer-';
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
function isVersionedShellCache(key) {
  return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key);
}

// Files that form the installable app shell.
const SHELL = [
  '/',
  '/index.html',
  '/styles.css',
  '/async-guard.js',
  '/sw-update.js',
  '/fingerprint.js',
  '/opfs.js',
  '/opfs-worker.js',
  '/app.js',
  '/manifest.webmanifest',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

// ---- Install: pre-cache the app shell ----
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(CACHE).then((cache) => cache.addAll(SHELL))
    // skipWaiting() is NOT called here — we wait for the client to confirm
    // before activating, so the update banner can appear first.
  );
});

// ---- Activate: evict old caches, claim clients, notify about update ----
self.addEventListener('activate', (e) => {
  e.waitUntil((async () => {
    const keys = await caches.keys();

    // Was there a *previous deploy's* app-shell cache lying around? If so,
    // this activation is a genuine version bump — worth telling the client
    // about. If the only versioned shell cache present is our own CACHE (or
    // none at all), this is the first-ever activation of a freshly
    // (re)registered worker — e.g. right after hardReloadUI() unregisters
    // the old SW and hard-navigates — and there is nothing new to report.
    // Without this check, that harmless re-install would re-broadcast
    // SW_UPDATE_AVAILABLE and immediately reopen the "Update ready" banner
    // the user just dismissed by clicking "Reload now".
    const staleShellCaches = keys.filter((k) => isVersionedShellCache(k) && k !== CACHE);
    const isGenuineUpdate = staleShellCaches.length > 0;

    // Delete every stale *versioned shell* cache — never the utility caches
    // (thumbs/fonts), which aren't tied to a deploy version and should
    // survive every activate.
    await Promise.all(staleShellCaches.map((k) => caches.delete(k)));

    // Claim all open clients immediately (new installs)
    await self.clients.claim();

    if (isGenuineUpdate) {
      // Broadcast to every open window so the app can show an update banner
      const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
      all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION }));
    }
  })());
});

// ---- Fetch: routing logic ----
self.addEventListener('fetch', (e) => {
  const { request } = e;
  const url = new URL(request.url);

  // Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
  if (request.method !== 'GET' && request.method !== 'HEAD') return;

  // API calls and binary downloads → network only, no caching
  if (url.pathname.startsWith('/api/')) {
    e.respondWith(fetch(request));
    return;
  }

  // YouTube thumbnails → stale-while-revalidate (fast load, fresh in background)
  if (url.hostname === 'i.ytimg.com') {
    e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs'));
    return;
  }

  // Google Fonts CSS — stale-while-revalidate so offline doesn't break type
  if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
    e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts'));
    return;
  }

  // App shell → cache-first, then network, then generic offline fallback
  e.respondWith(cacheFirst(request));
});

// ---- Message: handle SKIP_WAITING from the client ----
self.addEventListener('message', (e) => {
  if (e.data && e.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// ============================================================================
// Fetch helpers
// ============================================================================

// Cache-first: serve from cache; if missing, fetch, cache, return.
async function cacheFirst(request) {
  const cache  = await caches.open(CACHE);
  const cached = await cache.match(request);
  if (cached) return cached;
  try {
    const response = await fetch(request);
    // Only cache successful, non-opaque responses
    if (response && response.status === 200 && response.type !== 'opaque') {
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    // Network failed and nothing in cache — return a minimal offline page
    // for navigation requests; let sub-resources fail naturally.
    if (request.mode === 'navigate') {
      const nav = await cache.match('/index.html');
      if (nav) return nav;
    }
    return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
  }
}

// Stale-while-revalidate: return cached immediately, update in background.
async function staleWhileRevalidate(request, cacheName) {
  const cache  = await caches.open(cacheName);
  const cached = await cache.match(request);
  // Start a background revalidation — don't await it before responding
  const networkFetch = fetch(request).then((r) => {
    if (r && r.status === 200) cache.put(request, r.clone());
    return r;
  }).catch(() => null);

  return cached || networkFetch;
}
