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

export type {};
declare const self: ServiceWorkerGlobalScope;

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: unknown, baseUrl: unknown) {
  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);

  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<undefined>((resolve) => {
    timer = setTimeout(() => resolve(undefined), timeoutMs);
  });

  const result = await Promise.race([sessionPromise, timeout]);
  if (timer !== undefined) clearTimeout(timer);

  if (!result) {
    // Handshake missed: the client didn't reply within timeoutMs (or replied
    // with no session). Drop the cached pending promise + resolver so the NEXT
    // media request issues a FRESH requestSession instead of re-racing this
    // already-settled (undefined) promise. Without this, a single first-load
    // miss poisons the cache and every later media request falls back to an
    // unauthenticated fetch → permanent 401 until the page reloads. If the
    // real session arrives late, setSession() still stores it in `sessions`,
    // so the next request takes the fast cache-hit path above anyway.
    clientToResolve.delete(clientId);
    clientToSessionPromise.delete(clientId);
  }

  return result;
}

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

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

// ────────────────────────────────────────────────────────────────────
// In-app language bridge
//
// Main thread mirrors i18next's current language to the SW via
// `setLanguage` postMessage (handled just below); the SW persists it
// in IndexedDB so a cold wake (process restart) still knows the
// user's pref before any client connects. Fallback chain if the
// bridge never fired: IDB → navigator.language → 'en' (matches
// i18n.ts fallbackLng).
// ────────────────────────────────────────────────────────────────────

type SupportedLang = 'en' | 'ru';

// Three-state: `undefined` = not yet checked, `null` = checked and
// absent in IDB (→ use navigator.language), `'en' | 'ru'` = resolved.
let swLanguage: SupportedLang | null | undefined;

const LANG_DB_NAME = 'vojo-sw-meta';
const LANG_DB_STORE = 'kv';
const LANG_DB_KEY = 'language';

function openLangDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(LANG_DB_NAME, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(LANG_DB_STORE)) {
        db.createObjectStore(LANG_DB_STORE);
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function readStoredLang(): Promise<SupportedLang | null> {
  try {
    const db = await openLangDb();
    return await new Promise<SupportedLang | null>((resolve) => {
      const tx = db.transaction(LANG_DB_STORE, 'readonly');
      const req = tx.objectStore(LANG_DB_STORE).get(LANG_DB_KEY);
      req.onsuccess = () => {
        const val = req.result;
        resolve(val === 'en' || val === 'ru' ? val : null);
      };
      req.onerror = () => resolve(null);
    });
  } catch {
    return null;
  }
}

async function writeStoredLang(lang: SupportedLang): Promise<void> {
  try {
    const db = await openLangDb();
    await new Promise<void>((resolve) => {
      const tx = db.transaction(LANG_DB_STORE, 'readwrite');
      tx.objectStore(LANG_DB_STORE).put(lang, LANG_DB_KEY);
      tx.oncomplete = () => resolve();
      tx.onerror = () => resolve();
      tx.onabort = () => resolve();
    });
  } catch {
    /* non-fatal — `swLanguage` still holds the value in-memory */
  }
}

function normalizeLang(raw: string | undefined | null): SupportedLang {
  if (!raw) return 'en';
  const lower = raw.toLowerCase();
  if (lower.startsWith('ru')) return 'ru';
  // i18n.ts sets fallbackLng: 'en' — match that so the SW stays in lock
  // step with what the main app renders for unfamiliar locales. English
  // is a more useful lingua franca than Russian for a user whose device
  // is in some other language entirely.
  return 'en';
}

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

  const data = event.data || {};
  const { type } = data;

  if (type === 'setSession') {
    setSession(client.id, data.accessToken, data.baseUrl);
    cleanupDeadClients();
    return;
  }
  if (type === 'setLanguage') {
    const lang = normalizeLang(data.lang);
    swLanguage = lang;
    event.waitUntil(writeStoredLang(lang));
  }
});

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);
    })
  );
});

// --- Push Notifications ---

