// StellarCaptions — Background service worker

importScripts('db.js')

// Deployed backend the extension talks to. Change this (and BACKEND_URL in
// popup.js) to your local dev server only when developing locally.
const HTTP_BASE = 'https://stellarcaptions.com'
const TOKEN_URL = `${HTTP_BASE}/api/realtime/token`
const ACTIVE_SESSION_KEY = 'activeSessionId'
const FREE_CREDITS_TOTAL = 70
const SUPABASE_URL = 'https://gahhoyzmadmdershfmes.supabase.co'
const SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_-EQ1mve5ElZepU1-Ldz0Sg_m2S2O2Ei'

let platformOS = 'unknown'
try { chrome.runtime.getPlatformInfo((info) => { platformOS = info?.os || 'unknown' }) } catch {}

// ---------- Supabase token refresh ----------
// Supabase access tokens expire after ~1h. The extension is the single owner of
// the refresh flow (popup + offscreen request a fresh token from here) so the
// rotating refresh token is never used concurrently.
let refreshInFlight = null

function jwtExpMs(token) {
  try {
    const payload = JSON.parse(atob(token.split('.')[1]))
    return typeof payload.exp === 'number' ? payload.exp * 1000 : 0
  } catch {
    return 0
  }
}

async function refreshAccessToken(refreshToken) {
  const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', apikey: SUPABASE_PUBLISHABLE_KEY },
    body: JSON.stringify({ refresh_token: refreshToken }),
  })
  const data = await res.json().catch(() => null)
  if (!res.ok || !data?.access_token) throw new Error(data?.error_description || 'refresh_failed')
  await chrome.storage.local.set({ token: data.access_token, refreshToken: data.refresh_token })
  return data.access_token
}

// Returns a valid access token, refreshing it if missing/expiring. Returns null
// when the user is signed out or the refresh token is no longer valid (in which
// case stored tokens are cleared so the UI can prompt re-login).
async function ensureValidToken() {
  const { token, refreshToken } = await chrome.storage.local.get(['token', 'refreshToken'])
  if (!token) return null
  const expMs = jwtExpMs(token)
  if (expMs && Date.now() < expMs - 60_000) return token
  if (!refreshToken) return token // can't refresh; let the caller's 401 handling deal with it
  if (!refreshInFlight) {
    refreshInFlight = refreshAccessToken(refreshToken)
      .catch(async () => {
        await chrome.storage.local.remove(['token', 'refreshToken'])
        return null
      })
      .finally(() => { refreshInFlight = null })
  }
  return refreshInFlight
}
let active = false
let currentTabId = null
let contentReadyTabIds = new Set()
let activeSessionId = null
let currentMode = 'transcribe'
let captureRunId = 0
let pendingDubbingSyncTimer = null

let currentTargetLang = null
let currentDubbing = false

// ---------- Transcript persistence (works even when the popup is closed) ----------
// Buffer the in-flight segment briefly so a source line and its translation are
// merged into a single saved row, then write once to IndexedDB.
let pendingSeg = null
let pendingTimer = null

async function flushPendingSegment() {
  if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
  if (!pendingSeg) return
  const seg = pendingSeg
  pendingSeg = null
  if (self.DB) {
    try { await self.DB.saveSegment(seg.sessionId, seg.text || '', seg.translatedText || null, seg.timestamp, seg.relMs) } catch {}
  }
  // Mirror to the popup as a committed chunk so the Transcript tab shows the
  // same timestamped chunks whether they came from native cues or the chunker.
  chrome.runtime.sendMessage({
    action: 'segmentCommit',
    text: seg.text || '',
    translatedText: seg.translatedText || null,
    timestamp: seg.timestamp,
    relMs: seg.relMs,
  }).catch(() => {})
}

function persistFinalSegment({ text, translatedText }) {
  if (!activeSessionId) return
  const now = Date.now()

  if (
    pendingSeg &&
    now - pendingSeg.timestamp < 2500 &&
    ((text && !pendingSeg.text) || (translatedText && !pendingSeg.translatedText))
  ) {
    if (text) pendingSeg.text = text
    if (translatedText) pendingSeg.translatedText = translatedText
    flushPendingSegment()
    return
  }

  flushPendingSegment()
  pendingSeg = {
    sessionId: activeSessionId,
    text: text || '',
    translatedText: translatedText || null,
    timestamp: now,
    relMs: meterElapsedMs(),
  }
  pendingTimer = setTimeout(flushPendingSegment, 1200)
}

