// Background service worker.
//
// Two jobs:
//   1. Owns the lifecycle of the offscreen document that holds the
//      Telnyx WebRTC connection (Desktop dial-mode). Creates / closes
//      it on demand and re-broadcasts status to all extension surfaces.
//   2. Relays popup ↔ content script messages where useful.
//
// All Desktop-mode wiring is gated on the popup having previously
// granted mic permission to the extension origin. The popup emits
// pd_desktop_enable once mic is granted and the user has flipped the
// dial-mode radio.

const OFFSCREEN_PATH = "offscreen.html";

// CRM host tab patterns the extension runs a panel on. Status broadcasts
// (offscreen/WebRTC state, mic grant, nightly diag) MUST reach every division's
// CRM tab — not just AIL/Impact. Before this, broadcasts targeted only
// impact.ailife.com, so the Liberty (Salesforce/Geopointe) panel never received
// offscreen status and was stuck showing "this browser (WebRTC) · not connected"
// even when the headset was fine. Both patterns are harmless in the other build
// (no such tabs exist). Add future divisions here. 2026-08-08.
const PD_CRM_TAB_URLS = [
  "https://*.impact.ailife.com/*",
  "https://libertynational.my.site.com/*",
];

// Track latest status from the offscreen doc so newly-arrived content
// scripts and popup instances can ask for the current state without
// waiting for the next status push.
let lastOffscreenStatus = { state: "absent" };

// Cached config the offscreen doc needs in order to mint a JWT and
// connect to Telnyx. Set when popup or content script enables Desktop
// mode; consumed by the offscreen doc on boot via pd_off_request_init.
// This indirection avoids a race where we'd push pd_off_init at the
// doc before its onMessage listener is bound.
let pendingOffscreenConfig = null;

chrome.runtime.onInstalled.addListener((details) => {
  console.log("Power Dialer installed/updated:", details?.reason || "");
  // v1.33.9.2 — Mark the offscreen audio layer version-unknown on install/update
  // so the next desktop-enable recreates it with CURRENT offscreen.js (self-heals
  // the stale-offscreen one-way-audio bug — Logan 2026-08-17). We deliberately do
  // NOT close the doc here: that could hit a live call. The guarded recreate at
  // desktop-enable (idle by definition) handles the teardown safely.
  try { chrome.storage.local.remove("pd_off_code_version"); } catch {}
  // v1.32.0 — Nightly self-diagnostics heartbeat.
  // Fires once every 24h and broadcasts to all impact.ailife.com tabs.
  // Content.js receives the broadcast and submits its current state +
  // offscreen state to /api/diag/submit. Gives us a snapshot per active
  // agent per day so we can spot regressions even when no specific
  // failure trigger fired.
  //
  // First fire is delayed by ~24h * a small jitter to avoid all installs
  // hitting our worker at the same wall-clock moment.
  try {
    chrome.alarms.create("pd-diag-heartbeat", {
      // delayInMinutes: 24h + random jitter up to 60min so installs
      // don't all hit at the same minute. 1440-1500 min spread.
      delayInMinutes: 1440 + Math.floor(Math.random() * 60),
      periodInMinutes: 24 * 60,
    });
  } catch (e) {
    console.warn("[pd-bg] couldn't create nightly diag alarm:", e?.message);
  }
});

// Also reinstall the alarm on every SW startup (alarms survive SW
// restarts but if the user manually cleared via chrome://extensions,
// we want to re-arm). chrome.alarms.create is idempotent for same name.
try {
  chrome.alarms?.create?.("pd-diag-heartbeat", {
    delayInMinutes: 1440 + Math.floor(Math.random() * 60),
    periodInMinutes: 24 * 60,
  });
} catch {}

// v1.32.16 — Offscreen watchdog (was 1-min in v1.32.14, now 10-15s).
// Chrome can reclaim the offscreen document under memory pressure or
// other lifecycle events, leaving us with no Telnyx SDK and a dead WS.
// Agents see "worker keeps disconnecting" and "audio doesn't work" —
// Rabecca had 25+ offscreen-unavailable diagnostics on 2026-06-17 even
// post-v1.32.15. The 1-min watchdog was too slow.
//
// chrome.alarms minimum periodInMinutes is 0.5 (30s) in MV3 production.
// We supplement with a setInterval in content.js for sub-30s checks on
// active tabs (where it matters). content.js can also request an
// immediate respawn via pd_request_offscreen_resurrect.
try {
  chrome.alarms?.create?.("pd-offscreen-watchdog", {
    delayInMinutes: 0.1,  // first check ~6s after SW spin-up
    periodInMinutes: 0.5, // then every 30s (alarm minimum)
  });
} catch {}

// v1.32.0 — Nightly heartbeat handler.
// When the alarm fires, broadcast to all impact tabs. Content.js's
// listener picks it up and runs __pdSendDiagnostics({ reason: "nightly" }).
chrome.alarms?.onAlarm?.addListener?.((alarm) => {
  if (alarm.name === "pd-diag-heartbeat") {
    try {
      chrome.tabs.query({ url: PD_CRM_TAB_URLS }, (tabs) => {
        void chrome.runtime.lastError;
        if (!tabs || !tabs.length) return;
        for (const t of tabs) {
          try {
            chrome.tabs.sendMessage(t.id, { type: "pd_request_nightly_diag" }, (_r) => {
              void chrome.runtime.lastError;
            });
          } catch {}
        }
      });
    } catch (e) {
      console.warn("[pd-bg] nightly diag broadcast failed:", e?.message);
    }
    return;
  }
  // v1.32.14 — Offscreen watchdog tick.
  if (alarm.name === "pd-offscreen-watchdog") {
    respawnOffscreenIfNeeded("watchdog").catch((err) => {
      console.warn("[pd-bg] offscreen watchdog respawn failed:", err?.message);
    });
    return;
  }
});

