// Offscreen WebRTC sandbox.
//
// Lives forever (as long as the extension is installed AND we hold an
// active reason via chrome.offscreen.createDocument). Loads the Telnyx
// WebRTC SDK, registers as a SIP client using a JWT minted by the worker,
// and auto-answers any prospect call routed here by the worker's
// session.start when the user picks Desktop dial-mode.
//
// Loaded as a MODULE script from offscreen.html. MV3's default CSP
// forbids inline <script> blocks in offscreen documents, so the SDK
// import has to happen here (in a same-origin external module) rather
// than inline. The IIFE wrapper used in earlier versions is gone since
// module scripts get their own scope automatically.
//
// Communication is exclusively via chrome.runtime messages with the
// background service worker:
//
//   FROM background:
//     pd_off_init   — { token, workerBase }  → boot up, mint JWT, connect
//     pd_off_stop   — { }                    → tear down WebRTC, hangup call
//     pd_off_status_request — { }            → reply with current status
//
//   TO background (broadcast as pd_off_status):
//     { state: "idle" | "connecting" | "ready" | "ringing" | "active" | "error",
//       error?: string,
//       callerNumber?: string }

// v1.29.71 — Silence the Telnyx SDK timer bug for real this time.
// v1.29.69's try/catch approach didn't work: console.timeEnd doesn't
// THROW on a missing timer, it logs a warning. The catch caught
// nothing and the warning kept spamming the console + Web Store
// error dashboard (still seeing it on Kevin's box on v1.29.70).
//
// New approach: track which labels console.time() has actually
// started in a Set. If console.timeEnd() is called for a label that
// was never started, silently skip — origTimeEnd is never called,
// so the warning is never emitted. Legitimate timers pass through
// untouched.
(() => {
  const activeTimers = new Set();
  const origTime    = console.time.bind(console);
  const origTimeEnd = console.timeEnd.bind(console);
  console.time = function patchedTime(label) {
    activeTimers.add(label);
    return origTime(label);
  };
  console.timeEnd = function patchedTimeEnd(label) {
    if (!activeTimers.has(label)) return; // never started — skip silently
    activeTimers.delete(label);
    return origTimeEnd(label);
  };
})();

// ── SDK import ──────────────────────────────────────────────────────
// Use a namespace import so we can defensively probe for the export
// shape (esbuild's CJS→ESM conversion may put TelnyxRTC under
// `default.TelnyxRTC` instead of as a named export).
import * as TelnyxModule from "./vendor/telnyx-webrtc.js";

let TelnyxRTC = null;
let sdkLoadError = null;
try {
  TelnyxRTC = TelnyxModule.TelnyxRTC
           || TelnyxModule.default?.TelnyxRTC
           || (typeof TelnyxModule.default === "function" ? TelnyxModule.default : null);
  if (typeof TelnyxRTC !== "function") {
    sdkLoadError = "TelnyxRTC export not found on bundled SDK module. " +
      "Available keys: " + Object.keys(TelnyxModule).join(", ") +
      (TelnyxModule.default ? " (default keys: " + Object.keys(TelnyxModule.default).join(", ") + ")" : "");
    TelnyxRTC = null;
  }
} catch (err) {
  sdkLoadError = "SDK import threw: " + (err?.message || String(err));
}
if (sdkLoadError) {
  console.error("[offscreen]", sdkLoadError);
}

const $ = (id) => document.getElementById(id);

// ── State ────────────────────────────────────────────────────────
let token        = null;     // user's auth token from the dashboard
let workerBase   = "";       // worker URL for /api/desktop/jwt
let micStream    = null;
let rtcClient    = null;
let activeCall   = null;
let currentState = "idle";
let currentError = null;
let lastCaller   = null;
// v1.32.2 — Multi-device per-device SIP credentials.
// content.js persists pd_device_id + pd_multi_device_env to
// chrome.storage.local during boot. We read them lazily in
// startConnection() and use them to decide which JWT endpoint to call:
//   - pd_multi_device_env=true + pd_device_id present →
//     POST /api/devices/jwt with { token, deviceId } → per-device SIP
//   - otherwise → POST /api/desktop/jwt with { token, device: "desktop" }
//     → legacy single-credential path (same as v1.32.1 and earlier)
// This is what unblocks two desktop installs on the same account from
// colliding on Telnyx SIP REGISTER — each gets its own credentialId
// and sipUsername.
let _deviceId       = null;
let _multiDeviceEnv = false;

// ── v1.31.98 — Self-diagnostics infrastructure ────────────────────
// Ring buffer of the last N [offscreen][diag] events captured by the
// v1.31.18 instrumentation. Replaces the "console-only" log behavior
// with both console.log AND buffer-push, so we can collect + submit
// the buffer to the worker on SDK failure.
//
// Buffer is bounded — oldest events evicted when full.
const _diagEventBuffer = [];
const _DIAG_BUFFER_CAP = 50;
function _diagPush(eventName, summary) {
  _diagEventBuffer.push({ t: Date.now(), evt: eventName, summary });
  while (_diagEventBuffer.length > _DIAG_BUFFER_CAP) _diagEventBuffer.shift();
}

// v1.33.5.1 — Ping keepalives (telnyx_rtc.ping/pong fire ~every 15s) were
// pushed into the 50-slot ring like any other event, so within ~12 min they
// evicted EVERY meaningful event before an agent could hit Send Diagnostics
// (Nathan Gay 2026-07-20: his buffer was 100% pings, the real story was only
// in navBuffer). We now COUNT pings instead of buffering them — the count +
// last-ping time still prove the SIP socket is alive, without the flood.
let _socketPingCount   = 0;
let _lastSocketPingAt  = 0;
function _isTelnyxKeepalive(evt, data) {
  if (evt !== "telnyx.socket.message" || !data || typeof data !== "object") return false;
  const m = data.method || data.type || data.event || "";
  return m === "telnyx_rtc.ping" || m === "telnyx_rtc.pong";
}

// JWT claims subset and the worker /api/desktop/jwt response, captured
// during startConnection() and read by _collectDiagnosticsPayload().
// We deliberately store ONLY the safe claims (sip_username, sub, iss,
// exp) — never the raw JWT, never the full token.
let _diagJwtClaimsSafe   = null;
let _diagWorkerJwtResp   = null;
let _diagLastSdkLoadErr  = null;
// Throttle: only one auto-submit per failure type per session.
const _diagAutoSubmitted = new Set();

// v1.33.2.4 — JWT auto-refresh state.
// When Telnyx returns -32001 ("JWT token authentication failed") the SDK
// happily keeps reconnecting with the same dead token forever, leaving
// the agent stuck. (Observed: amedlock@holtzagency.com sat in a 9-hour
// JWT-failed loop on 2026-06-22 — exp=2026-06-19 09:26 UTC, captured at
// 2026-06-22 13:11 UTC.) We mint a fresh JWT and rebuild the SDK on
// every -32001, throttled to one refresh per 30s so a Telnyx-side
// account block can't melt us into a tight refresh loop.
let _jwtRefreshing = false;
let _lastJwtRefreshAt = 0;
const _JWT_REFRESH_COOLDOWN_MS = 30_000;