// ---------- Audio-pipeline transcript chunker ----------
// The realtime model streams transcript/translation as deltas and only emits a
// `.done` at the end of a VAD turn, which can be very long. To get otter.ai-style
// timestamped chunks (and to actually persist them as we go), we accumulate the
// deltas here and commit a chunk on sentence-ending punctuation, a length cap, a
// speech pause, or the turn's `.done`. Source and translation are paired per chunk.
let chunkSrc = ''
let chunkTrans = ''
let chunkStartTs = 0
// Recording-relative start offset (pause-excluded ms) of the in-progress chunk,
// so the transcript shows time-from-start instead of wall-clock time.
let chunkStartRel = 0
let chunkPauseTimer = null
// Whether the source/translation for the current turn have finalized (.done).
// In translate mode we wait for both so each saved row keeps its original line
// paired with its translation (instead of committing the translation alone when
// the slower input transcript hasn't landed yet).
let chunkSrcDone = false
let chunkTransDone = false
const CHUNK_MAX_CHARS = 180
const CHUNK_PAUSE_MS = 4000

function resetChunker() {
  chunkSrc = ''
  chunkTrans = ''
  chunkStartTs = 0
  chunkStartRel = 0
  chunkSrcDone = false
  chunkTransDone = false
  if (chunkPauseTimer) { clearTimeout(chunkPauseTimer); chunkPauseTimer = null }
}