// Sygnal's WebPush pushkin ships the Matrix push object flat at the top level
// (`{room_id, event_id, unread, prio}`) — no `notification` wrapper. The 4KB
// WebPush payload limit pushed Sygnal to drop the extra nesting. Other push
// gateways (or older Sygnal builds) still wrap — we accept both shapes so the
// SW doesn't silently drop pushes after a gateway swap.
type PushPayload = {
  event_id?: string;
  room_id?: string;
  unread?: number;
  prio?: 'high' | 'low';
  notification?: {
    event_id?: string;
    room_id?: string;
    sender?: string;
    sender_display_name?: string;
    room_name?: string;
    type?: string;
    content?: { body?: string; msgtype?: string };
    counts?: { unread?: number; missed_calls?: number };
    prio?: 'high' | 'low';
  };
};

// `WindowClient.focused` requires OS-level window focus on top of the tab being
// the active one — so Vojo visually open on monitor A while the user types in
// another window on monitor B reads as `focused: false`, and we'd double-notify
// a user who is looking right at the app. Relaxing to visibility-only treats
// "tab is on-screen" as "don't spam me", which matches the intuitive UX.
// Trade-off: a tab that's visually visible but the user isn't actually looking
// at (side-by-side layouts, background-visible panes) will also suppress OS
// notifications — acceptable for MVP; revisit if users report missed pings.
async function hasVisibleClient(): Promise<boolean> {
  const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
  return clients.some((c) => c.visibilityState === 'visible');
}

async function anySession(): Promise<SessionInfo | undefined> {
  const clients = await self.clients.matchAll({ type: 'window' });
  const existing = clients.map((c) => sessions.get(c.id)).find((s): s is SessionInfo => !!s);
  if (existing) return existing;
  if (clients.length > 0) {
    return requestSessionWithTimeout(clients[0].id);
  }
  return undefined;
}

// Fallback strings for when we can't fetch event content (offline / encrypted /
// no session). Single source of truth is public/locales/{en,ru}.json under
// the `Push` namespace — the same files i18next loads on the web. Android
// mirrors those keys into res/values{,-ru}/push_strings.xml at build time
// (scripts/gen-push-strings.mjs); this SW loader stays on the same source
// at runtime instead of holding a hand-maintained copy that would drift.
//
// The chosen language tracks the main-thread i18next pref via the
// setLanguage postMessage bridge + IndexedDB persistence (see
// `In-app language bridge` block above). If the bridge never fired
// (fresh SW with no controller interaction yet), we fall through to
// normalize(navigator.language) → 'en'.
type PushFallback = {
  brand: string;
  newMessage: string;
  encrypted: string;
  incomingCall: string;
  openToAnswer: string;
  invitation: string;
  inviteBody: (args: { inviter?: string; roomName?: string }) => string;
};

// Minimal hardcoded safety-net used only when the JSON bundle can't be
// fetched (cold SW wake with no Cache entry + offline, or a 5xx from the
// static hosting). Rich wording lives in public/locales/{lang}.json.
type HardcodedStrings = {
  brand: string;
  newMessage: string;
  encrypted: string;
  incomingCall: string;
  openToAnswer: string;
  invitation: string;
  inviteGeneric: string;
};
const HARDCODED: Record<'en' | 'ru', HardcodedStrings> = {
  en: {
    brand: 'Vojo',
    newMessage: 'New message',
    encrypted: 'New encrypted message',
    incomingCall: 'Incoming call',
    openToAnswer: 'Open Vojo to answer',
    invitation: 'Invitation',
    inviteGeneric: 'New invitation',
  },
  ru: {
    brand: 'Vojo',
    newMessage: 'Новое сообщение',
    encrypted: 'Новое зашифрованное сообщение',
    incomingCall: 'Входящий звонок',
    openToAnswer: 'Откройте Vojo, чтобы ответить',
    invitation: 'Приглашение',
    inviteGeneric: 'Новое приглашение',
  },
};

type LocaleBundle = Record<string, string>;

// Successful bundle parse per language. Failures are NOT cached: a
// transient fetch 5xx on the first push shouldn't lock us to HARDCODED
// for the whole SW lifetime, and a successful re-fetch on the next push
// is cheap. Compare to the session cache — different policy on purpose.
const bundleCache = new Map<SupportedLang, LocaleBundle>();