// v1.33.3.2 — Watchdog gate. The forced "ready" reset runs once per
// offscreen lifetime regardless of how many startConnection calls
// happen (after a JWT refresh, for example).
let _pdReadyWatchdogStarted = false;

// v1.33.5.1 — PROACTIVE JWT refresh.
// The v1.33.2.4 refresh above is REACTIVE — it only fires after Telnyx
// rejects a dead token with -32001. Fleet diagnostics 2026-07-21 showed
// ~1/3 of sampled agents sitting on an already-expired JWT (jen.m.leone
// 44h past exp, sdk=error). The reactive path can't help if the SDK never
// emits -32001 (it just goes silent, or the socket dropped). So we now ALSO
// schedule a refresh a few minutes BEFORE the JWT's own exp, reusing the
// same tested _refreshJwtAndReconnect path — the dead-token window never
// opens. Deferred while a call is live so we never tear down mid-call.
let _jwtProactiveTimer = null;
const _JWT_REFRESH_LEAD_MS = 5 * 60 * 1000;   // refresh 5 min before exp
const _JWT_MIN_DELAY_MS    = 5 * 1000;

function _clearProactiveJwtTimer() {
  if (_jwtProactiveTimer) { try { clearTimeout(_jwtProactiveTimer); } catch {} _jwtProactiveTimer = null; }
}

function _scheduleProactiveJwtRefresh(expSec) {
  _clearProactiveJwtTimer();
  const exp = Number(expSec) || 0;
  if (!exp) return;                              // no exp claim → nothing to schedule
  // At schedule time (right after mint) msUntilExp ≈ the token's full lifetime.
  const msUntilExp = exp * 1000 - Date.now();
  let delay;
  if (msUntilExp <= _JWT_REFRESH_LEAD_MS + _JWT_MIN_DELAY_MS) {
    // Short-lived (or already-expired) token: refresh at ~80% of remaining
    // life instead of a fixed 5-min lead, so an unexpectedly short TTL can't
    // clamp us into a tight refresh loop. Floor at MIN_DELAY.
    delay = Math.max(_JWT_MIN_DELAY_MS, Math.floor(msUntilExp * 0.8));
  } else {
    delay = msUntilExp - _JWT_REFRESH_LEAD_MS;   // fire 5 min before expiry
  }
  _diagPush("jwt.proactive.scheduled", `in ${Math.round(delay / 1000)}s (exp in ${Math.round(msUntilExp / 1000)}s)`);
  _jwtProactiveTimer = setTimeout(function _fireProactiveRefresh() {
    // Never tear the SDK down during a live call — wait it out, recheck in 60s.
    if (activeCall) {
      _diagPush("jwt.proactive.deferred", "active call in progress");
      _jwtProactiveTimer = setTimeout(_fireProactiveRefresh, 60 * 1000);
      return;
    }
    _refreshJwtAndReconnect("proactive-pre-expiry");
  }, delay);
}

// ── Status broadcaster ───────────────────────────────────────────
function setStatus(state, opts = {}) {
  currentState = state;
  if (state === "error") currentError = opts.error || "Unknown error";
  if (state === "ready" || state === "idle") currentError = null;
  if (opts.callerNumber !== undefined) lastCaller = opts.callerNumber;
  const payload = {
    type:        "pd_off_status",
    state:       currentState,
    error:       currentError || undefined,
    callerNumber: lastCaller || undefined,
  };
  try { chrome.runtime.sendMessage(payload); } catch {}
  console.log(`[offscreen] state=${state}${currentError ? " err=" + currentError : ""}`);
}

// ── Audio attachment (mirrors site/static/desktop.js) ────────────
// The Telnyx SDK's `remoteElement` constructor option doesn't reliably
// attach the prospect's MediaStream for programmatically-answered
// calls. We attach manually via three redundant strategies:
//   1. RTCPeerConnection 'track' event listener (canonical WebRTC)
//   2. Snapshot of any stream already on the call object
//   3. setTimeout retry if the PC isn't ready yet
function attachRemoteAudio(call) {
  const audioEl = $("remote-audio");
  if (!audioEl || !call) return;

  const pc = call.peer?.instance
          || call.peer?.peerConnection
          || call.options?.peerConnection
          || null;

  if (pc && !call.__pdTrackBound) {
    call.__pdTrackBound = true;
    pc.addEventListener("track", (event) => {
      if (event.track.kind !== "audio") return;
      const stream = event.streams[0] || new MediaStream([event.track]);
      playStream(stream, "track-event");
    });
  }

  let stream = null;
  try {
    stream = call.remoteStream
          || call.options?.remoteStream
          || call.peer?.remoteStream
          || null;
  } catch {}
  if (!stream && pc?.getReceivers) {
    const tracks = pc.getReceivers()
      .map((r) => r.track)
      .filter((t) => t && t.kind === "audio");
    if (tracks.length) stream = new MediaStream(tracks);
  }
  if (stream) playStream(stream, "snapshot");

  if (!pc && !call.__pdRetryQueued) {
    call.__pdRetryQueued = true;
    setTimeout(() => {
      call.__pdRetryQueued = false;
      attachRemoteAudio(call);
    }, 800);
  }
}

// v1.33.4.1 — Track the in-flight play() request token so a new
// stream swap can recognize when an aborted play() is harmless vs
// when it's a real failure. Karla Ireta (via Sam Jaizan, 2026-06-25
// 20:43 UTC) hit "Audio play blocked: The play() request was
// interrupted by a new load request" — that's a recoverable race
// when the Telnyx SDK swaps the remote stream during bridge /
// re-INVITE / call-progression. Previously we bubbled the
// interruption straight to setStatus("error") and left the offscreen
// stuck in error state until a new SDK lifecycle event fixed it.
// Karla's UI then showed "Audio play blocked" forever; she was DONE
// for the day after that error.
let _playStreamToken = 0;