function endsSentence(s) {
  return /[.!?。！？؟]['")\]\u201d\u2019]?\s*$/.test(s)
}

function commitChunk() {
  if (chunkPauseTimer) { clearTimeout(chunkPauseTimer); chunkPauseTimer = null }
  const text = chunkSrc.trim()
  const translated = chunkTrans.trim()
  if (!text && !translated) { chunkSrc = ''; chunkTrans = ''; chunkStartTs = 0; chunkStartRel = 0; chunkSrcDone = false; chunkTransDone = false; return }
  const timestamp = chunkStartTs || Date.now()
  const relMs = chunkStartRel
  chunkSrc = ''
  chunkTrans = ''
  chunkStartTs = 0
  chunkStartRel = 0
  chunkSrcDone = false
  chunkTransDone = false
  if (activeSessionId && self.DB) {
    try { self.DB.saveSegment(activeSessionId, text, translated || null, timestamp, relMs) } catch {}
  }
  chrome.runtime.sendMessage({
    action: 'segmentCommit',
    text,
    translatedText: translated || null,
    timestamp,
    relMs,
  }).catch(() => {})
}

// Feed a transcript/translation delta (or `.done`) into the chunker. In translate
// mode the translation (output) stream drives chunk boundaries; in transcribe
// mode the source (input) stream does.
function feedChunker({ isSrc, text, isFinal }) {
  if (!activeSessionId || !text) return
  const now = Date.now()
  if (!chunkStartTs) {
    chunkStartTs = now
    chunkStartRel = meterElapsedMs()
  }

  // A `.done` carries the full finalized text for the turn; deltas append.
  if (isSrc) {
    chunkSrc = isFinal ? text : chunkSrc + text
    if (isFinal) chunkSrcDone = true
  } else {
    chunkTrans = isFinal ? text : chunkTrans + text
    if (isFinal) chunkTransDone = true
  }

  // Pause safety net: if one side never finalizes, still commit after a quiet gap.
  if (chunkPauseTimer) clearTimeout(chunkPauseTimer)
  chunkPauseTimer = setTimeout(commitChunk, CHUNK_PAUSE_MS)

  if (currentMode === 'translate') {
    // Keep the original line paired with its translation: commit once BOTH the
    // source and translation for this turn have finalized (or on a length cap).
    // This stops the faster translation stream from committing alone and leaving
    // later rows translation-only / desynced.
    const bothDone = chunkSrcDone && chunkTransDone
    if (bothDone || chunkTrans.length >= CHUNK_MAX_CHARS || chunkSrc.length >= CHUNK_MAX_CHARS) {
      commitChunk()
    }
  } else {
    // Transcribe (source only): commit on its `.done`, a sentence end, or length.
    if (chunkSrcDone || endsSentence(chunkSrc) || chunkSrc.length >= CHUNK_MAX_CHARS) {
      commitChunk()
    }
  }
}

// ---------- Duration-based credit metering ----------
// Media flows directly to OpenAI over WebRTC, so we meter by active minutes
// (1 credit ~= 1 minute of captioning, paused time excluded).
let meterInterval = null
let meterAccumMs = 0
let meterLastTs = 0
let meterPaused = true
let creditsCharged = 0
// Server-tracked metering session id for the audio (WebRTC) pipeline. When set,
// credits are debited by the server-authoritative heartbeat instead of the
// client-driven meter, so a modified client cannot under-report usage.
let realtimeSessionId = null
// Credits charged per active minute (the audio pipeline runs the realtime model).
let meterRate = 1

function startMeter() {
  // Idempotent: a live language switch reconnects and re-fires proxyReady, but
  // we want one continuous meter for the whole session.
  if (meterInterval) {
    meterResumeClock()
    return
  }
  meterAccumMs = 0
  meterLastTs = Date.now()
  meterPaused = false
  creditsCharged = 0
  meterInterval = setInterval(meterTick, 20000)
}

function meterPauseClock() {
  if (meterPaused) return
  meterAccumMs += Date.now() - meterLastTs
  meterPaused = true
}

function meterResumeClock() {
  if (!meterPaused) return
  meterLastTs = Date.now()
  meterPaused = false
}

function meterElapsedMs() {
  return meterAccumMs + (meterPaused ? 0 : Date.now() - meterLastTs)
}

async function stopMeter() {
  await meterTick()
  if (meterInterval) { clearInterval(meterInterval); meterInterval = null }
  meterPaused = true
}

async function meterTick() {
  // Audio (WebRTC) sessions are metered server-side: the heartbeat times the
  // session itself and never trusts a client-supplied amount.
  if (realtimeSessionId) {
    if (meterPaused) return
    await realtimeHeartbeat(realtimeSessionId, false)
    return
  }
  // Client fallback before the server metering session id is assigned.
  const shouldHave = Math.floor((meterElapsedMs() / 60000) * meterRate)
  const delta = shouldHave - creditsCharged
  if (delta <= 0) return
  creditsCharged = shouldHave
  await debitCredits(delta)
}

async function realtimeHeartbeat(sessionId, end) {
  if (!sessionId) return
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  const token = await ensureValidToken()
  if (!token) return
  try {
    const res = await fetch(`${HTTP_BASE}/api/realtime/heartbeat`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
      },
      body: JSON.stringify({ sessionId, end: !!end }),
    })
    const data = await res.json().catch(() => null)
    // Only act on a stop signal for the session that is still current.
    if (!end && data?.stop && sessionId === realtimeSessionId) {
      realtimeSessionId = null
      notifyCreditsExhausted()
    }
  } catch {}
}

async function debitCredits(amount) {
  const { localCreditsUsed = 0, deviceId } = await chrome.storage.local.get([
    'localCreditsUsed', 'deviceId',
  ])
  const token = await ensureValidToken()
  const newLocal = localCreditsUsed + amount
  await chrome.storage.local.set({ localCreditsUsed: newLocal })

  if (token) {
    try {
      const res = await fetch(`${HTTP_BASE}/api/usage`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
          ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
        },
        body: JSON.stringify({ amount }),
      })
      if (res.status === 429) notifyCreditsExhausted()
    } catch {}
  } else if (newLocal >= FREE_CREDITS_TOTAL) {
    notifyCreditsExhausted()
  }
}