async function currentSwLanguage(): Promise<SupportedLang> {
  if (swLanguage === 'en' || swLanguage === 'ru') return swLanguage;
  if (swLanguage === undefined) {
    const stored = await readStoredLang();
    swLanguage = stored; // `null` marks "checked, empty" — skip IDB next call
    if (stored) return stored;
  }
  return normalizeLang(typeof navigator !== 'undefined' ? navigator.language : undefined);
}

async function loadBundle(lang: SupportedLang): Promise<LocaleBundle | null> {
  const cached = bundleCache.get(lang);
  if (cached) return cached;
  try {
    // Scope-relative URL so a Vite `base` change (served from `/app/`
    // instead of `/`) doesn't silently break the fetch. Same physical
    // file as i18next loads on the main thread.
    const url = new URL(`public/locales/${lang}.json`, self.registration.scope).href;
    const res = await fetch(url);
    if (!res.ok) return null;
    const json = (await res.json()) as { Push?: LocaleBundle };
    const push = json?.Push;
    if (!push) return null;
    bundleCache.set(lang, push);
    return push;
  } catch {
    return null;
  }
}

function interpolate(template: string, vars: Record<string, string>): string {
  return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => vars[name] ?? '');
}

async function pushFallback(): Promise<PushFallback> {
  const lang = await currentSwLanguage();
  const bundle = await loadBundle(lang);
  const safety = HARDCODED[lang];
  const lookup = (key: string, fallback: string): string => {
    const value = bundle?.[key];
    return typeof value === 'string' && value.length > 0 ? value : fallback;
  };
  return {
    brand: safety.brand,
    newMessage: lookup('new_message', safety.newMessage),
    encrypted: lookup('encrypted_message', safety.encrypted),
    incomingCall: lookup('incoming_call', safety.incomingCall),
    openToAnswer: lookup('open_to_answer', safety.openToAnswer),
    invitation: lookup('invitation', safety.invitation),
    inviteBody: ({ inviter, roomName }) => {
      const hasInviter = !!inviter;
      const hasRoom = !!roomName;
      let key: string;
      if (hasInviter && hasRoom) key = 'invite_body';
      else if (hasInviter) key = 'invite_body_no_room';
      else if (hasRoom) key = 'invite_body_no_inviter';
      else key = 'invite_body_generic';
      const template = bundle?.[key];
      if (typeof template === 'string' && template.length > 0) {
        return interpolate(template, {
          inviter: inviter ?? '',
          roomName: roomName ?? '',
        });
      }
      // Safety-net: bundle missing or key absent. Keep the sentence
      // shape reasonable in both languages.
      if (hasInviter && hasRoom) {
        return lang === 'ru'
          ? `${inviter} приглашает вас в ${roomName}`
          : `${inviter} invited you to ${roomName}`;
      }
      if (hasInviter) {
        return lang === 'ru'
          ? `${inviter} приглашает вас в комнату`
          : `${inviter} invited you to a room`;
      }
      if (hasRoom) {
        return lang === 'ru' ? `Приглашение в ${roomName}` : `Invited you to ${roomName}`;
      }
      return safety.inviteGeneric;
    },
  };
}

function mxidLocalPart(mxid: string): string {
  if (!mxid.startsWith('@')) return mxid;
  const colon = mxid.indexOf(':');
  return colon > 1 ? mxid.slice(1, colon) : mxid.slice(1);
}

// Fallback path for invitees: /rooms/{id}/event/{id} is 404 and
// /rooms/{id}/state/... is 403 for users who have never joined. The
// Matrix spec carves out /notifications for exactly this case — it
// returns events the user was (or would have been) notified about,
// accessible before the join. Synapse also ships stripped invite state
// under event.unsigned.invite_room_state, which carries m.room.name
// and the inviter's m.room.member so we can render a humane banner
// without ever joining.
//
// One retry at 400ms: Synapse indexes a just-dispatched push slightly
// behind the push itself, and the first call sometimes misses the
// event. After the retry we fall through and the caller shows the
// generic fallback. limit=30 is comfortably larger than any realistic
// burst between push dispatch and SW wake.
type InviteLookup = { isInvite: boolean; inviter?: string; roomName?: string };
type NotifEntry = {
  room_id?: string;
  event?: {
    event_id?: string;
    type?: string;
    sender?: string;
    content?: { membership?: string };
    unsigned?: { invite_room_state?: Array<Record<string, unknown>> };
  };
};
type StrippedEvent = {
  type?: string;
  sender?: string;
  content?: { name?: string; displayname?: string };
};

