/**
 * FamilySync Service Worker — injectManifest custom SW.
 *
 * Responsibilities:
 *   1. Precache the app shell (Workbox injects self.__WB_MANIFEST at build time).
 *   2. Reproduce autoUpdate behavior: skipWaiting + clientsClaim.
 *   3. Navigation denylist (T-03-20): /callback, /api/*, /health must never be
 *      intercepted — they must reach the server. Re-implemented from the former
 *      vite.config.ts workbox.navigateFallbackDenylist.
 *   4. Push handler: ALWAYS shows a visible notification (D-11 / iOS anti-pattern
 *      mitigation). iOS revokes subscriptions after ~3 silent pushes. Never swallow
 *      a push silently — even malformed payloads get a generic fallback notification.
 *   5. notificationclick handler: deep-link to the URL in notification.data.url.
 *      Focuses an existing window at that URL or opens a new one (D-14).
 *
 * Payload format (from pushDispatcher.ts buildPushBody):
 *   iOS 18.4+ declarative: { web_push: 8030, notification: { title, body, navigate } }
 *   Legacy (iOS 16.4–18.3 + Android): { title, body, tag, data: { url } }
 *
 * Security (T-05-11, T-05-12):
 *   - NavigationRoute denylist prevents the OIDC /callback from being served from cache.
 *   - Try/catch on push payload parse falls back to generic notification — prevents
 *     a malformed payload from silently dropping a push (which would revoke iOS subs).
 */

/// <reference lib="webworker" />

import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from 'workbox-routing';

declare const self: ServiceWorkerGlobalScope;

// ---------------------------------------------------------------------------
// AutoUpdate behavior: replace old SW immediately on install/activate.
// Equivalent to the former generateSW autoUpdate: 'prompt' → 'autoUpdate' path.
// ---------------------------------------------------------------------------
// skipWaiting() resolves when the SW is installed; fire-and-forget is the correct pattern here
void self.skipWaiting();
clientsClaim();

// ---------------------------------------------------------------------------
// Precache the app shell.
// self.__WB_MANIFEST is injected by vite-plugin-pwa injectManifest at build time.
// At dev time this is an empty array; the real manifest is injected in production.
// ---------------------------------------------------------------------------
precacheAndRoute(self.__WB_MANIFEST);

// ---------------------------------------------------------------------------
// Navigation denylist (T-03-20 — CRITICAL).
//
// Routes that must NEVER be intercepted by the SW navigation handler:
//   /callback  — OIDC authorization-code exchange; must reach the API server.
//   /api/*     — All API calls; SW must not cache or serve these.
//   /health    — Health endpoint; must not be served from cache.
//
// Re-implements the former workbox.navigateFallbackDenylist from vite.config.ts.
// ---------------------------------------------------------------------------
const navHandler = createHandlerBoundToURL('/index.html');
registerRoute(
  new NavigationRoute(navHandler, {
    denylist: [
      /^\/callback/, // OIDC redirect — must reach server (T-03-20)
      /^\/api\//, // API calls — never serve from cache
      /^\/health/, // Health endpoint
    ],
  }),
);

// ---------------------------------------------------------------------------
// Push handler (D-11, NOTIF-01/02/03).
//
// ALWAYS shows a visible notification. iOS silently revokes subscriptions after
// ~3 silent pushes — we must NEVER let a push event resolve without displaying
// a notification. The fallback title/body covers malformed payloads.
//
// Payload format (dual-format from buildPushBody):
//   Declarative (iOS 18.4+): { web_push: 8030, notification: { title, body, navigate } }
//   Legacy (iOS 16.4–18.3 + Android): { title, body, tag, data: { url } }
// ---------------------------------------------------------------------------
self.addEventListener('push', (event: PushEvent) => {
  let title = 'FamilySync';
  let body = 'You have a new notification';
  let tag = 'familysync-notification';
  let url = '/';

  try {
    if (event.data) {
      const payload = event.data.json() as Record<string, unknown>;

      // iOS 18.4+ declarative web push format
      if (
        payload.web_push === 8030 &&
        payload.notification &&
        typeof payload.notification === 'object'
      ) {
        const notif = payload.notification as Record<string, unknown>;
        if (typeof notif.title === 'string') title = notif.title;
        if (typeof notif.body === 'string') body = notif.body;
        if (typeof notif.navigate === 'string') url = notif.navigate;
        // Use navigate as tag for coalescing duplicate pushes
        tag = `familysync-${url}`;
      } else {
        // Legacy format: top-level title/body/tag/data
        if (typeof payload.title === 'string') title = payload.title;
        if (typeof payload.body === 'string') body = payload.body;
        if (typeof payload.tag === 'string') tag = payload.tag;
        if (
          payload.data &&
          typeof payload.data === 'object' &&
          typeof (payload.data as Record<string, unknown>).url === 'string'
        ) {
          url = (payload.data as Record<string, string>).url;
        }
      }
    }
  } catch {
    // Malformed payload — fall through with default title/body/url.
    // We still show the notification below (D-11 — never silent).
  }

  // event.waitUntil() is MANDATORY: keeps the SW alive until the notification
  // is displayed. Without it the SW may be terminated before showNotification
  // resolves, resulting in a silent push (iOS subscription death).
  // renotify and vibrate are valid NotificationOptions per spec but are absent
  // from some lib.dom versions (TS strict check). Cast the extended object to
  // NotificationOptions so only these two extra keys are affected — no blanket
  // suppression of unrelated errors elsewhere in the file.
  const options = {
    body,
    tag,
    data: { url },
    icon: '/icon-192.png',
    badge: '/icon-192.png',
    renotify: true,
    vibrate: [200, 100, 200],
  } as NotificationOptions;
  event.waitUntil(self.registration.showNotification(title, options));
});

// ---------------------------------------------------------------------------
// notificationclick handler (D-14 — deep-link on tap).
//
// Closes the notification, then:
//   1. Find any existing same-origin window with clients.matchAll (not exact-URL
//      match — deep-link URLs differ from the current window URL). Focus it and
//      navigate it to the target URL so the deep-link is honoured (CR-03 fix).
//   2. If no window exists, open a new one at the target URL.
//
// CR-03: The previous exact-URL match (client.url === url) never fired for
// deep-link notifications whose query string differed from the current window URL
// (e.g. notification for /calendar?event=uid while window is at /calendar).
// Using any existing window + navigate() satisfies D-14 on both Android and iOS.
// ---------------------------------------------------------------------------
self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();

  // Notification.data is typed as 'any' in the ServiceWorker lib; we validate with typeof
  // before using the value so the access is safe despite the lack of static types.
  let url = '/';
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any' per webworker lib; typeof guard on the right-hand side validates this access
  if (typeof event.notification.data?.url === 'string') {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any'; typeof check above validates this access
    url = event.notification.data.url as string;
  }

  event.waitUntil(
    self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
      // Focus any existing window on this origin and navigate it to the target URL.
      // We don't match on client.url — the deep-link URL will almost always differ
      // from the current window location (query string with event uid / date).
      for (const client of clientList) {
        if ('focus' in client) {
          return client
            .focus()
            .then(() => client.navigate(url))
            .then((navigated) => {
              if (navigated === null && self.clients.openWindow)
                return self.clients.openWindow(url);
            })
            .catch(() => {
              if (self.clients.openWindow) return self.clients.openWindow(url);
            });
        }
      }
      // No existing window — open a new one at the deep-link URL
      if (self.clients.openWindow) {
        return self.clients.openWindow(url);
      }
    }),
  );
});