function notifyCreditsExhausted() {
  chrome.runtime.sendMessage({ action: 'creditsExhausted' }).catch(() => {})
  handleStop()
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.action === 'getToken') {
    // Async: keep the message channel open by returning true below.
    ensureValidToken().then((token) => sendResponse({ token: token || null }))
    return true
  } else if (msg.action === 'googleSignIn') {
    // Sign in via the website instead of hitting Supabase's OAuth endpoint
    // directly: a self-distributed (unpacked) extension has an unstable ID, so
    // its chromiumapp.org redirect can't be allow-listed in Supabase/Google.
    // The website does the Google login (a normal web context where OAuth
    // works), mints a one-time handoff code, and redirects back to our
    // chromiumapp.org callback. We then exchange that code for tokens.
    const redirectUrl = chrome.identity.getRedirectURL()
    const startUrl = `${HTTP_BASE}/login?ext=1&ext_redirect=${encodeURIComponent(redirectUrl)}`
    chrome.identity.launchWebAuthFlow({ url: startUrl, interactive: true }, async (callbackUrl) => {
      if (chrome.runtime.lastError || !callbackUrl) {
        sendResponse({ error: chrome.runtime.lastError?.message || 'cancelled' })
        return
      }
      try {
        const code = new URL(callbackUrl).searchParams.get('code')
        if (!code) {
          sendResponse({ error: 'no_code' })
          return
        }
        const res = await fetch(`${HTTP_BASE}/api/auth/extension-handoff`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ code }),
        })
        const data = await res.json()
        if (data.access_token) {
          await chrome.storage.local.set({
            token: data.access_token,
            refreshToken: data.refresh_token,
            backendUrl: HTTP_BASE,
            supabaseUrl: SUPABASE_URL,
          })
          sendResponse({ token: data.access_token })
        } else {
          sendResponse({ error: data.error || 'no_token' })
        }
      } catch (e) {
        sendResponse({ error: 'exchange_failed' })
      }
    })
    return true
  } else if (msg.action === 'startCapture') {
    handleStart(msg)
    sendResponse({ ok: true })
  } else if (msg.action === 'micPermissionGranted') {
    resumeMicStart()
    sendResponse({ ok: true })
  } else if (msg.action === 'stopCapture') {
    handleStop()
    sendResponse({ ok: true })
  } else if (msg.action === 'pauseCapture') {
    sendToOffscreen({ action: 'pauseCapture' })
    meterPauseClock()
    sendResponse({ ok: true })
  } else if (msg.action === 'resumeCapture') {
    sendToOffscreen({ action: 'resumeCapture' })
    meterResumeClock()
    sendResponse({ ok: true })
  } else if (msg.action === 'setOriginalMode') {
    sendToOffscreen({ action: 'setOriginalMode', mode: msg.mode || 'normal' })
    sendResponse({ ok: true })
  } else if (msg.action === 'updateDubbing') {
    handleUpdateDubbing(!!msg.dubbing)
    sendResponse({ ok: true })
  } else if (msg.action === 'reacquireDisplay') {
    handleReacquireDisplay(!!msg.dubbing, msg.originalMode || 'normal')
    sendResponse({ ok: true })
  } else if (msg.action === 'updateTargetLang') {
    handleUpdateTargetLang(msg.targetLang || null)
    sendResponse({ ok: true })
  } else if (msg.action === 'realtimeSession') {
    // A new server-tracked metering session was minted (initial start or a
    // language-switch re-mint). End the prior one and adopt the new id.
    const prior = realtimeSessionId
    if (prior && prior !== msg.sessionId) realtimeHeartbeat(prior, true)
    realtimeSessionId = msg.sessionId || null
  } else if (msg.action === 'contentReady') {
    // The content script (re)injected and registered its listeners. Re-push the
    // current caption mode/settings so a stale or freshly-injected overlay shows
    // the right channel and (if capturing) the loading placeholder.
    if (sender && sender.tab && sender.tab.id != null) {
      contentReadyTabIds.add(sender.tab.id)
      console.log('[bg] contentReady from tab', sender.tab.id)
      pushCaptionStateToTab(sender.tab.id)
    }
    sendResponse({ ok: true })
  } else if (msg.action === 'videoTime') {
    // Forward the page clock to the offscreen dub for drift correction.
    if (active) sendToOffscreen({ action: 'videoTime', t: msg.t, paused: !!msg.paused })
  } else if (msg.action === 'updateCaptionSettings') {
    handleUpdateCaptionSettings(msg.settings || {})
    sendResponse({ ok: true })
  } else if (msg.action === 'getState') {
    sendResponse({ active })
  } else if (msg.action === 'transcriptUpdate' || msg.action === 'translationUpdate') {
    // Carry the current mode so the overlay can self-correct its display channel
    // even if an earlier setCaptionMode raced content-script readiness.
    broadcastToContent({ ...msg, mode: currentMode })
    // Accumulate deltas and commit timestamped chunks as we go (persists +
    // broadcasts segmentCommit).
    feedChunker({ isSrc: msg.action === 'transcriptUpdate', text: msg.text, isFinal: !!msg.isFinal })
  } else if (msg.action === 'proxyReady') {
    if (!active) active = true
    startMeter()
    // Re-sync dubbing state and re-prime caption state now that media is live.
    sendToOffscreen({ action: 'updateDubbing', dubbing: currentDubbing })
    if (currentTabId) primeCaptionState(currentTabId)
  } else if (msg.action === 'proxyClosed' || msg.action === 'proxyError') {
    if (msg.action === 'proxyError') {
      console.error('[bg] Proxy error:', msg.message)
    }
  }
  return true
})