function playStream(stream, source) {
  const audioEl = $("remote-audio");
  if (!audioEl || !stream) return;
  if (audioEl.srcObject === stream) return;
  // Bump the token. Any in-flight play() promise from a previous
  // call will see its `myToken !== _playStreamToken` on resolve and
  // know it's been superseded.
  _playStreamToken += 1;
  const myToken = _playStreamToken;
  // Pause the old stream first if any. Pausing BEFORE swapping
  // srcObject avoids the "interrupted by new load" error from
  // Chrome's media element — it's the swap-during-loading that
  // causes it. With a clean pause, the new load starts fresh.
  try { audioEl.pause(); } catch {}
  audioEl.srcObject = stream;
  audioEl.muted = false;
  audioEl.volume = 1.0;
  // v1.32.10 — Rana Shirer 2026-06-16 report: set output to Bluetooth in
  // picker, but ring + call audio still routed through laptop speakers.
  // Root cause: previously we applied setSinkId once when the user picked
  // a speaker, but every time the SDK swapped the call's remote stream
  // (new call, re-INVITE, or after offscreen reload), the assignment was
  // lost back to the default sink. Re-apply the selected speaker on every
  // playStream call so the binding sticks across stream swaps.
  if (selectedSpeakerId && typeof audioEl.setSinkId === "function") {
    audioEl.setSinkId(selectedSpeakerId).catch((err) => {
      // v1.34 — A stale speaker deviceId (rotates after an extension toggle /
      // browser restart / headset reconnect) makes setSinkId reject, which
      // previously left audio pinned to a dead sink. Clear it so playback
      // falls back to the OS-default output instead of silence — same
      // resilience as the mic-acquisition fallback.
      console.warn("[offscreen] setSinkId failed (stale speaker id?) — falling back to default output:", err?.message);
      try { _diagPush && _diagPush("speaker.fell-back-to-default", "selected sink invalid; using OS default"); } catch {}
      selectedSpeakerId = null;
    });
  }
  audioEl.play()
    .then(() => console.log(`[offscreen] ✓ audio playing (${source})`))
    .catch((err) => {
      const msg = String(err?.message || err || "");
      const name = err?.name || "";
      // If a newer playStream call has superseded us, the abort is
      // expected — keep quiet.
      if (myToken !== _playStreamToken) {
        console.log(`[offscreen] audio.play() superseded (${source}) — token ${myToken} < ${_playStreamToken}, ignoring`);
        return;
      }
      // The "interrupted by new load request" error is a Chrome media
      // element race during back-to-back stream swaps. Recoverable —
      // the next playStream call will succeed. Don't put the offscreen
      // in error state for this class of failure.
      const isInterruptedByLoad = /interrupted by a new load/i.test(msg);
      const isAbortError = name === "AbortError";
      if (isInterruptedByLoad || isAbortError) {
        console.log(`[offscreen] audio.play() interrupted (recoverable, ${source}): ${msg}`);
        _diagPush && _diagPush("audio.play.interrupted-soft", `${source}: ${msg}`);
        return;
      }
      // NotAllowedError (autoplay policy): real failure, surface.
      // NotSupportedError, other: also real, surface.
      console.warn(`[offscreen] audio.play() rejected (${source}):`, name, msg);
      setStatus("error", { error: "Audio play blocked: " + msg });
    });
}

// ── Mic acquisition ──────────────────────────────────────────────
// v1.29.31 — `selectedMicId` is the deviceId the user chose in the
// panel's mic picker. When null, we use the OS default. Persisted on
// the content-script side via chrome.storage; pushed down to the
// offscreen on init or change.
let selectedMicId = null;

async function acquireMic() {
  if (micStream) return micStream;
  const baseAudio = {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true,
  };
  // v1.34 — Resilient mic acquisition. Previously we passed the user's
  // selected mic as deviceId:{ exact }. deviceIds ROTATE after an extension
  // toggle, a browser restart, or a headset reconnect — a stale `exact` id
  // throws OverconstrainedError, which killed mic capture ENTIRELY. The agent
  // then went silent outbound even though mic permission was fine, and the
  // catch reported the misleading "Mic not granted" message. (Casey England
  // 2026-08-04: toggled the extension off/on → deviceId went stale → both
  // directions silent.) Fix: try the selected device first, then fall back to
  // the OS default mic; only fail on a genuine permission denial.
  const attempts = [];
  if (selectedMicId) attempts.push({ ...baseAudio, deviceId: { exact: selectedMicId } });
  attempts.push(baseAudio); // default-device fallback
  let lastErr = null;
  for (let i = 0; i < attempts.length; i++) {
    try {
      micStream = await navigator.mediaDevices.getUserMedia({ audio: attempts[i], video: false });
      if (i > 0 && selectedMicId) {
        // We fell back off the selected device — its id is stale. Clear it so
        // we stop retrying a dead device, and record it in diagnostics.
        console.warn("[offscreen] selected mic deviceId stale/unavailable — using default mic");
        try { _diagPush && _diagPush("mic.fell-back-to-default", "selected deviceId invalid; using OS default"); } catch {}
        selectedMicId = null;
      }
      return micStream;
    } catch (err) {
      lastErr = err;
      const name = err && err.name;
      // Genuine permission/security denial — the default won't help either.
      if (name === "NotAllowedError" || name === "SecurityError" || name === "PermissionDeniedError") break;
      // OverconstrainedError / NotFoundError / etc. → fall through to default.
    }
  }
  throw new Error("Mic not granted to extension. Open the popup → Desktop mode → Grant mic. (" + (lastErr?.message || "unknown") + ")");
}

// v1.29.31 — Enumerate audio input devices. Labels are only populated
// if getUserMedia has been called at least once on this origin (which
// the offscreen has — see acquireMic). Returns an array suitable for
// rendering in a <select>.
async function listMics() {
  try {
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices
      .filter((d) => d.kind === "audioinput")
      .map((d) => ({
        deviceId: d.deviceId,
        label:    d.label || `Microphone ${d.deviceId.slice(0, 6)}…`,
        groupId:  d.groupId || null,
      }));
  } catch (err) {
    console.warn("[offscreen] enumerateDevices failed:", err);
    return [];
  }
}

// v1.29.31 — Enumerate audio OUTPUT devices for the speaker picker.
// Same gating as mic enumeration (labels only present post-mic-grant).
async function listSpeakers() {
  try {
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices
      .filter((d) => d.kind === "audiooutput")
      .map((d) => ({
        deviceId: d.deviceId,
        label:    d.label || `Speaker ${d.deviceId.slice(0, 6)}…`,
        groupId:  d.groupId || null,
      }));
  } catch (err) {
    console.warn("[offscreen] enumerateDevices (speakers) failed:", err);
    return [];
  }
}

// v1.29.31 — Selected speaker deviceId. Applied to the remote-audio
// element via setSinkId. Persists across audio element replacements.
let selectedSpeakerId = null;

async function applySpeaker(deviceId) {
  selectedSpeakerId = deviceId || null;
  const audioEl = $("remote-audio");
  if (!audioEl) return { ok: false, error: "no remote-audio element" };
  if (typeof audioEl.setSinkId !== "function") {
    return { ok: false, error: "setSinkId not supported in this browser" };
  }
  try {
    await audioEl.setSinkId(selectedSpeakerId || "");
    return { ok: true };
  } catch (err) {
    return { ok: false, error: err.message };
  }
}

// v1.33.2.4 — Tear down the SDK + retry startConnection. Used by the
// JWT auto-refresh path AND by the background heartbeat when offscreen
// recovers from a forced re-create. Keeping the mic stream alive across
// the rebuild saves the user a second permission prompt.
async function _refreshJwtAndReconnect(reason) {
  const now = Date.now();
  if (_jwtRefreshing) {
    _diagPush("jwt.autorefresh.skip", "already in progress");
    return;
  }
  if (now - _lastJwtRefreshAt < _JWT_REFRESH_COOLDOWN_MS) {
    _diagPush("jwt.autorefresh.skip", "cooldown active");
    return;
  }
  _jwtRefreshing = true;
  _lastJwtRefreshAt = now;
  _clearProactiveJwtTimer();   // v1.33.5.1 — startConnection() will re-arm from the fresh token
  _diagPush("jwt.autorefresh.start", String(reason || "unspecified"));
  try {
    // Mark connecting so the status UI doesn't show "error" through the
    // 250ms reconnect window — it would briefly flash to red in the
    // popup before going green.
    setStatus("connecting");
    try { activeCall?.hangup(); } catch {}
    activeCall = null;
    try { rtcClient?.disconnect?.(); } catch {}
    rtcClient = null;
    // Brief delay so the disconnect lifecycle settles before we mint and
    // bind a new JWT. Without this Telnyx occasionally serves the OLD
    // session and we get another -32001 immediately.
    await new Promise((r) => setTimeout(r, 250));
    await startConnection();
    _diagPush("jwt.autorefresh.ok", "rtcClient re-initialized");
  } catch (e) {
    _diagPush("jwt.autorefresh.fail", String(e?.message || e));
    setStatus("error", { error: "JWT auto-refresh failed: " + (e?.message || e) });
  } finally {
    _jwtRefreshing = false;
  }
}

