/// <reference lib="WebWorker" />

import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';

export type {};
declare const self: ServiceWorkerGlobalScope & {
  // Replaced at build time by vite-plugin-pwa (injectManifest) with the list of
  // hashed build assets to precache. See vite.config.js VitePWA injectManifest.
  __WB_MANIFEST: Array<string | PrecacheEntry>;
};

/**
 * PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time
 * by vite-plugin-pwa with the list of hashed build assets
 * (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns).
 *
 * DEPLOY-SAFETY INVARIANTS (do not break):
 *  1. index.html / navigations are NEVER precached or precache-routed. The
 *     manifest globs only `assets/**` (content-hashed), so index.html (served
 *     from the app root) is absent from it and navigation requests fall through
 *     to the network — a new deploy is picked up immediately, no stale SPA
 *     shell. We deliberately do NOT register a navigation route /
 *     createHandlerBoundToURL fallback.
 *  2. precacheAndRoute only matches its own manifest URLs (same-origin hashed
 *     assets). It never matches the media-auth paths handled by the fetch
 *     listener below — those are cross-origin homeserver URLs absent from the
 *     manifest — so the existing media fetch behaviour is fully preserved. It
 *     is registered before that listener; for a media request the precache
 *     route finds no match and does not call respondWith, so the media handler
 *     still runs.
 *  3. Assets are content-hashed, so a changed asset ships under a new filename;
 *     PrecacheController drops entries no longer in the current manifest on
 *     activate, so the precache self-updates each deploy without unbounded
 *     growth.
 */
precacheAndRoute(self.__WB_MANIFEST);

type SessionInfo = {
  accessToken: string;
  baseUrl: string;
};

/**
 * Store session per client (tab)
 */
const sessions = new Map<string, SessionInfo>();

const clientToResolve = new Map<string, (value: SessionInfo | undefined) => void>();
const clientToSessionPromise = new Map<string, Promise<SessionInfo | undefined>>();

async function cleanupDeadClients() {
  const activeClients = await self.clients.matchAll();
  const activeIds = new Set(activeClients.map((c) => c.id));

  Array.from(sessions.keys()).forEach((id) => {
    if (!activeIds.has(id)) {
      sessions.delete(id);
      clientToResolve.delete(id);
      clientToSessionPromise.delete(id);
    }
  });
}

function setSession(clientId: string, accessToken: any, baseUrl: any) {
  if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
    sessions.set(clientId, { accessToken, baseUrl });
  } else {
    // Logout or invalid session
    sessions.delete(clientId);
  }

  const resolveSession = clientToResolve.get(clientId);
  if (resolveSession) {
    resolveSession(sessions.get(clientId));
    clientToResolve.delete(clientId);
    clientToSessionPromise.delete(clientId);
  }
}

function requestSession(client: Client): Promise<SessionInfo | undefined> {
  const promise =
    clientToSessionPromise.get(client.id) ??
    new Promise((resolve) => {
      clientToResolve.set(client.id, resolve);
      client.postMessage({ type: 'requestSession' });
    });

  if (!clientToSessionPromise.has(client.id)) {
    clientToSessionPromise.set(client.id, promise);
  }

  return promise;
}

async function requestSessionWithTimeout(
  clientId: string,
  timeoutMs = 3000,
): Promise<SessionInfo | undefined> {
  const client = await self.clients.get(clientId);
  if (!client) return undefined;

  const sessionPromise = requestSession(client);

  const timeout = new Promise<undefined>((resolve) => {
    setTimeout(() => resolve(undefined), timeoutMs);
  });

  return Promise.race([sessionPromise, timeout]);
}

self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      await self.clients.claim();
      await cleanupDeadClients();
    })(),
  );
});

/**
 * Receive session updates from clients
 */
self.addEventListener('message', (event: ExtendableMessageEvent) => {
  const client = event.source as Client | null;
  if (!client) return;

  const { type, accessToken, baseUrl } = event.data || {};

  if (type === 'setSession') {
    setSession(client.id, accessToken, baseUrl);
    cleanupDeadClients();
  }
});

/**
 * N105: handle clicks on service-worker-owned notifications. This fires even
 * when the originating tab was closed (a page-level `Notification.onclick`
 * would not). Focus an existing app window and forward the target path so it
 * can route there; if no window is open, open the app.
 */
self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();
  const data = event.notification.data ?? {};
  const path = typeof data.path === 'string' ? data.path : undefined;

  event.waitUntil(
    (async () => {
      const windowClients = await self.clients.matchAll({
        type: 'window',
        includeUncontrolled: true,
      });
      const client = windowClients.find((c): c is WindowClient => 'focus' in c);
      if (client) {
        await client.focus();
        if (path) client.postMessage({ type: 'notificationClick', path });
        return;
      }
      // No app window open — open one at the app root (the client routes from
      // there). Router config (hash vs browser) is page-owned, so we don't try
      // to deep-link the URL here.
      if (self.clients.openWindow) {
        await self.clients.openWindow(self.registration.scope);
      }
    })(),
  );
});

const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];

function mediaPath(url: string): boolean {
  try {
    const { pathname } = new URL(url);
    return MEDIA_PATHS.some((p) => pathname.startsWith(p));
  } catch {
    return false;
  }
}

function validMediaRequest(url: string, baseUrl: string): boolean {
  return MEDIA_PATHS.some((p) => {
    const validUrl = new URL(p, baseUrl);
    return url.startsWith(validUrl.href);
  });
}

function fetchConfig(token: string): RequestInit {
  return {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    cache: 'default',
  };
}

self.addEventListener('fetch', (event: FetchEvent) => {
  const { url, method } = event.request;

  if (method !== 'GET' || !mediaPath(url)) return;

  const { clientId } = event;
  if (!clientId) return;

  const session = sessions.get(clientId);
  if (session) {
    if (validMediaRequest(url, session.baseUrl)) {
      event.respondWith(fetch(url, fetchConfig(session.accessToken)));
    }
    return;
  }

  event.respondWith(
    requestSessionWithTimeout(clientId).then((s) => {
      if (s && validMediaRequest(url, s.baseUrl)) {
        return fetch(url, fetchConfig(s.accessToken));
      }
      return fetch(event.request);
    }),
  );
});