// Resume a microphone session after the permission window granted access.
async function resumeMicStart() {
  const { pendingMicStart } = await chrome.storage.local.get('pendingMicStart')
  if (!pendingMicStart) return
  await chrome.storage.local.remove('pendingMicStart')
  if (self.DB) {
    try {
      await self.DB.createSession(pendingMicStart.sessionId, {
        title: pendingMicStart.title || new Date().toLocaleString(),
        sourceLang: null,
        targetLang: pendingMicStart.targetLang || null,
      })
    } catch {}
  }
  await chrome.storage.local.set({ [ACTIVE_SESSION_KEY]: pendingMicStart.sessionId })
  handleStart(pendingMicStart)
}

async function handleStart({ source, mode, sourceLang, targetLang, originalMode, dubbing, sessionId, tabId }) {
  if (active) return
  captureRunId += 1

  // Sign-in wall: a Supabase access token is required to start capture.
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  const token = await ensureValidToken()
  if (!token) {
    chrome.runtime.sendMessage({ action: 'proxyError', message: 'Sign in required' }).catch(() => {})
    return
  }

  // Prefer an explicit target tab (e.g. resuming after a permission window stole
  // focus); otherwise fall back to the active tab.
  let tab = null
  if (tabId != null) {
    try { tab = await chrome.tabs.get(tabId) } catch {}
  }
  if (!tab) tab = await getCurrentTab()
  if (!tab) return
  currentTabId = tab.id
  console.log('[bg] capture target tab', currentTabId)

  // WebRTC always needs a language. "Keep original" passes 'en' but shows source transcript.
  const effectiveTargetLang = targetLang || 'en'
  currentMode = targetLang ? 'translate' : 'transcribe'
  currentTargetLang = targetLang || null
  currentDubbing = !!dubbing

  activeSessionId = sessionId || null
  if (!activeSessionId) {
    try {
      const stored = await chrome.storage.local.get(ACTIVE_SESSION_KEY)
      activeSessionId = stored[ACTIVE_SESSION_KEY] || null
    } catch {}
  }
  pendingSeg = null
  if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
  resetChunker()

  // Inject the captions content script (renders the on-page overlay).
  let injected = false
  for (let attempt = 0; attempt < 2 && !injected; attempt++) {
    try {
      await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] })
      await chrome.scripting.insertCSS({ target: { tabId: tab.id }, files: ['overlay.css'] })
      injected = true
    } catch (e) {
      console.warn(`[bg] Content script injection attempt ${attempt + 1} failed:`, e)
    }
  }
  if (!injected) {
    // Captions overlay can't render on this page (e.g. chrome:// or the Web
    // Store). Tell the popup so the failure isn't silent; audio/transcript still work.
    notifyPopupStatus('On-page captions unavailable on this page')
  }

  // Apply persisted caption settings + caption mode to the tab overlay.
  try {
    const stored = await chrome.storage.local.get(['captionSettings'])
    if (stored.captionSettings) {
      chrome.tabs.sendMessage(tab.id, {
        action: 'updateCaptionSettings',
        settings: stored.captionSettings,
      }).catch(() => {})
    }
  } catch {}
  chrome.tabs.sendMessage(tab.id, { action: 'setCaptionMode', mode: currentMode }).catch(() => {})

  // ----- Audio pipeline -----
  await ensureOffscreen()

  let streamId = null
  if (source === 'tab') {
    streamId = await getTabStreamId(tab.id)
  }

  // Begin page clock reporting immediately, but defer caption-start placeholder
  // until capture is actually live (proxyReady) so cancelling the picker doesn't
  // start captions.
  chrome.tabs.sendMessage(tab.id, { action: 'setCaptureMode', mode: 'audio' }).catch(() => {})
  primeCaptionState(tab.id)

  sendToOffscreen({
    action: 'startCapture',
    source,
    streamId,
    tokenUrl: TOKEN_URL,
    authToken: token,
    deviceId,
    targetLang: effectiveTargetLang,
    originalMode,
    dubbing,
    platformOS,
  })

  active = true
}