// ── Boot the WebRTC connection ───────────────────────────────────
async function startConnection() {
  if (rtcClient) return; // idempotent
  if (!token) {
    setStatus("error", { error: "No auth token. Sign in via the popup first." });
    return;
  }
  if (!TelnyxRTC) {
    setStatus("error", {
      error: "Telnyx SDK didn't load. " + (sdkLoadError || "Unknown SDK error.") +
             " Re-run wrangler deploy with esbuild + @telnyx/webrtc installed in worker/.",
    });
    return;
  }

  setStatus("connecting");

  // Step 1 — acquire mic
  try {
    await acquireMic();
  } catch (err) {
    setStatus("error", { error: err.message });
    return;
  }

  // v1.32.2 — Step 2a: refresh the device-state cache from storage.
  // content.js writes pd_device_id + pd_multi_device_env at boot; we
  // re-read on every startConnection in case the agent has signed into
  // a different env (staging vs prod workerBase swap).
  try {
    const data = await chrome.storage.local.get([
      "pd_device_id",
      "pd_multi_device_env",
      "pd_device_nickname",
    ]);
    _deviceId       = (data && typeof data.pd_device_id === "string" && data.pd_device_id.length > 0) ? data.pd_device_id : null;
    _multiDeviceEnv = !!(data && data.pd_multi_device_env);
    console.log("[offscreen][diag] device-state:", {
      deviceId:       _deviceId ? _deviceId.slice(0, 8) + "…" : null,
      multiDeviceEnv: _multiDeviceEnv,
    });
    _diagPush("device-state.loaded", `multi=${_multiDeviceEnv} hasDeviceId=${!!_deviceId}`);
  } catch (_e) {
    _deviceId = null;
    _multiDeviceEnv = false;
  }

  // Step 2b — mint a JWT via the worker
  let jwt = null;
  // v1.31.19 — hoisted so the diagnostic block below can read these.
  let _jwtBody = {};
  // v1.32.2 — Choose the right endpoint:
  //   - per-device (/api/devices/jwt) when we have BOTH a deviceId AND
  //     the multi-device flag — gives this install its OWN SIP creds
  //   - legacy single-credential (/api/desktop/jwt) otherwise — same
  //     behavior as v1.32.1 and earlier; one SIP user per account
  //
  // If /api/devices/jwt returns 404 (device not yet registered — race
  // with content.js's registerThisDevice()), we silently register the
  // device ourselves and retry ONCE. If that still fails, we fall back
  // to /api/desktop/jwt so the agent can still place calls — never let
  // multi-device complications block a call.
  const _wantPerDevice = !!(_multiDeviceEnv && _deviceId);
  const _baseUrl       = (workerBase || "https://app.goalpowerdialer.com").replace(/\/+$/, "");

  async function _mintLegacy() {
    // v1.29.8 — Identify as the desktop device so the worker provisions
    // (or returns) the desktop-specific SIP credential. The mobile site
    // uses device:"mobile" with a different credential, so both clients
    // can stay registered concurrently.
    const res = await fetch(_baseUrl + "/api/desktop/jwt", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token, device: "desktop" }),
    });
    const body = await res.json().catch(() => ({}));
    return { res, body, endpoint: "/api/desktop/jwt" };
  }

  async function _mintPerDevice() {
    const res = await fetch(_baseUrl + "/api/devices/jwt", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token, deviceId: _deviceId }),
    });
    const body = await res.json().catch(() => ({}));
    return { res, body, endpoint: "/api/devices/jwt" };
  }

  async function _ensureRegistered() {
    // Idempotent. content.js usually registers on its own boot, but the
    // offscreen-vs-content race can land us here first on a brand-new
    // install. We make a best-effort POST and ignore errors — the
    // /api/devices/jwt retry will surface any real problem.
    try {
      let nick = "";
      try {
        const d = await chrome.storage.local.get(["pd_device_nickname"]);
        nick = (d && typeof d.pd_device_nickname === "string") ? d.pd_device_nickname : "";
      } catch (_e) {}
      await fetch(_baseUrl + "/api/devices/register", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ token, deviceId: _deviceId, name: nick }),
      });
    } catch (_e) {
      // Swallow — caller will fall back to legacy if the retry mint fails.
    }
  }

  try {
    let attempt;
    if (_wantPerDevice) {
      attempt = await _mintPerDevice();
      // 404 = device not registered yet (or stale cached deviceId after
      // server-side cleanup). v1.32.19 — clear the stale deviceId AND
      // register fresh, then retry. Previously we re-registered with the
      // same bad ID then silently fell back to legacy (causing SIP user
      // collisions with mobile PWA — Lannie 2026-06-18).
      if (attempt.res.status === 404) {
        console.log("[offscreen][diag] /api/devices/jwt 404 — clearing stale deviceId + re-registering");
        _diagPush("jwt-mint.404-clear-and-retry", "");
        try { await chrome.storage.local.remove("pd_device_id"); } catch {}
        _deviceId = null;
        // Re-acquire fresh deviceId — content.js may have re-generated by now
        try {
          const data = await chrome.storage.local.get(["pd_device_id"]);
          _deviceId = (data && typeof data.pd_device_id === "string" && data.pd_device_id.length > 0) ? data.pd_device_id : null;
        } catch {}
        if (_deviceId) {
          await _ensureRegistered();
          attempt = await _mintPerDevice();
        }
      }
      // v1.32.21 — ROLLED BACK the strict v1.32.19 refusal. That guard
      // broke connection entirely when per-device JWT failed for any
      // reason (transient KV race, missing device record, 5xx, network
      // blip): extension Start Session would flip Cancel for 1s then
      // bounce back, mobile would silently die after mic prompt.
      // Net effect: NOBODY could connect on staging.
      //
      // New behavior: per-device first, legacy as SOFT fallback. We log
      // the fallback to the diag buffer so we can still see in admin
      // diagnostics that the fallback was used, but we don't punish the
      // user by refusing to dial. The collision risk (Lannie's bug) is
      // still real but degraded routing > no routing.
      if (!attempt.res.ok || !attempt.body.ok) {
        const detail = attempt.body?.error || ("HTTP " + attempt.res.status);
        console.warn("[offscreen][diag] /api/devices/jwt failed — soft-falling-back to legacy:", detail);
        _diagPush("jwt-mint.per-device-failed-soft-fallback", detail);
        attempt = await _mintLegacy();
      } else if (attempt.body?.device?.legacyFallback) {
        // Server-side legacy fallback signal — multi-device flag may be
        // off on the worker. Log it but accept the credential the worker
        // just minted (the JWT itself is valid).
        console.warn("[offscreen][diag] worker returned legacyFallback=true — accepting (multi-device flag may be off)");
        _diagPush("jwt-mint.server-legacy-fallback-accepted", "");
      }
    } else {
      attempt = await _mintLegacy();
    }
    _jwtBody = attempt.body;
    if (!attempt.res.ok || !_jwtBody.ok) {
      const detail = _jwtBody.error || ("HTTP " + attempt.res.status);
      setStatus("error", { error: "JWT mint failed (" + attempt.endpoint + "): " + detail });
      return;
    }
    jwt = _jwtBody.jwt;
    console.log("[offscreen][diag] JWT minted via " + attempt.endpoint);
    _diagPush("jwt-mint.success", attempt.endpoint);
  } catch (err) {
    setStatus("error", { error: "Couldn't reach worker: " + err.message });
    return;
  }

  // v1.31.18 — DIAGNOSTIC INSTRUMENTATION ────────────────────────────
  // We see state=ready in offscreen but worker dials hang up in 1s with
  // cause=normal_clearing, and Telnyx Portal shows the connection as
  // "Unregistered". That implies SIP REGISTER never completed even
  // though the SDK's signaling websocket auth did. The block below logs
  // every TelnyxRTC event verbatim so we can see exactly what the SDK
  // hears from Telnyx — and decodes the JWT claims so we know which
  // credential the SDK is supposed to be registering as.
  let _diagJwtClaims = null;
  try {
    const _parts = String(jwt || "").split(".");
    if (_parts.length === 3) {
      let _payload = _parts[1].replace(/-/g, "+").replace(/_/g, "/");
      while (_payload.length % 4) _payload += "=";
      _diagJwtClaims = JSON.parse(atob(_payload));
    }
  } catch (e) {
    console.warn("[offscreen][diag] JWT decode failed:", e.message);
  }
  // v1.32.2 — Normalize across the two JWT response shapes:
  //   /api/desktop/jwt → { ok, jwt, credentialId, sipUsername, sipUri, device: "desktop" }
  //   /api/devices/jwt → { ok, jwt, device: { id, name, type, sipUsername, legacyFallback? }, sipUri }
  const _devObj = (_jwtBody.device && typeof _jwtBody.device === "object") ? _jwtBody.device : null;
  const _normCredentialId = _jwtBody.credentialId || (_devObj && _devObj.credentialId) || null;
  const _normSipUsername  = _jwtBody.sipUsername  || (_devObj && _devObj.sipUsername)  || null;
  const _normSipUri       = _jwtBody.sipUri       || null;
  const _normDeviceLabel  = _devObj
    ? `${_devObj.type || "?"}:${_devObj.id ? _devObj.id.slice(0, 8) : "?"}${_devObj.legacyFallback ? " (legacy-fallback)" : ""}`
    : (typeof _jwtBody.device === "string" ? _jwtBody.device : null);
  console.log("[offscreen][diag] worker JWT response (normalized):",
    { credentialId: _normCredentialId, sipUsername: _normSipUsername, sipUri: _normSipUri, device: _normDeviceLabel });
  console.log("[offscreen][diag] JWT claims:", _diagJwtClaims);
  // v1.31.98 — capture into self-diagnostics safe state (subset only).
  _diagWorkerJwtResp = {
    credentialId: _normCredentialId,
    sipUsername:  _normSipUsername,
    sipUri:       _normSipUri,
    device:       _normDeviceLabel,
  };
  if (_diagJwtClaims && typeof _diagJwtClaims === "object") {
    _diagJwtClaimsSafe = {
      sip_username: _diagJwtClaims.sip_username || null,
      sub:          _diagJwtClaims.sub          || null,
      iss:          _diagJwtClaims.iss          || null,
      exp:          _diagJwtClaims.exp          || null,
    };
  }
  _diagPush("worker.jwt-response", JSON.stringify(_diagWorkerJwtResp));

  // v1.33.5.1 — Arm the proactive refresh from this token's own exp so we
  // rotate BEFORE Telnyx would reject it, closing the dead-JWT window that
  // stranded ~1/3 of the fleet (diag 2026-07-21).
  try { _scheduleProactiveJwtRefresh(_diagJwtClaims && _diagJwtClaims.exp); } catch {}

  // Step 3 — connect the SDK
  try {
    rtcClient = new TelnyxRTC({
      login_token: jwt,
      remoteElement: "remote-audio",
    });

    // v1.31.18 — subscribe to every event we know TelnyxRTC emits so we
    // can see register/auth failures the SDK normally swallows. These
    // run in addition to the call-handling listeners below.
    const _diagEventNames = [
      "telnyx.socket.open",
      "telnyx.socket.close",
      "telnyx.socket.error",
      "telnyx.socket.message",
      "telnyx.error",
      "telnyx.ready",
      "telnyx.notification",
    ];
    for (const _evt of _diagEventNames) {
      try {
        rtcClient.on(_evt, (data) => {
          try {
            // v1.33.5.1 — Count keepalive pings instead of buffering them, so
            // real events aren't evicted from the 50-slot ring by ping churn.
            if (_isTelnyxKeepalive(_evt, data)) {
              _socketPingCount++;
              _lastSocketPingAt = Date.now();
              return;
            }
            let summary;
            if (data instanceof Error) {
              summary = data.name + ": " + data.message;
            } else if (data && typeof data === "object") {
              const _t = data.type || data.method || data.event || "";
              const _code = data.code || data.statusCode || "";
              const _reason = data.reason || data.error || data.message || "";
              const _callState = data?.call?.state || "";
              summary = JSON.stringify({
                t: _t, code: _code, reason: _reason, callState: _callState,
                keys: Object.keys(data).slice(0, 12),
              });
            } else {
              summary = String(data);
            }
            console.log("[offscreen][diag] " + _evt + " →", summary);
            // v1.31.98 — capture into self-diagnostics buffer too
            _diagPush(_evt, summary);
          } catch (e) {
            const failMsg = "(serialize failed: " + e.message + ")";
            console.log("[offscreen][diag] " + _evt + " " + failMsg);
            _diagPush(_evt, failMsg);
          }
        });
      } catch (e) {
        console.warn("[offscreen][diag] couldn't subscribe to " + _evt + ": " + e.message);
        _diagPush("(subscribe-failed)", _evt + ": " + e.message);
      }
    }

    rtcClient.on("telnyx.ready", () => {
      setStatus("ready");
    });

    rtcClient.on("telnyx.error", (err) => {
      const msg = err?.message || (typeof err === "string" ? err : JSON.stringify(err));
      setStatus("error", { error: "Telnyx error: " + msg });
      // v1.33.2.4 — Auto-refresh JWT on -32001 ("JWT token authentication
      // failed"). The Telnyx SDK doesn't refresh on its own — it just
      // keeps reconnecting with the same expired token. Without this
      // we ship agents on multi-day outages (amedlock 3 days,
      // 2026-06-22). Code may be nested under err.error per Telnyx's
      // JSON-RPC shape; fall through if missing.
      const errCode = err?.error?.code ?? err?.code ?? null;
      if (errCode === -32001) {
        _refreshJwtAndReconnect("telnyx.error code=-32001 (JWT auth failed)");
      }
    });

    rtcClient.on("telnyx.socket.close", () => {
      if (currentState !== "error") setStatus("idle");
    });

    rtcClient.on("telnyx.notification", (notification) => {
      if (notification.type !== "callUpdate") return;
      const call = notification.call;
      switch (call.state) {
        case "ringing":
          activeCall = call;
          lastCaller = call.options.remoteCallerNumber
                    || call.options.destinationNumber
                    || null;
          setStatus("ringing", { callerNumber: lastCaller });
          try { call.answer(); } catch (e) { console.error("[offscreen] answer failed:", e); }
          attachRemoteAudio(call);
          break;
        case "active":
          setStatus("active", { callerNumber: lastCaller });
          attachRemoteAudio(call);
          break;
        case "hangup":
        case "destroy": {
          const audioEl = $("remote-audio");
          if (audioEl) { try { audioEl.srcObject = null; } catch {} }
          activeCall = null;
          lastCaller = null;
          setStatus("ready");
          break;
        }
      }
    });

    rtcClient.connect();

    // v1.33.3.2 — Aggressive "ready" recovery. The premature-hangup defer
    // in content.js (v1.33.2.4) reads state.desktopStatus.state to decide
    // whether to wait for the SDK to settle. That mirror was only being
    // updated to "ready" from inside the telnyx.notification callUpdate
    // hangup/destroy handler above (line 670). But when the WORKER drives
    // the hangup (every bridge AMD-drop, every ring-timeout, every
    // worker-side cleanup), Telnyx may not emit a callUpdate locally —
    // so currentState stays "active" forever. content.js's defer then
    // fires on every subsequent prospect.hangup, runs for 3.6s, expires
    // with state still "active", then runs the legacy code with
    // leadId=null → sets pd_pending_no_answer_at → wrong-lead No-Answer
    // click on next page nav. Casey/Rabeckie/Teryn all hit this.
    //
    // Fix: every 2 seconds, if there's no activeCall AND currentState is
    // still "active", force-transition to "ready". The SDK's own state
    // is the truth; the mirror was lying.
    if (!_pdReadyWatchdogStarted) {
      _pdReadyWatchdogStarted = true;
      setInterval(() => {
        try {
          if (currentState === "active" && !activeCall) {
            _diagPush("auto-ready-reset",
              "no activeCall + currentState=active → forcing ready (v1.33.3.2)");
            setStatus("ready");
          }
        } catch (e) {
          _diagPush("auto-ready-reset.threw", e?.message || String(e));
        }
      }, 2000);
    }
    // Also: listen for socket-level "bye" messages. Telnyx sends
    // telnyx_rtc.bye over the WebSocket when a call ends, BEFORE
    // emitting the JS-level callUpdate hangup. Subscribe at the socket
    // level so we react to bye even if callUpdate is suppressed.
    rtcClient.on("telnyx.socket.message", (data) => {
      try {
        const t = data?.method || data?.type || "";
        if (t === "telnyx_rtc.bye") {
          _diagPush("socket-bye", "v1.33.3.2 socket-level bye → ready");
          activeCall = null;
          if (currentState !== "error" && currentState !== "idle") {
            setStatus("ready");
          }
        }
      } catch {}
    });

    // v1.31.18 — 5s health check. If the SDK is NOT in "ready" state by
    // then, REGISTER almost certainly failed silently and we should see
    // a telnyx.error / telnyx.socket.error in the diag logs above. If we
    // ARE ready but worker dials still hang up in 1s with normal_clearing,
    // it's a Telnyx-side routing problem (credential not associated with
    // a routable SIP identity), not an SDK problem.
    setTimeout(() => {
      const _sipFromJwt = _diagJwtClaims?.sip_username
                       || _diagJwtClaims?.sub
                       || "(unknown)";
      console.log("[offscreen][diag] +5s health check — state=" + currentState
                + " sipFromJwt=" + _sipFromJwt
                + " workerSip=" + (_jwtBody.sipUsername || "(none)"));
      _diagPush("5s-healthcheck", "state=" + currentState
                + " sipFromJwt=" + _sipFromJwt
                + " workerSip=" + (_jwtBody.sipUsername || "(none)"));
      if (currentState === "ready") {
        console.log("[offscreen][diag] SDK reports ready. inbound INVITEs should reach this client. " +
                    "if worker dials still hangup in 1s, the failure is Telnyx-side routing.");
      } else {
        console.warn("[offscreen][diag] SDK NOT ready 5s after connect — likely silent register failure. " +
                     "look for telnyx.error / telnyx.socket.error / telnyx.notification login events above.");
        // v1.31.98 — auto-submit self-diagnostics so we can root-cause
        // without asking the agent to open DevTools. Throttled to one
        // submission per session per failure type.
        if (!_diagAutoSubmitted.has("sdk-not-ready-5s")) {
          _diagAutoSubmitted.add("sdk-not-ready-5s");
          _submitDiagnostics({ reason: "auto:sdk-not-ready-5s" })
            .then((r) => console.log("[offscreen][diag] auto-submit result:", r))
            .catch((e) => console.warn("[offscreen][diag] auto-submit failed:", e?.message || e));
        }
      }
    }, 5000);
  } catch (err) {
    setStatus("error", { error: "SDK init failed: " + err.message });
  }
}