// v1.32.14 — Centralized offscreen resurrection.
// Called by both the periodic watchdog alarm AND content.js requests
// (pd_request_offscreen_resurrect). Idempotent — silently no-ops if
// the offscreen is already up or if we have no config to bring it
// back with (e.g. signed-out state, mobile-only park source).
//
// v1.33.2.4 — Added liveness ping. hasOffscreen() returns true even
// for a zombie document (exists but JS context wedged / Telnyx SDK
// dead). Six users (Cleo, Christine, mbowe, dgholtz, duchessmiller,
// ranashirer) submitted "offscreen-unavailable" diagnostics in the
// last week — their offscreen docs existed but content.js's
// pd_off_collect_diag never got a response. New behavior: if the doc
// exists, send pd_off_ping with a 3s timeout. If it doesn't pong, we
// treat it as zombie → closeDocument + ensureOffscreen.
async function respawnOffscreenIfNeeded(reason) {
  // Skip if we have no config to boot with. This is the "user is signed
  // out" or "Desktop mode never requested" case — the agent doesn't
  // want offscreen up and we shouldn't surprise them with one.
  if (!pendingOffscreenConfig) return;
  const hadDoc = await hasOffscreen();
  if (hadDoc) {
    // v1.33.2.4 — Liveness check. If the document exists, ping it.
    // Pong within 3s → healthy, no-op. No pong → kill + rebuild.
    let alive = false;
    try {
      const pongPromise = new Promise((resolve) => {
        try {
          chrome.runtime.sendMessage({ type: "pd_off_ping" }, (resp) => {
            const _ = chrome.runtime.lastError;  // swallow disconnect error
            resolve(!!(resp && resp.ok === true));
          });
        } catch {
          resolve(false);
        }
      });
      const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(false), 3000));
      alive = await Promise.race([pongPromise, timeoutPromise]);
    } catch {
      alive = false;
    }
    if (alive) return; // happy path — doc exists and responds
    console.warn(`[pd-bg] offscreen-resurrect (${reason}) — doc exists but unresponsive (no pong in 3s) → killing + recreating`);
    try { await closeOffscreen(); } catch (e) {
      console.warn(`[pd-bg] closeOffscreen during resurrect failed (ignoring):`, e?.message);
    }
    // Fall through to ensureOffscreen below.
  } else {
    console.log(`[pd-bg] offscreen-resurrect (${reason}) — pendingConfig present, doc absent → recreating`);
  }
  try {
    await ensureOffscreen();
    lastOffscreenStatus = { state: "connecting" };
    broadcastStatus({ type: "pd_off_status", state: "connecting" });
    // The offscreen.html boot will call pd_off_request_init on its
    // own and pull pendingOffscreenConfig — no need to push init here.
  } catch (e) {
    console.error(`[pd-bg] offscreen-resurrect (${reason}) failed:`, e?.message);
    lastOffscreenStatus = { state: "error", error: "Resurrect failed: " + e?.message };
    broadcastStatus({ type: "pd_off_status", state: "error", error: e?.message });
  }
}

// ── Offscreen document helpers ───────────────────────────────────────

async function hasOffscreen() {
  // Chrome has an explicit check; older versions need a fallback.
  if (chrome.offscreen?.hasDocument) {
    try { return await chrome.offscreen.hasDocument(); } catch {}
  }
  // Fallback: try to look up offscreen documents via getContexts (Chrome 116+)
  try {
    const url = chrome.runtime.getURL(OFFSCREEN_PATH);
    const contexts = await chrome.runtime.getContexts({
      contextTypes: ["OFFSCREEN_DOCUMENT"],
      documentUrls: [url],
    });
    return contexts.length > 0;
  } catch {}
  return false;
}

// ── v1.33.9.6 — Serialized offscreen lifecycle lock ──────────────────
// ALL create/close ops go through ONE promise chain so they can never
// overlap. The old model coalesced concurrent CREATES but did nothing to
// stop a closeDocument() from firing WHILE a createDocument() was still
// loading — Chrome then throws "Offscreen document closed before fully
// loading" and pd_desktop_enable fails. That collision happened right
// after an in-place update, when the self-heal recreate raced the 30s
// watchdog respawn or the token-change recycle. Because createDocument()
// only resolves once the doc is fully loaded, serializing create+close
// behind this chain guarantees a close always waits for an in-flight
// create to finish (and vice-versa) — the race can't occur.
let _offscreenOpChain = Promise.resolve();
function withOffscreenLock(label, fn) {
  const run = _offscreenOpChain.then(fn);
  // Keep the chain alive whether this op resolves or rejects; the caller
  // still sees the real result/error via `run`.
  _offscreenOpChain = run.then(() => {}, () => {});
  return run;
}