async function tryFetchInvite(
  session: SessionInfo,
  roomId: string,
  eventId: string,
  headers: { Authorization: string }
): Promise<{ kind: 'result'; value: InviteLookup } | { kind: 'miss' } | { kind: 'error' }> {
  let res: Response;
  try {
    res = await fetch(`${session.baseUrl}/_matrix/client/v3/notifications?limit=30`, { headers });
  } catch {
    return { kind: 'error' };
  }
  if (!res.ok) return { kind: 'error' };
  let json: { notifications?: NotifEntry[] };
  try {
    json = (await res.json()) as { notifications?: NotifEntry[] };
  } catch {
    return { kind: 'error' };
  }
  const entries = Array.isArray(json?.notifications) ? json.notifications : [];
  const hit = entries.find((n) => n?.room_id === roomId && n?.event?.event_id === eventId);
  if (!hit) return { kind: 'miss' };
  const ev = hit.event;
  if (
    ev?.type !== 'm.room.member' ||
    ev?.content?.membership !== 'invite' ||
    typeof ev?.sender !== 'string'
  ) {
    return { kind: 'result', value: { isInvite: false } };
  }
  const rawStripped = ev?.unsigned?.invite_room_state;
  const stripped: StrippedEvent[] = Array.isArray(rawStripped)
    ? (rawStripped as StrippedEvent[])
    : [];
  const inviterMember = stripped.find(
    (s) =>
      s?.type === 'm.room.member' &&
      s?.sender === ev.sender &&
      typeof s?.content?.displayname === 'string' &&
      !!s.content.displayname.trim()
  );
  const nameState = stripped.find(
    (s) =>
      s?.type === 'm.room.name' && typeof s?.content?.name === 'string' && !!s.content.name.trim()
  );
  const inviter = inviterMember?.content?.displayname ?? mxidLocalPart(ev.sender);
  const roomName = nameState?.content?.name;
  return { kind: 'result', value: { isInvite: true, inviter, roomName } };
}

// Fallback path for invitees: /rooms/{id}/event/{id} is 404 and
// /rooms/{id}/state/... is 403 for users who have never joined. The
// Matrix spec carves out /notifications for exactly this case — it
// returns events the user was (or would have been) notified about,
// accessible before the join. Synapse also ships stripped invite state
// under event.unsigned.invite_room_state, which carries m.room.name
// and the inviter's m.room.member so we can render a humane banner
// without ever joining.
//
// One retry at 400ms: Synapse indexes a just-dispatched push slightly
// behind the push itself, and the first call sometimes misses the
// event. After the retry we fall through and the caller shows the
// generic fallback. limit=30 is comfortably larger than any realistic
// burst between push dispatch and SW wake.
async function fetchInviteFromNotifications(
  session: SessionInfo,
  roomId: string,
  eventId: string,
  headers: { Authorization: string }
): Promise<InviteLookup | undefined> {
  const first = await tryFetchInvite(session, roomId, eventId, headers);
  if (first.kind === 'error') return undefined;
  if (first.kind === 'result') return first.value;
  await new Promise<void>((r) => {
    setTimeout(r, 400);
  });
  const second = await tryFetchInvite(session, roomId, eventId, headers);
  return second.kind === 'result' ? second.value : undefined;
}