function stopConnection() {
  _clearProactiveJwtTimer();   // v1.33.5.1 — don't let a pending refresh revive a stopped SDK
  try { activeCall?.hangup(); } catch {}
  activeCall = null;
  try { rtcClient?.disconnect?.(); } catch {}
  rtcClient = null;
  if (micStream) {
    try { micStream.getTracks().forEach((t) => t.stop()); } catch {}
    micStream = null;
  }
  const audioEl = $("remote-audio");
  if (audioEl) { try { audioEl.srcObject = null; } catch {} }
  setStatus("idle");
}

// ── v1.31.98 — Self-diagnostics collect + submit ─────────────────
// Builds a structured payload describing the offscreen sandbox's
// current SDK / Telnyx / JWT state and the last 50 captured diag
// events, then POSTs to the worker's /api/diag/submit endpoint.
//
// Deliberately excludes: prospect numbers, lead IDs, transcripts,
// agent name, the raw JWT/token. Only telemetry about the extension's
// own state — never customer/prospect data.
//
// Auto-fired by the 5s health check on failure (throttled to one
// per session per failure type), or manually via pd_off_collect_diag
// message from content.js (Setup drawer "Send Diagnostics" button).
function _collectDiagnosticsPayload(extras) {
  let ua = "";
  try { ua = navigator.userAgent || ""; } catch {}
  let conn = null;
  try {
    if (navigator.connection) {
      conn = {
        effectiveType: navigator.connection.effectiveType || null,
        downlink:      navigator.connection.downlink      || null,
        rtt:           navigator.connection.rtt           || null,
        saveData:      navigator.connection.saveData      || false,
      };
    }
  } catch {}
  return {
    schemaVersion: 1,
    capturedAt:    new Date().toISOString(),
    extension:     {
      // version is set by content.js side before submit; offscreen
      // doesn't have direct access to chrome.runtime.getManifest in
      // every Chrome build, but we try anyway.
      version: (() => {
        try { return chrome.runtime.getManifest?.()?.version || null; } catch { return null; }
      })(),
    },
    env: {
      userAgent:        ua,
      navigatorConn:    conn,
      sdkLoadError:     _diagLastSdkLoadErr || sdkLoadError || null,
      hasTelnyxRTC:     !!TelnyxRTC,
    },
    sdk: {
      currentState,
      currentError,
      hasRtcClient:     !!rtcClient,
      hasActiveCall:    !!activeCall,
      hasMicStream:     !!micStream,
      // v1.33.5.1 — keepalive pings are counted, not buffered (see _diagPush).
      // These two still prove the SIP socket is alive without flooding the ring.
      socketPingCount:  _socketPingCount,
      lastSocketPingAt: _lastSocketPingAt || null,
    },
    jwt: {
      claimsSafe: _diagJwtClaimsSafe,           // sip_username, sub, iss, exp only
      workerResp: _diagWorkerJwtResp,           // credentialId, sipUsername, sipUri, device
    },
    diagEvents:   _diagEventBuffer.slice(),     // ring buffer copy
    extras:       (extras && typeof extras === "object") ? extras : null,
  };
}