async function ensureOffscreen() {
  return withOffscreenLock("ensure", async () => {
    if (await hasOffscreen()) return;            // atomic check-and-create
    try {
      await chrome.offscreen.createDocument({
        url: OFFSCREEN_PATH,
        // AUDIO_PLAYBACK exempts the doc from autoplay restrictions so the
        // prospect's voice can play without a user gesture in the doc.
        // USER_MEDIA + WEB_RTC cover the mic stream + SIP registration.
        reasons: ["AUDIO_PLAYBACK", "USER_MEDIA", "WEB_RTC"],
        justification: "Holds the Telnyx WebRTC connection so prospect calls bridge directly into the agent's headset without keeping a separate browser tab open.",
      });
      // v1.33.9.2 — Stamp the code version this doc was created with, so a later
      // build can detect a stale-code offscreen and recreate it (self-heal audio).
      try { await chrome.storage.local.set({ pd_off_code_version: currentExtVersion() }); } catch {}
    } catch (err) {
      // Belt-and-suspenders: even serialized, if Chrome reports the doc
      // already exists, treat as success (the doc we wanted IS there).
      const msg = String(err?.message || err || "");
      if (!/single offscreen document|already.*created|already.*exists/i.test(msg)) {
        throw err;
      }
      console.log("[bg] ensureOffscreen: doc already exists, continuing.");
    }
  });
}

async function closeOffscreen() {
  return withOffscreenLock("close", async () => {
    if (!(await hasOffscreen())) return;         // atomic check-and-close
    try { await chrome.offscreen.closeDocument(); } catch (e) {
      console.warn("[bg] closeDocument failed:", e?.message);
    }
  });
}

// ── v1.33.9.2 — Self-healing audio layer ─────────────────────────────
// The offscreen doc runs whatever offscreen.js was loaded when it was CREATED.
// After an in-place extension update the visible UI refreshes but a still-running
// doc keeps serving the OLD offscreen.js → one-way audio (agent can't hear the
// customer; Logan 2026-08-17). The zombie watchdog misses it because the stale
// doc still answers pings. Fix: stamp the doc's code version at creation
// (ensureOffscreen), and at each desktop-enable (session start — idle) recreate
// the doc if its version != this build. NEVER tears down a live call.
function currentExtVersion() {
  try { return chrome.runtime.getManifest().version || ""; } catch { return ""; }
}

async function maybeRefreshStaleOffscreen(reason) {
  try {
    if (!(await hasOffscreen())) return;            // nothing running — ensureOffscreen makes fresh
    const cur = currentExtVersion();
    const got = await chrome.storage.local.get("pd_off_code_version");
    const docVer = got?.pd_off_code_version || null;
    if (docVer === cur) return;                      // already this build — healthy
    // Never nuke a live call. Enabling desktop is normally idle; if a call is up,
    // leave the stale doc and let the NEXT idle enable refresh it.
    const onCall = !!(lastOffscreenStatus && lastOffscreenStatus.callerNumber);
    if (onCall) {
      console.warn(`[bg] offscreen stale-code (doc=${docVer} vs ext=${cur}) but call active — deferring refresh (${reason})`);
      return;
    }
    console.warn(`[bg] offscreen stale-code (doc=${docVer} vs ext=${cur}) — recreating for self-heal (${reason})`);
    // v1.33.9.6 — go through the SERIALIZED closeOffscreen() (not a raw
    // closeDocument) so this teardown can't overlap a concurrent create
    // from the watchdog/token-recycle. ensureOffscreen() (called right
    // after in the enable flow) recreates with current code, and because
    // both share the lock the recreate is queued strictly after this close.
    try { await closeOffscreen(); } catch (e) { console.warn("[bg] stale-offscreen close failed:", e?.message); }
  } catch (e) {
    console.warn("[bg] maybeRefreshStaleOffscreen failed (non-fatal):", e?.message);
  }
}

