// Axis Service Worker
// Strategy: cache-first for all static assets (JS/CSS/fonts/icons)
// This makes repeat loads INSTANT — served from disk cache, no network.

const CACHE_VERSION = 'mc-v22-safe-client-assets';

function isHtmlAssetResponse(response) {
  const contentType = response.headers.get('content-type') || '';
  return contentType.toLowerCase().includes('text/html');
}

async function findCachedAssetFallback(requestUrl) {
  const url = new URL(requestUrl);
  const pathname = url.pathname;

  // If a fresh build swaps index.html before the matching hashed CSS has
  // reached disk (or the network flakes mid-load), do not leave Axis in raw
  // unstyled HTML. A slightly stale hashed asset is massively better than a
  // broken shell. JS stays network-only for fresh hashes so stale app logic
  // cannot survive across upload fixes.
  const fallbackPatterns = [];
  if (/^\/assets\/index-[^/]+\.css$/.test(pathname)) {
    fallbackPatterns.push(/^\/assets\/index-[^/]+\.css$/);
  } else if (/^\/assets\/vendor-xterm-[^/]+\.css$/.test(pathname)) {
    fallbackPatterns.push(/^\/assets\/vendor-xterm-[^/]+\.css$/);
  }

  if (fallbackPatterns.length === 0) return undefined;

  const cacheNames = await caches.keys();
  for (const cacheName of cacheNames) {
    const cache = await caches.open(cacheName);
    const keys = await cache.keys();
    for (let i = keys.length - 1; i >= 0; i -= 1) {
      const candidateUrl = new URL(keys[i].url);
      if (fallbackPatterns.some((pattern) => pattern.test(candidateUrl.pathname))) {
        const cached = await cache.match(keys[i]);
        if (cached) return cached;
      }
    }
  }

  return undefined;
}

// Shell assets always cached on install
const SHELL_ASSETS = [
  '/',
  '/manifest.json',
  '/icon-192.png',
  '/icon-512.png',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_VERSION).then((cache) => cache.addAll(SHELL_ASSETS))
  );
  // Take over immediately — don't wait for old SW to die
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

// ─── Web Push ───────────────────────────────────────────────────────────────
// Axis notifies on human gates (axis_request_approval / axis_ask).
// Payload shape is defined by PushNotificationPayload in push-notifications.ts.

self.addEventListener('push', (event) => {
  let payload = {};
  try {
    payload = event.data ? event.data.json() : {};
  } catch (_err) {
    payload = { title: 'Axis', body: event.data ? event.data.text() : '' };
  }

  const title = payload.title || 'Axis';
  event.waitUntil((async () => {
    // Axis already shows human gates in-app (blue pulse + inbox
    // badge), so an alert is only useful when it is not being watched. A push
    // with `userVisibleOnly` must still show something or Chrome substitutes its
    // own "updated in the background" notice, so downgrade to a quiet,
    // auto-dismissing toast instead of suppressing it.
    const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
    const watching = clientList.some((client) => client.focused && client.visibilityState === 'visible');

    await self.registration.showNotification(title, {
      body: payload.body || '',
      tag: payload.tag || 'axis',
      icon: '/icon-192.png',
      badge: '/icon-192.png',
      // Human gates block an agent indefinitely, so an unattended notification
      // persists until it is actually dealt with rather than auto-dismissing.
      requireInteraction: !watching,
      silent: watching,
      renotify: Boolean(payload.tag) && !watching,
      data: {
        projectPath: payload.projectPath || null,
        sessionId: payload.sessionId || null,
        url: payload.url || '/',
      },
    });
  })());
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const data = event.notification.data || {};

  event.waitUntil((async () => {
    const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
    // Reuse an already-open Axis tab so clicking a notification
    // never spawns a duplicate app instance with its own terminal state.
    for (const client of clientList) {
      if (new URL(client.url).origin !== self.location.origin) continue;
      await client.focus();
      client.postMessage({ type: 'mc:push-navigate', projectPath: data.projectPath, sessionId: data.sessionId });
      return;
    }
    const target = data.projectPath
      ? `/?project=${encodeURIComponent(data.projectPath)}&approvals=1`
      : (data.url || '/?approvals=1');
    await self.clients.openWindow(target);
  })());
});

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

  // The Cache API only supports http(s) requests. Browser extensions can
  // inject chrome-extension:// assets into the controlled page; leave those
  // alone so they cannot crash the service worker while Axis is booting.
  if (
    event.request.method !== 'GET' ||
    (url.protocol !== 'http:' && url.protocol !== 'https:') ||
    url.origin !== self.location.origin
  ) {
    return;
  }

  // Never intercept API/WebSocket requests or proxied preview traffic.
  // BrowserPane iframes serve local apps through same-origin /preview/:port/…
  // routes, and dev frameworks (Next/Vite/etc.) emit .js/.css files under
  // those paths. Caching those non-content-hashed dev chunks here can serve a
  // stale app bundle against fresh HTML, leaving Next's FOUC style in place and
  // producing the BrowserPane white screen of death.
  if (
    url.pathname.startsWith('/api') ||
    url.pathname.startsWith('/ws') ||
    url.pathname === '/preview' ||
    url.pathname.startsWith('/preview/') ||
    url.pathname.startsWith('/_next/') ||
    url.pathname.startsWith('/static/')
  ) {
    return;
  }

  // JS chunks, CSS, icons, fonts — cache-first (these are content-hashed, safe forever)
  const isStaticAsset =
    url.pathname.startsWith('/assets/') ||
    url.pathname.endsWith('.js') ||
    url.pathname.endsWith('.css') ||
    url.pathname.endsWith('.woff2') ||
    url.pathname.endsWith('.woff') ||
    url.pathname.endsWith('.png') ||
    url.pathname.endsWith('.svg') ||
    url.pathname.endsWith('.ico');

  if (isStaticAsset) {
    event.respondWith(
      caches.open(CACHE_VERSION).then(async (cache) => {
        const cached = await cache.match(event.request);
        if (cached) return cached;
        // Not in cache — fetch, store, return. If the fetch fails or returns a
        // transient 404 during a non-atomic Vite rebuild, fall back to the last
        // cached app CSS/entry chunk rather than letting Chrome render raw HTML.
        try {
          const response = await fetch(event.request);
          // A missing /assets/* route used to fall through to index.html with
          // HTTP 200. Never cache that HTML under a hashed JS/CSS URL: doing so
          // poisons retries even after the real asset is restored.
          if (response.ok && !isHtmlAssetResponse(response)) {
            cache.put(event.request, response.clone());
            return response;
          }
          const fallback = await findCachedAssetFallback(event.request.url);
          return fallback || response;
        } catch (_err) {
          const fallback = await findCachedAssetFallback(event.request.url);
          if (fallback) return fallback;
          throw _err;
        }
      })
    );
    return;
  }

  // HTML navigation — network first, fall back to cached shell
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Cache the fresh HTML too
          const clone = response.clone();
          caches.open(CACHE_VERSION).then((cache) => cache.put(event.request, clone));
          return response;
        })
        .catch(async () => {
          // Offline: serve cached shell
          const cached = await caches.match('/');
          return cached || fetch(event.request);
        })
    );
    return;
  }

  // Everything else — network with cache fallback
  event.respondWith(
    fetch(event.request).catch(() => caches.match(event.request))
  );
});