async function _submitDiagnostics(extras) {
  if (!token || !workerBase) {
    return { ok: false, reason: "no-token-or-base" };
  }
  let payload;
  try {
    payload = _collectDiagnosticsPayload(extras);
  } catch (err) {
    return { ok: false, reason: "collect-failed: " + (err?.message || err) };
  }
  try {
    const r = await fetch(`${workerBase}/api/diag/submit?token=${encodeURIComponent(token)}`, {
      method:  "POST",
      headers: { "Content-Type": "application/json" },
      body:    JSON.stringify({ payload }),
    });
    let body = null;
    try { body = await r.json(); } catch {}
    if (!r.ok) {
      return { ok: false, status: r.status, body };
    }
    return { ok: true, key: body?.key || null };
  } catch (err) {
    return { ok: false, reason: "network: " + (err?.message || err) };
  }
}

// v1.33.3 — Audio test (mic + speaker loopback). Acquires the agent's
// chosen mic, plays it back through their chosen speaker, samples the
// peak dB level every 100ms, and runs for ~5s. Returns the final stats
// to content.js so it can render a VU meter + ask "Did you hear yourself?"
//
// Why not reuse the active call's stream: doesn't matter — acquireMic()
// is idempotent and returns the existing micStream. We create a SECOND
// hidden audio element for playback so we don't disrupt the live remote-
// audio element if a call is in progress. Most tests run in idle state
// anyway (agent is preflighting their headset before Start Session).
//
// Bluetooth profile detection: if peak dB stays near silence (-50 or
// lower) for the full test, the mic likely isn't capturing — common
// when iOS/Android Bluetooth headset is connected via A2DP instead of
// HSP/HFP. content.js surfaces this to the agent + auto-submits diag.
let _audioTestRunning = false;
async function runAudioTest({ durationMs = 5000 } = {}) {
  if (_audioTestRunning) return { ok: false, error: "test already in progress" };
  _audioTestRunning = true;
  let audioCtx = null;
  let intervalId = null;
  let testEl = null;
  try {
    await acquireMic();
    if (!micStream) {
      return { ok: false, error: "mic not acquired" };
    }
    // Get the mic track label for diag (helps spot Bluetooth issues)
    let micLabel = null;
    try { micLabel = micStream.getAudioTracks()[0]?.label || null; } catch {}

    // Hidden audio element for playback through chosen speaker.
    testEl = document.getElementById("audio-test-playback");
    if (!testEl) {
      testEl = document.createElement("audio");
      testEl.id = "audio-test-playback";
      testEl.autoplay = true;
      // playsInline so iOS Safari doesn't open a full-screen player
      testEl.setAttribute("playsinline", "");
      document.body.appendChild(testEl);
    }
    testEl.srcObject = micStream;
    if (selectedSpeakerId && typeof testEl.setSinkId === "function") {
      try { await testEl.setSinkId(selectedSpeakerId); } catch (e) {
        _diagPush("audio-test.setSinkId-failed", e?.message || "");
      }
    }
    try { await testEl.play(); } catch (e) {
      _diagPush("audio-test.play-rejected", e?.message || "");
    }

    // Audio analyser for the VU meter.
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    const source = audioCtx.createMediaStreamSource(micStream);
    const analyser = audioCtx.createAnalyser();
    analyser.fftSize = 256;
    source.connect(analyser);
    const dataArr = new Uint8Array(analyser.frequencyBinCount);

    let peakDb = -100;
    let sampleCount = 0;
    let silentSamples = 0;
    const start = Date.now();

    intervalId = setInterval(() => {
      analyser.getByteTimeDomainData(dataArr);
      // Compute peak amplitude in this frame.
      let peak = 0;
      for (let i = 0; i < dataArr.length; i++) {
        const v = Math.abs(dataArr[i] - 128) / 128;
        if (v > peak) peak = v;
      }
      const db = peak > 0 ? 20 * Math.log10(peak) : -100;
      if (db > peakDb) peakDb = db;
      sampleCount++;
      // Track "silent" samples (below -50 dB threshold) so we can flag
      // a likely Bluetooth-profile problem if the WHOLE test is silent.
      if (db < -50) silentSamples++;
      // Broadcast current level so the VU meter in the panel animates.
      try {
        chrome.runtime.sendMessage({
          type: "pd_audio_test_level",
          peakDb: db,
          peakDbCumulative: peakDb,
          elapsedMs: Date.now() - start,
          durationMs,
        });
      } catch {}
    }, 100);

    // Run for durationMs.
    await new Promise((r) => setTimeout(r, durationMs));

    clearInterval(intervalId); intervalId = null;
    // v1.33.3.2 — AudioContext.close() returns a Promise. If we call
    // close() twice (once here on success, once in the finally block),
    // the second call rejects with "InvalidStateError: Cannot close a
    // closed AudioContext". Brandon Keesler reported this Uncaught (in
    // promise) error in Chrome's extension errors view 2026-06-24 night.
    // Fix: close + null out so the finally check is a no-op AND attach
    // a .catch so the rejected Promise doesn't propagate as uncaught.
    try { audioCtx.close().catch(() => {}); } catch {}
    audioCtx = null;
    try { testEl.pause(); testEl.srcObject = null; } catch {}

    const silentRatio = sampleCount > 0 ? silentSamples / sampleCount : 1;
    const micLikelySilent = silentRatio > 0.95;

    return {
      ok: true,
      peakDb,
      durationMs,
      sampleCount,
      silentRatio,
      micLikelySilent,
      micDeviceId: selectedMicId,
      micLabel,
      speakerDeviceId: selectedSpeakerId,
      offscreenSdkState: currentState,
    };
  } catch (err) {
    return { ok: false, error: err?.message || String(err) };
  } finally {
    if (intervalId) { try { clearInterval(intervalId); } catch {} }
    // v1.33.3.2 — Same Promise-catch fix as above (success path).
    if (audioCtx) {
      try { audioCtx.close().catch(() => {}); } catch {}
      audioCtx = null;
    }
    _audioTestRunning = false;
  }
}