async function fetchEventDetails(
  session: SessionInfo,
  roomId: string,
  eventId: string
): Promise<{ title: string; body: string; isCall: boolean; isInvite: boolean }> {
  const headers = { Authorization: `Bearer ${session.accessToken}` };
  // /event/{id} and /state/... share the same "joined-or-former-joined"
  // access gate — for invitees both return 404/403 respectively. For
  // joined users they both succeed and the parallel fetch saves a round
  // trip; for invitees we fall through to /notifications below.
  const [evRes, nameRes] = await Promise.all([
    fetch(
      `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(
        roomId
      )}/event/${encodeURIComponent(eventId)}`,
      { headers }
    ),
    fetch(
      `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name/`,
      { headers }
    ).catch(() => undefined),
  ]);

  const fb = await pushFallback();
  let title = fb.brand;
  let body = fb.newMessage;
  let isCall = false;
  let isInvite = false;
  let roomName: string | undefined;
  let inviterDisplay: string | undefined;
  let inviterMxid: string | undefined;

  if (nameRes?.ok) {
    const json = await nameRes.json();
    if (typeof json?.name === 'string') roomName = json.name;
  }

  if (evRes.ok) {
    const event = await evRes.json();
    // RTC ring: `format: 'event_id_only'` strips `type` from the push payload,
    // so we can only identify a call after fetching the full event here. Match
    // the same pair as useIncomingRtcNotifications — any other rtc.notification
    // flavour (group "notification") falls through to the message path.
    if (
      event?.type === 'org.matrix.msc4075.rtc.notification' &&
      event?.content?.notification_type === 'ring'
    ) {
      isCall = true;
      title = fb.incomingCall;
      body = fb.openToAnswer;
    } else if (event?.type === 'm.room.member' && event?.content?.membership === 'invite') {
      // Invite path for an already-joined user (rare — re-invite after
      // leave, or multi-device where another session joined). The 404
      // path below handles the pre-join case.
      isInvite = true;
      if (typeof event?.sender === 'string') inviterMxid = event.sender;
    } else if (event?.type === 'm.room.encrypted') {
      body = fb.encrypted;
    } else if (typeof event?.content?.body === 'string') {
      body = event.content.body.slice(0, 200);
    }
  } else {
    // /event/{id} 404: invitee pre-join, or an event the user lacks
    // history-visibility for. /notifications is the only spec'd endpoint
    // that serves invite context here (state/* is also 403 for invitees).
    const invite = await fetchInviteFromNotifications(session, roomId, eventId, headers);
    if (invite?.isInvite) {
      isInvite = true;
      inviterDisplay = invite.inviter;
      if (invite.roomName) roomName = invite.roomName;
    }
  }

  if (isInvite && !inviterDisplay && inviterMxid) {
    // Joined-user invite path: look up the inviter's display name via
    // their member state. Failure is non-fatal — local-part fallback.
    try {
      const memberRes = await fetch(
        `${session.baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(
          roomId
        )}/state/m.room.member/${encodeURIComponent(inviterMxid)}`,
        { headers }
      );
      if (memberRes.ok) {
        const member = await memberRes.json();
        if (typeof member?.displayname === 'string' && member.displayname.trim()) {
          inviterDisplay = member.displayname;
        }
      }
    } catch {
      /* keep inviterDisplay undefined; local-part fallback below */
    }
    if (!inviterDisplay) inviterDisplay = mxidLocalPart(inviterMxid);
  }

  if (isInvite) {
    title = fb.invitation;
    body = fb.inviteBody({ inviter: inviterDisplay, roomName });
  } else if (isCall) {
    // For DM calls room_name is typically the peer's display name, so
    // surface it in body (title stays "Incoming call" as the primary signal).
    if (roomName) body = roomName;
  } else if (roomName) {
    title = roomName;
  }

  return { title, body, isCall, isInvite };
}

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

  event.waitUntil(
    (async () => {
      let payload: PushPayload = {};
      try {
        if (event.data) payload = event.data.json();
      } catch {
        payload = {};
      }

      const notif = payload.notification;
      const roomId = notif?.room_id ?? payload.room_id;
      const eventId = notif?.event_id ?? payload.event_id;

      // Defensive: if Sygnal sends a notification without event_id (e.g. unread-count-only
      // push, which `events_only: true` on the pusher should suppress but can slip through
      // if config drifts), skip it. We'd otherwise show a generic "New message" tied to no
      // room/event — clicking it goes nowhere useful.
      if (!eventId || !roomId) return;

      const fb = await pushFallback();
      let title = notif?.room_name ?? fb.brand;
      let body = notif?.content?.body ?? fb.newMessage;
      let isCall = false;
      let isInvite = false;

      // Fetch event details BEFORE the visible-client gate, because an
      // incoming-call ring must surface even when Vojo is already open in
      // another pane/monitor — we can only distinguish a ring from a regular
      // message by the event type, which is stripped from the push payload
      // (`format: 'event_id_only'`).
      const session = await anySession();
      if (session) {
        try {
          const details = await fetchEventDetails(session, roomId, eventId);
          title = details.title;
          body = details.body;
          isCall = details.isCall;
          isInvite = details.isInvite;
        } catch {
          // fall back to defaults; isCall/isInvite stay false and we show a
          // generic message notification. Cold-start (no live session → no
          // access token) lands here too — see techdebt 5.24.
        }
      }

      // Foreground dedup: a visible Vojo window already surfaces sound +
      // favicon in-app, so we skip the OS banner for messages and invites.
      // Calls are an explicit exception — a missed ring is a much higher-cost
      // failure than a duplicated banner.
      if (!isCall && (await hasVisibleClient())) return;

      if (isCall) {
        // Distinct tag so a call notification doesn't collide with a prior
        // message notification for the same room (message path uses `roomId`).
        // `requireInteraction` keeps it on-screen until the user acts — a ring
        // shouldn't auto-dismiss after a few seconds like a chat message.
        // `renotify: true` forces re-alert (sound + re-surface) when a second
        // ring arrives for the same room while the first notification is still
        // in the tray — without it Chrome silently replaces the old one.
        await self.registration.showNotification(title, {
          body,
          icon: '/res/android/android-chrome-192x192.png',
          badge: '/res/android/android-chrome-96x96.png',
          tag: `call_${roomId}`,
          data: { roomId, eventId, isCall: true },
          requireInteraction: true,
          renotify: true,
        } as NotificationOptions & { renotify?: boolean });
        return;
      }

      if (isInvite) {
        // Invite notifications route to the bare /direct/ panel — the row
        // lives inline in the Direct list and the user picks Accept/Decline
        // there. We intentionally don't deep-link into /direct/{roomId}/ for
        // invite-state rooms (Room.tsx has no useful render path for them
        // and on mobile the panel itself would be hidden). We still carry
        // `isInvite` in data so the client-side handler can branch on it.
        // Distinct tag namespace keeps invite banners from clobbering a
        // message notification for the same room (rare, but possible if
        // the invite event and a decrypted preview arrive close together).
        await self.registration.showNotification(title, {
          body,
          icon: '/res/android/android-chrome-192x192.png',
          badge: '/res/android/android-chrome-96x96.png',
          tag: `invite_${roomId}`,
          data: { roomId, eventId, isInvite: true },
          renotify: true,
        } as NotificationOptions & { renotify?: boolean });
        return;
      }

      await self.registration.showNotification(title, {
        body,
        icon: '/res/android/android-chrome-192x192.png',
        badge: '/res/android/android-chrome-96x96.png',
        tag: roomId,
        data: { roomId, eventId },
        renotify: true,
      } as NotificationOptions & { renotify?: boolean });
    })()
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const { roomId, isCall, isInvite } =
    (event.notification.data as {
      roomId?: string;
      isCall?: boolean;
      isInvite?: boolean;
    }) ?? {};

  event.waitUntil(
    (async () => {
      const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
      if (windows.length > 0) {
        const target = windows[0];
        await target.focus();
        // `isCall` is forwarded but unused by the client today — once live
        // session lands on the room, useIncomingRtcNotifications picks the
        // ring up from the timeline (or backfill). The 2.5.3b bridge will
        // consume this flag to auto-invoke the accept flow.
        target.postMessage({ type: 'notificationClick', roomId, isCall, isInvite });
        return;
      }
      // Cold-start path: no live window to hand off to, so pick the
      // destination up-front. Invites always land on the bare /direct/ panel
      // (their row sits at the top until the user accepts/declines) — we
      // intentionally don't deep-link into /direct/{roomId}/ for invite-state
      // rooms because Room.tsx has no membership gate and on mobile the
      // panel itself would be hidden, leaving no Accept/Decline UI on
      // screen. Everything else opens the room directly via the Home
      // redirect shim.
      let path = '/';
      if (isInvite) path = '/direct/';
      else if (roomId) path = `/home/${encodeURIComponent(roomId)}/`;
      await self.clients.openWindow(path);
    })()
  );
});

self.addEventListener('pushsubscriptionchange', ((event: ExtendableEvent) => {
  event.waitUntil(
    (async () => {
      const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
      windows.forEach((c) => c.postMessage({ type: 'pushSubscriptionChange' }));
    })()
  );
}) as EventListener);