function handleUpdateDubbing(dubbing) {
  currentDubbing = dubbing
  sendToOffscreen({ action: 'updateDubbing', dubbing })
}

// Display capture can't toggle suppressLocalAudioPlayback on a live track, so
// when the user enables dubbing/duck/mute mid-session on a Window/Screen that
// started un-suppressed, re-acquire the source (re-prompts the picker) without
// ending the session — the meter, chunker and transcript continue.
async function handleReacquireDisplay(dubbing, originalMode) {
  if (!active) return
  const runId = captureRunId
  const token = await ensureValidToken()
  if (!token || !active || runId !== captureRunId) return
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  if (!active || runId !== captureRunId) return
  currentDubbing = !!dubbing
  // Tear down only the offscreen media + WebRTC, then re-acquire with the new
  // state (suppression is decided at acquire time inside offscreen).
  sendToOffscreen({ action: 'stopCapture' })
  sendToOffscreen({
    action: 'startCapture',
    source: 'display',
    streamId: null,
    tokenUrl: TOKEN_URL,
    authToken: token,
    deviceId,
    targetLang: currentTargetLang || 'en',
    originalMode: originalMode || 'normal',
    dubbing: !!dubbing,
    platformOS,
  })
  // Ensure offscreen dub state is synced after re-acquire start.
  if (pendingDubbingSyncTimer) clearTimeout(pendingDubbingSyncTimer)
  pendingDubbingSyncTimer = setTimeout(() => {
    if (!active || runId !== captureRunId) return
    sendToOffscreen({ action: 'updateDubbing', dubbing: !!dubbing })
  }, 350)
  if (currentTabId) primeCaptionState(currentTabId)
}

function handleUpdateTargetLang(targetLang) {
  if (!active) return
  const effectiveTargetLang = targetLang || 'en'
  const mode = targetLang ? 'translate' : 'transcribe'
  currentMode = mode
  currentTargetLang = targetLang || null

  // Switch output language over the WebRTC data channel (no reconnect).
  sendToOffscreen({ action: 'updateOutputLang', targetLang: effectiveTargetLang })
  if (currentTabId) {
    chrome.tabs.sendMessage(currentTabId, { action: 'setCaptionMode', mode }).catch(() => {})
  }
}

function handleUpdateCaptionSettings(settings) {
  chrome.storage.local.set({ captionSettings: settings }).catch(() => {})
  if (currentTabId) {
    chrome.tabs.sendMessage(currentTabId, {
      action: 'updateCaptionSettings',
      settings,
    }).catch(() => {})
  }
}