// ── Message handler ──────────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (!msg || !msg.type) return;
  // v1.33.2.4 — Heartbeat from background. Lets background.js detect a
  // dying offscreen document (Chrome MV3 idle-kills the doc after long
  // periods of inactivity, taking the Telnyx SDK with it) and recreate
  // it BEFORE the next dial silently fails. Cleanest possible payload —
  // a single { ok: true } so timing-sensitive callers can rely on it.
  if (msg.type === "pd_off_ping") {
    sendResponse({
      ok:    true,
      t:     Date.now(),
      state: currentState,
    });
    return true;
  }
  // v1.33.3 — Audio test (mic + speaker loopback). 5-sec loopback that
  // pipes the chosen mic through the chosen speaker so the agent can
  // verify their headset works. Emits pd_audio_test_level broadcasts
  // every 100ms with peak dB so content.js can animate a VU meter,
  // then returns the final stats including a "micLikelySilent" flag
  // for Bluetooth-profile-stuck cases (Cloeo Munroe 2026-06-23).
  if (msg.type === "pd_off_audio_test") {
    runAudioTest(msg.opts || {})
      .then((r) => sendResponse(r))
      .catch((e) => sendResponse({ ok: false, error: e?.message || String(e) }));
    return true;
  }
  if (msg.type === "pd_off_collect_diag") {
    // Manual diagnostics submit request (from content.js Setup drawer).
    // Always submits regardless of throttle since the user explicitly
    // asked for it.
    _submitDiagnostics({ reason: "manual:setup-drawer", extras: msg.extras || null })
      .then((r) => sendResponse(r))
      .catch((e) => sendResponse({ ok: false, reason: "threw: " + (e?.message || e) }));
    return true;  // keep channel open for async sendResponse
  }
  if (msg.type === "pd_off_init") {
    token = String(msg.token || "").trim();
    workerBase = String(msg.workerBase || "").trim();
    startConnection();
    sendResponse({ ok: true });
    return true;
  }
  if (msg.type === "pd_off_stop") {
    stopConnection();
    sendResponse({ ok: true });
    return true;
  }
  if (msg.type === "pd_off_status_request") {
    sendResponse({
      ok:    true,
      state: currentState,
      error: currentError,
      callerNumber: lastCaller,
    });
    return true;
  }
  // v1.29.31 — Panel mic picker: enumerate devices for the dropdown.
  if (msg.type === "pd_off_list_mics") {
    (async () => {
      try {
        const mics = await listMics();
        sendResponse({ ok: true, mics, currentMicId: selectedMicId });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.29.31 — Panel mic picker: apply the user's selection. Strategy:
  // 1. Update selectedMicId so the next acquireMic uses it.
  // 2. If a stream is currently live, get a fresh stream with the new
  //    deviceId and use RTCRtpSender.replaceTrack() to swap mics WITHOUT
  //    a SIP re-INVITE (same trick the v1.29.30 mute uses). This avoids
  //    the parked-call-killing bug we saw with SDK methods that signal.
  if (msg.type === "pd_off_set_mic") {
    (async () => {
      try {
        const newId = msg.deviceId || null;
        selectedMicId = newId;
        let switched = false;
        if (micStream) {
          // Get a fresh stream from the new device.
          const constraints = {
            audio: {
              echoCancellation: true,
              noiseSuppression: true,
              autoGainControl: true,
            },
            video: false,
          };
          if (newId) constraints.audio.deviceId = { exact: newId };
          const newStream = await navigator.mediaDevices.getUserMedia(constraints);
          const newTrack = newStream.getAudioTracks()[0];
          if (newTrack && activeCall) {
            // Replace track on the live RTCPeerConnection — no renegotiation.
            const pc = activeCall.peer?.instance
                    || activeCall.peer
                    || activeCall.rtcPeer?.peerConnection
                    || null;
            if (pc && typeof pc.getSenders === "function") {
              for (const s of pc.getSenders()) {
                if (s?.track?.kind === "audio") {
                  await s.replaceTrack(newTrack);
                  switched = true;
                }
              }
            }
          }
          // Stop the old tracks and swap the cached stream.
          try { micStream.getTracks().forEach((t) => t.stop()); } catch {}
          micStream = newStream;
        }
        sendResponse({ ok: true, switched, deviceId: newId });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.29.31 — Panel speaker picker: enumerate audio output devices.
  if (msg.type === "pd_off_list_speakers") {
    (async () => {
      try {
        const speakers = await listSpeakers();
        sendResponse({ ok: true, speakers, currentSpeakerId: selectedSpeakerId });
      } catch (err) {
        sendResponse({ ok: false, error: err.message });
      }
    })();
    return true;
  }

  // v1.29.31 — Panel speaker picker: apply the user's selection via
  // setSinkId on the <audio id="remote-audio"> element. Works on
  // desktop Chrome; mobile browsers typically don't expose multiple
  // outputs so setSinkId no-ops there (intentionally — panel hides the
  // picker on mobile via the existing platform gate in mobile.js).
  if (msg.type === "pd_off_set_speaker") {
    (async () => {
      const result = await applySpeaker(msg.deviceId || null);
      sendResponse(result);
    })();
    return true;
  }

  // v1.29.29b — Panel Mute button forwards here via background.
  // PREVIOUSLY (v1.29.29): called Telnyx SDK's muteAudio()/unmuteAudio().
  // This triggered a SIP re-INVITE which Telnyx mishandled on a
  // parked-then-bridged agent leg — the call dropped and the next
  // bridge returned 422 "no longer active".
  // NOW: directly toggle `enabled` on the outbound audio track of the
  // existing RTCPeerConnection. No SIP renegotiation, no re-INVITE.
  // The call stays alive; the prospect just stops hearing the agent's
  // audio for the duration of the mute.
  if (msg.type === "pd_off_mute_apply") {
    try {
      let appliedCount = 0;
      if (activeCall) {
        // Common Telnyx SDK shape: call.peer.instance is the RTCPeerConnection.
        const pc = activeCall.peer?.instance
                || activeCall.peer
                || activeCall.rtcPeer?.peerConnection
                || null;
        if (pc && typeof pc.getSenders === "function") {
          for (const s of pc.getSenders()) {
            if (s?.track?.kind === "audio") {
              s.track.enabled = !msg.muted;
              appliedCount++;
            }
          }
        }
        // Belt-and-suspenders: also disable any tracks on the call's
        // localStream so the meter and SDK both see consistent state.
        const localStream = activeCall.options?.localStream
                          || activeCall.localStream
                          || null;
        if (localStream && typeof localStream.getAudioTracks === "function") {
          for (const t of localStream.getAudioTracks()) {
            t.enabled = !msg.muted;
          }
        }
      }
      sendResponse({ ok: true, applied: appliedCount });
    } catch (err) {
      sendResponse({ ok: false, error: err.message });
    }
    return true;
  }
  return false;
});

// If the SDK failed to load on import, surface the error immediately so
// the panel pip turns red without waiting for a session start. Otherwise
// announce we're idle and ready for an init message.
if (sdkLoadError) {
  setStatus("error", { error: "Telnyx SDK didn't load: " + sdkLoadError });
} else {
  setStatus("idle");
}

// Proactively pull our config from background. Belt-and-suspenders for
// the race where background creates this doc, then sends pd_off_init
// before our onMessage listener is bound. If background still has a
// pending config (it does, since pd_desktop_enable cached it), we get
// it back here and start connecting straight away.
try {
  chrome.runtime.sendMessage({ type: "pd_off_request_init" }, (resp) => {
    void chrome.runtime.lastError;
    if (resp?.ok && resp.token) {
      token = resp.token;
      workerBase = resp.workerBase || "";
      startConnection();
    }
  });
} catch {}