// ── Message routing ──────────────────────────────────────────────────

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || !msg.type) return;

  // Popup or content script asks us to spin up the offscreen WebRTC
  // sandbox. Required payload: { token, workerBase }.
  if (msg.type === "pd_desktop_enable") {
    (async () => {
      try {
        // Cache the config first so the offscreen doc can pull it as soon
        // as its onMessage listener is up. Avoids a race where we'd push
        // pd_off_init before the listener exists.
        pendingOffscreenConfig = {
          token:      String(msg.token || "").trim(),
          workerBase: String(msg.workerBase || "https://app.goalpowerdialer.com").trim(),
        };
        // v1.33.9.2 — Self-heal: if a stale-code offscreen from a previous build
        // is still running (one-way-audio bug), recreate it with current code
        // before use. No-op when already current; skipped if a call is live.
        await maybeRefreshStaleOffscreen("desktop-enable");
        const wasAlready = await hasOffscreen();
        await ensureOffscreen();
        // If the doc was already there, push the new config now —
        // listener is guaranteed to be bound. If we just created the
        // doc, the bootstrap pd_off_request_init handler below will
        // fetch it as soon as offscreen.js boots.
        if (wasAlready) {
          chrome.runtime.sendMessage({
            type:       "pd_off_init",
            token:      pendingOffscreenConfig.token,
            workerBase: pendingOffscreenConfig.workerBase,
          }).catch(() => {});
        }
        // Optimistic — actual ready state comes back via pd_off_status
        lastOffscreenStatus = { state: "connecting" };
        sendResponse({ ok: true });
      } catch (err) {
        console.error("[bg] pd_desktop_enable failed:", err);
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true; // async response
  }

  // Offscreen doc on boot asks for its config. We hand back the cached
  // token + workerBase. If nothing's pending (rare — popup must enable
  // first), we reply with empty values and the offscreen doc surfaces
  // an error status.
  if (msg.type === "pd_off_request_init") {
    sendResponse({
      ok:         true,
      token:      pendingOffscreenConfig?.token || "",
      workerBase: pendingOffscreenConfig?.workerBase || "https://app.goalpowerdialer.com",
    });
    return false;
  }

  // Tear down. Triggered when user flips dial-mode back to phone or
  // signs out.
  if (msg.type === "pd_desktop_disable") {
    (async () => {
      try {
        // Polite tear-down first so the SDK closes the SIP socket and
        // hangs up any active call before we kill the doc.
        try {
          await chrome.runtime.sendMessage({ type: "pd_off_stop" });
        } catch {}
        await closeOffscreen();
        pendingOffscreenConfig = null;
        lastOffscreenStatus = { state: "absent" };
        // Broadcast the new "absent" state so panels stop showing the
        // Desktop status pip as "Ready".
        broadcastStatus({ type: "pd_off_status", state: "absent" });
        sendResponse({ ok: true });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // Anyone (panel, popup, offscreen doc itself) can ask for the latest
  // status. v1.29.23 — Now queries the offscreen for FRESH state on
  // every request instead of returning the in-memory cache. Without
  // this, the panel showed stale "connecting…" forever after every
  // Impact page nav because the offscreen had nothing new to broadcast
  // (already at "ready"). The cache only reflects state CHANGES, not
  // steady-state. Now: every query goes to the source of truth.
  if (msg.type === "pd_desktop_status") {
    (async () => {
      try {
        if (await hasOffscreen()) {
          const resp = await chrome.runtime.sendMessage({ type: "pd_off_status_request" });
          if (resp?.ok) {
            lastOffscreenStatus = {
              state:        resp.state,
              error:        resp.error || null,
              callerNumber: resp.callerNumber || null,
            };
          }
        }
      } catch {}
      sendResponse({ ok: true, status: lastOffscreenStatus });
    })();
    return true; // async response
  }

  // v1.33.4.4 — Pre-dial "ensure live" gate. content.js calls this right
  // before session.start so a silently-reclaimed or zombie offscreen is
  // recreated BEFORE the worker dials into it (otherwise the worker dials a
  // dead audio client and the agent gets a silent no-audio call). Re-caches
  // config first so it still works after a service-worker restart wiped
  // pendingOffscreenConfig, runs the ping-backed respawn, then returns FRESH
  // status so the dial gate decides on truth, not the stale mirror.
  if (msg.type === "pd_ensure_offscreen_live") {
    (async () => {
      try {
        if (msg.token) {
          pendingOffscreenConfig = {
            token:      String(msg.token || "").trim(),
            workerBase: String(msg.workerBase || pendingOffscreenConfig?.workerBase || "https://app.goalpowerdialer.com").trim(),
          };
        }
        await respawnOffscreenIfNeeded("pre-dial");
        let status = lastOffscreenStatus;
        try {
          if (await hasOffscreen()) {
            const resp = await chrome.runtime.sendMessage({ type: "pd_off_status_request" });
            if (resp?.ok) {
              status = { state: resp.state, error: resp.error || null, callerNumber: resp.callerNumber || null };
              lastOffscreenStatus = status;
            }
          }
        } catch {}
        sendResponse({ ok: true, status });
      } catch (err) {
        sendResponse({ ok: false, error: err.message, status: lastOffscreenStatus });
      }
    })();
    return true;
  }

  // v1.32.14 — content.js detected offscreen no-response. Trigger an
  // immediate respawn rather than waiting for the 60s watchdog. This
  // shortens the recovery window when Chrome reclaims the offscreen
  // document mid-session.
  if (msg.type === "pd_request_offscreen_resurrect") {
    (async () => {
      try {
        await respawnOffscreenIfNeeded("content-request");
        sendResponse({ ok: true });
      } catch (err) {
        sendResponse({ ok: false, error: err?.message || "respawn failed" });
      }
    })();
    return true; // async response
  }

  // ── v1.28.0: Plug-and-play microphone grant ───────────────────────
  // Panel button "🎤 Enable microphone" sends { type: "open-mic-grant" }.
  // We open mic-grant.html in a regular tab so it's impossible to miss.
  //
  // v1.31.15-stable — Replaced chrome.windows.create({type:"popup"}) with
  // chrome.tabs.create(). The popup-window flow failed silently for
  // multiple users: popups can open off-screen on multi-monitor setups,
  // get suppressed by Chrome enterprise/managed policies, or open
  // behind the active window where the user never sees them — and
  // none of those failures returned an error we could surface. A
  // foreground tab in the current window is reliable on every platform.
  if (msg.type === "open-mic-grant") {
    (async () => {
      try {
        const url = chrome.runtime.getURL("mic-grant.html");
        const tab = await chrome.tabs.create({
          url,
          active: true,
        });
        sendResponse({ ok: true, opened: true, tabId: tab && tab.id });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.31.15-stable — mic-grant.js asks us to close its own tab once it has
  // reported success/failure. We do it from here because chrome.tabs.remove
  // requires the tabs permission, which the page itself doesn't have.
  if (msg.type === "pd_mic_grant_close_self") {
    (async () => {
      try {
        const tabId = (sender && sender.tab && sender.tab.id) || null;
        if (tabId) await chrome.tabs.remove(tabId);
        sendResponse({ ok: true });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.31.15-stable — Open a chrome:// or extension-internal URL on behalf
  // of an extension page (mic-grant uses this to open Chrome's mic settings
  // when getUserMedia is silently denied; extension pages can't navigate
  // to chrome:// URLs themselves).
  if (msg.type === "pd_open_url") {
    (async () => {
      try {
        await chrome.tabs.create({ url: String(msg.url || ""), active: true });
        sendResponse({ ok: true });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.31.86 — Split-screen window opener for demo mode (Dustin spec).
  // Opens the mobile companion in a separate chrome.windows window
  // positioned to the right half of the screen, so a sales presenter
  // can show "this is what the dialer looks like on the agent's phone"
  // side-by-side with the desktop dialer on the Impact tab.
  //
  // Message body: { url, side?: "right" | "left" }
  // Returns: { ok: true, windowId } or { ok: false, error }
  //
  // Window uses chrome.windows.create with type="popup" (no tab strip,
  // dedicated frame, looks like the iPhone Safari portrait viewport).
  // Sized to ~40% width × 90% height, positioned right half of screen
  // by default. If a window with the same URL is already open, focuses
  // it instead of creating a duplicate.
  if (msg.type === "pd_open_split_window") {
    (async () => {
      try {
        const url = String(msg.url || "");
        const side = msg.side === "left" ? "left" : "right";
        // Check for existing window with this URL
        const existing = await chrome.windows.getAll({ populate: true });
        for (const w of existing) {
          if (!w.tabs) continue;
          for (const t of w.tabs) {
            if (t.url && t.url.split("?")[0] === url.split("?")[0]) {
              await chrome.windows.update(w.id, { focused: true });
              sendResponse({ ok: true, windowId: w.id, reused: true });
              return;
            }
          }
        }
        // Get current display size for positioning
        const displays = await chrome.system?.display?.getInfo?.() || [];
        const primary = displays.find((d) => d.isPrimary) || displays[0] || null;
        const sw = primary?.workArea?.width  || 1920;
        const sh = primary?.workArea?.height || 1080;
        const sx = primary?.workArea?.left   || 0;
        const sy = primary?.workArea?.top    || 0;
        const w  = Math.round(sw * 0.40);           // 40% of screen width
        const h  = Math.round(sh * 0.90);           // 90% of screen height
        const top = sy + Math.round(sh * 0.05);     // 5% margin from top
        const left = side === "right"
          ? sx + sw - w - Math.round(sw * 0.02)
          : sx + Math.round(sw * 0.02);
        const win = await chrome.windows.create({
          url,
          type: "popup",
          width: w,
          height: h,
          left,
          top,
          focused: true,
        });
        sendResponse({ ok: true, windowId: win.id, reused: false });
      } catch (err) {
        console.warn("[bg] pd_open_split_window failed:", err && err.message);
        sendResponse({ ok: false, error: err && err.message });
      }
    })();
    return true;
  }

  // The mic-grant page reports the outcome. We persist it and rebroadcast
  // so any open panel can hide its Enable button.
  if (msg.type === "pd_mic_granted") {
    chrome.storage.local.set({ pd_mic_granted: !!msg.granted }).catch(() => {});
    // Broadcast to content scripts (any Impact CRM tabs)
    chrome.tabs.query({ url: PD_CRM_TAB_URLS }, (tabs) => {
      for (const t of tabs) {
        chrome.tabs.sendMessage(t.id, {
          type: "pd_mic_granted_broadcast",
          granted: !!msg.granted,
          error: msg.error || null,
        }).catch(() => {});
      }
    });
    sendResponse({ ok: true });
    return false;
  }

  // v1.29.31 — Panel mic picker: enumerate audio input devices.
  // Returns list to the panel for dropdown population. Forwards to the
  // offscreen doc which has mic permission and can read device labels.
  if (msg.type === "pd_off_list_mics") {
    (async () => {
      try {
        if (!(await hasOffscreen())) {
          sendResponse({ ok: false, error: "offscreen not running (Mobile App mode?)" });
          return;
        }
        const resp = await chrome.runtime.sendMessage({ type: "pd_off_list_mics" });
        sendResponse(resp || { ok: false, error: "no response" });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.29.31 — Panel mic picker: apply the user's selection. Forwards
  // the deviceId to the offscreen, which swaps the track via
  // replaceTrack() so the change doesn't kill a live call.
  if (msg.type === "pd_off_set_mic") {
    (async () => {
      try {
        if (!(await hasOffscreen())) {
          // Even if offscreen isn't running, accept the choice — it'll
          // apply on the next pd_desktop_enable. Persistence lives in
          // chrome.storage on the panel side.
          sendResponse({ ok: true, deferred: true });
          return;
        }
        const resp = await chrome.runtime.sendMessage({
          type:     "pd_off_set_mic",
          deviceId: msg.deviceId || null,
        });
        sendResponse(resp || { ok: false, error: "no response" });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.29.31 — Panel speaker picker: enumerate audio output devices
  // and apply a chosen one via setSinkId on the offscreen's remote-audio.
  if (msg.type === "pd_off_list_speakers") {
    (async () => {
      try {
        if (!(await hasOffscreen())) {
          sendResponse({ ok: false, error: "offscreen not running (Mobile App mode?)" });
          return;
        }
        const resp = await chrome.runtime.sendMessage({ type: "pd_off_list_speakers" });
        sendResponse(resp || { ok: false, error: "no response" });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }
  if (msg.type === "pd_off_set_speaker") {
    (async () => {
      try {
        if (!(await hasOffscreen())) {
          sendResponse({ ok: true, deferred: true });
          return;
        }
        const resp = await chrome.runtime.sendMessage({
          type:     "pd_off_set_speaker",
          deviceId: msg.deviceId || null,
        });
        sendResponse(resp || { ok: false, error: "no response" });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.29.29 — Panel mic-mute button. Forward to the offscreen doc
  // which calls the Telnyx SDK's muteAudio/unmuteAudio on the active
  // agent leg. No-op if the offscreen isn't running (Mobile App mode).
  if (msg.type === "pd_off_mute") {
    (async () => {
      try {
        if (!(await hasOffscreen())) {
          sendResponse({ ok: false, error: "offscreen not running" });
          return;
        }
        await chrome.runtime.sendMessage({
          type:  "pd_off_mute_apply",
          muted: !!msg.muted,
        });
        sendResponse({ ok: true });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // The offscreen doc broadcasts its state changes here; we cache the
  // latest and re-broadcast to all extension surfaces so the panel +
  // popup can update their status pips without polling.
  if (msg.type === "pd_off_status") {
    lastOffscreenStatus = {
      state:        msg.state,
      error:        msg.error || null,
      callerNumber: msg.callerNumber || null,
    };
    broadcastStatus(msg);
    return false;
  }

  // v1.30.2 — Content script asks us to fetch the worker's version
  // stamp. Routing through background avoids two failure modes seen in
  // the wild:
  //   1) Content-script fetches get cancelled mid-flight when Impact
  //      navigates the page (TypeError "Failed to fetch")
  //   2) Cross-origin fetch from impact.ailife.com → app.goalpowerdialer.com
  //      can occasionally hit CORS edge cases in MV3
  // Background workers don't share the content script's lifecycle and
  // bypass CORS entirely (host_permissions covers it).
  if (msg.type === "pd_fetch_version") {
    (async () => {
      const workerBase = String(msg.workerBase || "https://app.goalpowerdialer.com")
        .replace(/\/+$/, "");
      const url = workerBase + "/static/extension-version.json";
      // Two tries with a 1.5s backoff — transient network glitches and
      // service-worker cold-starts are common on first boot.
      for (let attempt = 0; attempt < 2; attempt++) {
        try {
          const r = await fetch(url, { cache: "no-cache" });
          if (!r.ok) {
            sendResponse({ ok: false, error: `HTTP ${r.status}` });
            return;
          }
          const j = await r.json();
          sendResponse({ ok: true, data: j });
          return;
        } catch (err) {
          if (attempt === 0) {
            await new Promise((r) => setTimeout(r, 1500));
            continue;
          }
          sendResponse({ ok: false, error: err?.message || String(err) });
        }
      }
    })();
    return true;
  }

  // v1.31.61 — Worker-config Layer 2: fetch runtime config via background
  // worker (same lifecycle-isolation pattern as pd_fetch_version). Used by
  // content.js to fetch /api/runtime-config without being affected by
  // Impact's page-lifecycle "Failed to fetch" cancellations.
  if (msg.type === "pd_fetch_runtime_config") {
    (async () => {
      const workerBase = String(msg.workerBase || "https://app.goalpowerdialer.com")
        .replace(/\/+$/, "");
      const url = workerBase + "/api/runtime-config";
      for (let attempt = 0; attempt < 2; attempt++) {
        try {
          const r = await fetch(url, { cache: "no-cache" });
          if (!r.ok) { sendResponse({ ok: false, error: `HTTP ${r.status}` }); return; }
          const j = await r.json();
          sendResponse({ ok: true, data: j });
          return;
        } catch (err) {
          if (attempt === 0) { await new Promise((r) => setTimeout(r, 1500)); continue; }
          sendResponse({ ok: false, error: err?.message || String(err) });
        }
      }
    })();
    return true;
  }

  // v1.29.97 — Content script asks us to open chrome://extensions in a
  // new tab so the agent can click Reload after we've shown the update
  // banner. Content scripts can't open chrome:// URLs themselves.
  if (msg.type === "pd_open_extensions_page") {
    try {
      chrome.tabs.create({ url: "chrome://extensions" });
      sendResponse({ ok: true });
    } catch (err) {
      sendResponse({ ok: false, error: err?.message || String(err) });
    }
    return false;
  }

  // Content script asks us to capture a JPEG of the sender tab. Used by
  // the mobile-companion lead-info push so the agent's phone can show a
  // screenshot of the Impact lead detail page. Content scripts can't
  // call chrome.tabs.captureVisibleTab themselves — only background can.
  // v1.31.69 — Away Mode capture + Downloads save.
  if (msg.type === "pd_away_capture_and_save") {
    const tabId = sender?.tab?.id;
    if (!tabId) {
      sendResponse({ ok: false, error: "no sender tab id" });
      return false;
    }
    chrome.tabs.captureVisibleTab(
      sender.tab.windowId,
      { format: "jpeg", quality: 85 },
      (dataUrl) => {
        if (chrome.runtime.lastError) {
          sendResponse({ ok: false, error: chrome.runtime.lastError.message });
          return;
        }
        // Save to Downloads with structured filename.
        try {
          chrome.downloads.download(
            { url: dataUrl, filename: msg.filename || "awaymode_capture.jpg", saveAs: false },
            (downloadId) => {
              if (chrome.runtime.lastError) {
                sendResponse({ ok: false, error: chrome.runtime.lastError.message, dataUrl });
              } else {
                sendResponse({ ok: true, downloadId, filename: msg.filename });
              }
            }
          );
        } catch (e) {
          sendResponse({ ok: false, error: String(e && e.message), dataUrl });
        }
      }
    );
    return true; // async
  }

  if (msg.type === "pd_capture_screenshot") {
    const tabId = sender?.tab?.id;
    if (!tabId) {
      sendResponse({ ok: false, error: "no sender tab id" });
      return false;
    }
    // captureVisibleTab requires the tab to be in a focused window.
    // 80% JPEG quality keeps the data URL well under 1MB for typical
    // 1920×1080 screens — enough resolution to read every form field on
    // a phone, while small enough to fly through the worker's WebSocket
    // without noticeable lag.
    chrome.tabs.captureVisibleTab(
      sender.tab.windowId,
      { format: "jpeg", quality: 80 },
      (dataUrl) => {
        if (chrome.runtime.lastError) {
          sendResponse({ ok: false, error: chrome.runtime.lastError.message });
        } else {
          sendResponse({ ok: true, dataUrl });
        }
      }
    );
    return true; // keep the message channel open for async sendResponse
  }

  // Liberty adapter hot-reload (dev). Re-inject liberty-crm.js into the sender
  // tab WITHOUT a page reload, then tell content.js to repoint CRM + rebuild
  // its driver. Only the Liberty build ever sends this (via Ctrl+Shift+U);
  // liberty-crm.js is idempotent at module scope (no timers/listeners created
  // on load beyond a once-guarded hotkey), so re-injection is safe. Requires
  // the "scripting" permission (present in the Liberty manifest only).
  if (msg.type === "pd_hot_reload_adapter") {
    (async () => {
      try {
        const tabId = sender?.tab?.id;
        if (!tabId) { sendResponse({ ok: false, error: "no sender tab id" }); return; }
        if (!chrome.scripting?.executeScript) { sendResponse({ ok: false, error: "scripting API unavailable (add 'scripting' permission)" }); return; }
        await chrome.scripting.executeScript({ target: { tabId }, files: ["liberty-crm.js"] });
        let version = "?";
        try { version = chrome.runtime.getManifest().version; } catch {}
        try { await chrome.tabs.sendMessage(tabId, { type: "pd_liberty_adapter_reloaded", version }); } catch {}
        sendResponse({ ok: true, version });
      } catch (err) {
        sendResponse({ ok: false, error: err?.message || String(err) });
      }
    })();
    return true; // async
  }

  return false;
});

// v1.31.22 — Auto-reprovision offscreen JWT when the user switches
// accounts. Without this listener, signing out + signing in as a
// different account (e.g. switching to a test account
// dustinholtz@yahoo.com) leaves the offscreen running with the
// OLD token. Its credential cache + Telnyx SIP registration stay tied
// to the old user, so session.start under the new account fails with
// "No Telnyx WebRTC credential on file" because /api/desktop/jwt was
// never called under the new token to provision a fresh credential.
//
// Fix: watch pd_account for token changes. When the token flips, if
// the offscreen is currently up, tear it down and immediately recreate
// it with the new token. The new offscreen boot will call
// /api/desktop/jwt under the new account → fresh credential provisioned
// on that user's record → session.start succeeds.
chrome.storage.onChanged.addListener(async (changes, areaName) => {
  if (areaName !== "local") return;
  if (!changes.pd_account) return;

  const newToken = (changes.pd_account.newValue?.authToken
                 || changes.pd_account.newValue?.token
                 || "").trim();
  const oldToken = (changes.pd_account.oldValue?.authToken
                 || changes.pd_account.oldValue?.token
                 || "").trim();

  // Nothing to do if the token didn't actually change (e.g. just a
  // name/email update on the same account).
  if (newToken === oldToken) return;

  // Sign-out (or never-signed-in). Tear down without recreating.
  if (!newToken) {
    if (await hasOffscreen()) {
      try { await chrome.runtime.sendMessage({ type: "pd_off_stop" }); } catch {}
      await closeOffscreen();
      pendingOffscreenConfig = null;
      lastOffscreenStatus = { state: "absent" };
      broadcastStatus({ type: "pd_off_status", state: "absent" });
    }
    return;
  }

  // Token changed AND we're (or were) running an offscreen — recycle it
  // under the new token. Skip recreation if the old offscreen wasn't up
  // (e.g. user signed in fresh on the popup and hasn't picked Desktop
  // mode yet — pd_desktop_enable will fire that path normally).
  const offscreenIsUp = await hasOffscreen();
  if (!offscreenIsUp) {
    // Just refresh the cached config so the next pd_desktop_enable uses
    // the right token. No teardown needed.
    if (pendingOffscreenConfig) {
      pendingOffscreenConfig = { ...pendingOffscreenConfig, token: newToken };
    }
    return;
  }

  console.log("[bg] pd_account.token changed — recycling offscreen under new token");
  // Save the workerBase we were using so the recreate uses the same one.
  const workerBase = pendingOffscreenConfig?.workerBase
                  || "https://app.goalpowerdialer.com";
  try {
    try { await chrome.runtime.sendMessage({ type: "pd_off_stop" }); } catch {}
    await closeOffscreen();
  } catch (e) {
    console.warn("[bg] offscreen teardown on token change failed:", e?.message);
  }
  // Recreate fresh with the new token. ensureOffscreen + cached pending
  // config mirrors the pd_desktop_enable path exactly.
  pendingOffscreenConfig = { token: newToken, workerBase };
  try {
    await ensureOffscreen();
    lastOffscreenStatus = { state: "connecting" };
    broadcastStatus({ type: "pd_off_status", state: "connecting" });
  } catch (e) {
    console.error("[bg] offscreen recreate on token change failed:", e?.message);
    lastOffscreenStatus = { state: "error", error: "Offscreen recreate failed: " + e?.message };
    broadcastStatus({ type: "pd_off_status", state: "error", error: e?.message });
  }
});

// Broadcast a status payload to every content script + every open popup.
// chrome.runtime.sendMessage already reaches popup; for content scripts
// we have to enumerate tabs and sendMessage each one.
function broadcastStatus(msg) {
  // Popup (if open) listens via chrome.runtime.onMessage — broadcasting
  // by re-sending would loop, so we skip. The popup queries us via
  // pd_desktop_status when it opens.
  // Content scripts on Impact pages get the message via tabs:
  chrome.tabs.query({ url: PD_CRM_TAB_URLS }, (tabs) => {
    for (const t of tabs || []) {
      chrome.tabs.sendMessage(t.id, msg).catch(() => {});
    }
  });
}


// ─────────────────────────────────────────────────────────────────────
// v1.31.76 — Update detection module
//
// Polls /static/extension-version.json every 30 minutes (plus on
// startup + install) and writes { current, latest, checkedAt } to
// chrome.storage.local when a newer version is available. Content
// scripts react via chrome.storage.onChanged and render a banner.
//
// Uses chrome.alarms because MV3 service workers get terminated when
// idle; setTimeout/setInterval are unreliable.
// ─────────────────────────────────────────────────────────────────────

const PD_UPDATE_ALARM = "pd-update-check";
const PD_UPDATE_VERSION_URL = "https://app.goalpowerdialer.com/static/extension-version.json";

// v1.33.2 — 4-segment silent-update scheme. Only the first 3 segments
// (major.minor.patch) are significant for triggering an update notification
// to agents. A 4th-segment bump (e.g., 1.33.2 → 1.33.2.1) is a mid-day
// silent patch: it ships new code to /static/extension.zip + extension-
// version.json so any agent who reloads picks it up, but does NOT set
// pd_update_available — meaning no banner, no modal, no manager fatigue.
// To trigger a fleet-wide update notification, bump segment 1, 2, or 3.
// The 4th segment is reserved for "no nag" iterative fixes.
//
// Rationale: managers were burning out on the every-deploy update modal,
// even for trivial copy/CSS tweaks they would never notice. Silent
// patching solves "i refuse to update for the 5th time" by being honest
// about which changes deserve an interruption.
function pdCompareVersions(a, b) {
  const parse = (s) => String(s || "0").split(".").map((n) => parseInt(n, 10) || 0).slice(0, 3);
  const pa = parse(a);
  const pb = parse(b);
  const len = Math.max(pa.length, pb.length);
  for (let i = 0; i < len; i++) {
    const da = pa[i] || 0;
    const db = pb[i] || 0;
    if (da !== db) return da - db;
  }
  return 0;
}

async function pdCheckForUpdate() {
  try {
    const r = await fetch(PD_UPDATE_VERSION_URL, { cache: "no-cache" });
    if (!r.ok) {
      console.warn("[pd-update] fetch failed:", r.status);
      return;
    }
    const data = await r.json();
    const latest = String(data?.version || "").trim();
    const current = String(chrome.runtime.getManifest().version || "").trim();
    if (!latest || !current) return;
    if (pdCompareVersions(latest, current) > 0) {
      await chrome.storage.local.set({
        pd_update_available: {
          current,
          latest,
          checkedAt: Date.now(),
          zipUrl: data?.zipUrl || "/static/extension.zip",
        },
      });
      console.log("[pd-update] new version available:", current, "→", latest);
    } else {
      // Already up-to-date — clear any stale flag so the banner goes
      // away after the agent updates.
      const existing = await chrome.storage.local.get([
        "pd_update_available",
        "pd_update_dismissed_for",
      ]);
      const toRemove = [];
      if (existing.pd_update_available) toRemove.push("pd_update_available");
      // Also clear dismissed-for if it matches current — keeps storage tidy.
      if (existing.pd_update_dismissed_for && pdCompareVersions(existing.pd_update_dismissed_for, current) <= 0) {
        toRemove.push("pd_update_dismissed_for");
      }
      if (toRemove.length) await chrome.storage.local.remove(toRemove);
    }
  } catch (e) {
    console.warn("[pd-update] check failed:", e?.message);
  }
}

// Schedule the periodic check. First fire in 1 minute (so a freshly
// installed/reloaded extension surfaces an update notice quickly if
// it's already behind), then every 30 minutes.
chrome.alarms.create(PD_UPDATE_ALARM, { delayInMinutes: 1, periodInMinutes: 30 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === PD_UPDATE_ALARM) pdCheckForUpdate();
});

// Run on every service-worker boot (install, startup, after idle wake).
chrome.runtime.onInstalled.addListener(() => { pdCheckForUpdate(); });
chrome.runtime.onStartup.addListener(() => { pdCheckForUpdate(); });
// Belt-and-suspenders for the case where neither install nor startup
// fires at the right moment (service worker waking from idle).
pdCheckForUpdate();

// Content scripts can't open chrome:// URLs — they ask us to. We use
// chrome.tabs.create with the chrome://extensions URL, which is
// privileged but allowed from the extension's background context.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || msg.type !== "pd_open_extensions_page") return;
  chrome.tabs.create({ url: "chrome://extensions" }, () => {
    sendResponse({ ok: true });
  });
  return true; // async response
});

// Content scripts can also ask us to force-check (e.g. when the user
// clicks "Check now" or the modal opens). Triggers an immediate fetch
// independent of the alarm cadence.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || msg.type !== "pd_force_update_check") return;
  pdCheckForUpdate().then(() => sendResponse({ ok: true }));
  return true;
});