async function handleStop() {
  captureRunId += 1
  if (pendingDubbingSyncTimer) {
    clearTimeout(pendingDubbingSyncTimer)
    pendingDubbingSyncTimer = null
  }
  sendToOffscreen({ action: 'stopCapture' })
  active = false
  contentReadyTabIds = new Set()
  // Total pause-excluded recording time, captured before stopMeter() (which
  // leaves the final running delta out of the accumulator).
  const durationMs = meterElapsedMs()
  await stopMeter()

  // Post a final server-side heartbeat to bill the last interval and close the
  // realtime metering session.
  if (realtimeSessionId) {
    const sid = realtimeSessionId
    realtimeSessionId = null
    await realtimeHeartbeat(sid, true)
  }

  // Flush the audio chunker's partial buffer so the final words are saved.
  commitChunk()
  flushPendingSegment()

  // Mark the session as ended in IndexedDB, recording its pause-excluded length.
  if (activeSessionId && self.DB) {
    try { await self.DB.updateSession(activeSessionId, { endTime: Date.now(), durationMs }) } catch {}
  }
  activeSessionId = null

  // Tell content script to stop capturing and hide the overlay
  if (currentTabId) {
    chrome.tabs.sendMessage(currentTabId, { action: 'setCaptureMode', mode: 'off' }).catch(() => {})
    chrome.tabs.sendMessage(currentTabId, { action: 'hideOverlay' }).catch(() => {})
  }

  // Small delay to ensure async DB writes complete before tearing down offscreen
  await new Promise((r) => setTimeout(r, 250))

  // Close the offscreen document so the next session starts clean
  try {
    const contexts = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] })
    if (contexts.length > 0) await chrome.offscreen.closeDocument()
  } catch {}
}

async function primeCaptionState(tabId, retries = 6, delayMs = 250) {
  for (let i = 0; i < retries; i++) {
    chrome.tabs.sendMessage(tabId, { action: 'setCaptionMode', mode: currentMode }).catch(() => {})
    if (active) {
      chrome.tabs.sendMessage(tabId, { action: 'setCaptureMode', mode: 'audio' }).catch(() => {})
      chrome.tabs.sendMessage(tabId, { action: 'captionsStarting' }).catch(() => {})
    }
    if (contentReadyTabIds.has(tabId)) return
    await new Promise((r) => setTimeout(r, delayMs))
  }
  console.warn('[bg] contentReady not observed for tab', tabId)
}

function broadcastToContent(msg) {
  if (currentTabId) {
    chrome.tabs.sendMessage(currentTabId, msg).catch(() => {})
  }
}

// Re-push caption mode + settings (+ capture mode/loading) to a freshly-ready
// content script so it never sits in a stale/default state.
async function pushCaptionStateToTab(tabId) {
  try {
    const stored = await chrome.storage.local.get(['captionSettings'])
    if (stored.captionSettings) {
      chrome.tabs.sendMessage(tabId, { action: 'updateCaptionSettings', settings: stored.captionSettings }).catch(() => {})
    }
  } catch {}
  chrome.tabs.sendMessage(tabId, { action: 'setCaptionMode', mode: currentMode }).catch(() => {})
  if (active) {
    chrome.tabs.sendMessage(tabId, { action: 'setCaptureMode', mode: 'audio' }).catch(() => {})
    chrome.tabs.sendMessage(tabId, { action: 'captionsStarting' }).catch(() => {})
  }
}

// Non-fatal status surfaced in the popup (does not stop capture).
function notifyPopupStatus(text) {
  chrome.runtime.sendMessage({ action: 'captureStatus', text }).catch(() => {})
}

function sendToOffscreen(msg) {
  chrome.runtime.sendMessage(msg).catch(() => {})
}

async function ensureOffscreen() {
  const contexts = await chrome.runtime.getContexts({
    contextTypes: ['OFFSCREEN_DOCUMENT'],
  })
  if (contexts.length === 0) {
    await chrome.offscreen.createDocument({
      url: 'offscreen.html',
      reasons: ['USER_MEDIA', 'DISPLAY_MEDIA', 'AUDIO_PLAYBACK'],
      justification: 'Audio capture and dubbed audio playback',
    })
  }
}

function getTabStreamIdOnce(tabId) {
  return new Promise((resolve) => {
    chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => {
      // Swallow lastError (e.g. transient "Cannot capture") and report null so
      // the caller can retry instead of throwing an opaque failure.
      void chrome.runtime.lastError
      resolve(id || null)
    })
  })
}

// getMediaStreamId occasionally returns null/undefined transiently; one quick
// retry avoids the spurious "tab no source" failure on Start.
async function getTabStreamId(tabId) {
  let id = await getTabStreamIdOnce(tabId)
  if (!id) {
    await new Promise((r) => setTimeout(r, 150))
    id = await getTabStreamIdOnce(tabId)
  }
  return id
}

async function getCurrentTab() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
  return tab
}
