// StellarCaptions — Offscreen document
// Always runs a single persistent WebRTC translation session. Language switching,
// dubbing toggle, and "Keep original" all happen over the existing data channel
// with zero reconnect.

// Shared capture state
let streamRef = null
let originalAudioEl = null

// Input conditioning graph (tab/display): boost + gentle compression so quiet
// sources reach the realtime model (and the meter) at a healthy level. The
// conditioned track is what we send to OpenAI; `mic` is sent raw (it already
// has AGC).
let captureCtx = null
let captureSource = null
let captureGain = null
let captureComp = null
let captureDest = null
let sendTrack = null
// Local monitor gain for the original audio when we replay a suppressed display
// capture through Web Audio (reliable volume control vs. an <audio> element,
// which Chrome may keep silent for a suppressed track).
let originalGain = null
// Whether this display capture suppresses local speaker playback (so the
// extension's replay is the only audible path and duck/mute can work).
let displaySuppressed = false
const CAPTURE_MAKEUP_GAIN = 1.6

// WebRTC state (persistent for the whole session)
let pc = null
let dataChannel = null
let audioSender = null
let dubAudioEl = null
let dcOpen = false
let dcQueue = [] // queued messages for the data channel before it opens

// Audio level meter
let meterCtx = null
let meterSource = null
let meterAnalyser = null
let meterInterval = null

// Drift correction: nudge dub playback rate so the dubbed audio keeps up with
// the original instead of falling progressively behind.
let dubMeterCtx = null
let dubMeterSource = null
let dubMeterAnalyser = null
let dubMeterInterval = null
let driftInterval = null
let originalVoiced = 0 // seconds of voiced original audio observed
let dubVoiced = 0      // seconds of voiced dubbed audio played
let videoPaused = false
let lastVideoTime = null
const VOICE_RMS = 0.015

// Session state
let paused = false
let dubbingEnabled = false
// Original-audio handling: 'normal' (full volume), 'duck' (faint background so the
// dub stays clear), or 'mute' (silent). Only applies to tab/display capture —
// microphone has no computer-played original to attenuate.
let originalMode = 'normal'
// Duck levels + silence adaptation: deep-duck while the original is speaking, but
// ease back up during long silent gaps (no competing speech to duck under).
const DUCK_DEEP = 0.06
const DUCK_SOFT = 0.35
const SILENCE_GAP_MS = 5000
let lastVoicedTs = 0
let currentSource = null
let currentTargetLang = null
let platformOS = 'unknown'
let tokenUrl = null
let authToken = null
let deviceId = null

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.action === 'startCapture') {
    startCapture(msg)
    sendResponse({ ok: true })
  } else if (msg.action === 'stopCapture') {
    stopCapture()
    sendResponse({ ok: true })
  } else if (msg.action === 'pauseCapture') {
    pauseCapture()
    sendResponse({ ok: true })
  } else if (msg.action === 'resumeCapture') {
    resumeCapture()
    sendResponse({ ok: true })
  } else if (msg.action === 'setOriginalMode') {
    setOriginalMode(msg.mode || 'normal')
    sendResponse({ ok: true })
  } else if (msg.action === 'updateDubbing') {
    dubbingEnabled = !!msg.dubbing
    applyDubMute()
    sendResponse({ ok: true })
  } else if (msg.action === 'updateOutputLang') {
    currentTargetLang = msg.targetLang || 'en'
    setOutputLanguage(currentTargetLang)
    // A language switch makes prior dub progress meaningless — re-anchor drift.
    resetDriftAnchor()
    sendResponse({ ok: true })
  } else if (msg.action === 'videoTime') {
    onVideoTime(msg.t, !!msg.paused)
    sendResponse({ ok: true })
  }
  return true
})

async function startCapture(msg) {
  currentSource = msg.source
  currentTargetLang = msg.targetLang || 'en'
  dubbingEnabled = !!msg.dubbing
  originalMode = msg.originalMode || 'normal'
  platformOS = msg.platformOS || 'unknown'
  lastVoicedTs = Date.now()
  tokenUrl = msg.tokenUrl || null
  authToken = msg.authToken || null
  deviceId = msg.deviceId || null
  paused = false

  try {
    await acquireStream(msg)
  } catch (e) {
    chrome.runtime.sendMessage({ action: 'proxyError', message: `Capture failed: ${e?.message || e}` })
    return
  }

  await setupCaptureConditioning()
  setupOriginalPlayback()
  startLevelMeter()
  await startWebRTC()
}

async function acquireStream({ source, streamId }) {
  if (source === 'tab') {
    if (!streamId) throw new Error('Tab audio unavailable — reopen the tab and try again.')
    streamRef = await navigator.mediaDevices.getUserMedia({
      audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: streamId } },
    })
    displaySuppressed = false
  } else if (source === 'mic') {
    streamRef = await navigator.mediaDevices.getUserMedia({ audio: true })
    displaySuppressed = false
  } else if (source === 'display') {
    // Suppress only when user explicitly asked to lower/mute original audio.
    // On macOS, never suppress — it triggers a device-wide mute that kills dubbing.
    const suppress = originalMode !== 'normal' && platformOS !== 'mac'
    const audio = {}
    const supported = navigator.mediaDevices.getSupportedConstraints().suppressLocalAudioPlayback
    if (suppress && supported) {
      audio.suppressLocalAudioPlayback = true
    } else if (suppress && !supported) {
      console.warn('[offscreen] suppressLocalAudioPlayback unsupported; original may not mute/duck reliably')
    }
    const attempts = [
      {
        audio,
        video: { displaySurface: 'window' },
        systemAudio: 'exclude',
        windowAudio: 'window',
        monitorTypeSurfaces: 'exclude',
      },
      {
        audio,
        video: { displaySurface: 'window' },
        systemAudio: 'exclude',
        monitorTypeSurfaces: 'exclude',
      },
      {
        audio,
        video: true,
        systemAudio: 'exclude',
      },
    ]
    let picked = null
    let lastErr = null
    try {
      for (const opts of attempts) {
        try {
          picked = await navigator.mediaDevices.getDisplayMedia(opts)
          if (opts !== attempts[0]) {
            console.warn('[offscreen] display capture fallback used:', opts)
          }
          break
        } catch (e) {
          lastErr = e
          // TypeError usually means one of the newer hints isn't accepted.
          if (e?.name === 'TypeError') {
            console.warn('[offscreen] display capture option rejected, retrying:', opts, e)
            continue
          }
          throw e
        }
      }
      if (!picked) throw lastErr || new Error('DISPLAY_CAPTURE_FAILED')
      streamRef = picked
    } catch (e) {
      // Picker dismissed/cancelled.
      if (e?.name === 'AbortError' || e?.name === 'NotAllowedError') {
        throw new Error('NO_WINDOW_SELECTED')
      }
      // Keep non-cancellation failures visible to the popup/logger.
      throw new Error(e?.message || 'DISPLAY_CAPTURE_FAILED')
    }
    const displaySurface = streamRef.getVideoTracks?.()[0]?.getSettings?.().displaySurface || null
    if (displaySurface && displaySurface !== 'window') {
      streamRef.getTracks().forEach((t) => t.stop())
      streamRef = null
      throw new Error('Please select a Window source (not Entire Screen or Tab).')
    }
    streamRef.getVideoTracks().forEach((t) => t.stop())
    if (!streamRef.getAudioTracks().length) {
      throw new Error('No audio track — select a window and enable Share audio.')
    }
    const aTrack0 = streamRef.getAudioTracks()[0]
    const suppressApplied = aTrack0?.getSettings?.().suppressLocalAudioPlayback === true
    if (audio.suppressLocalAudioPlayback && !suppressApplied) {
      console.warn('[offscreen] suppressLocalAudioPlayback requested but not applied by browser')
    }
    displaySuppressed = suppressApplied

  } else {
    throw new Error(`Unknown source: ${source}`)
  }
}

// Boost + gently compress quiet tab/display sources so the realtime model and
// the meter see a healthy level. Produces `sendTrack` (the track we send to
// OpenAI). Mic is left raw — getUserMedia already applies AGC.
async function setupCaptureConditioning() {
  teardownCaptureConditioning()
  const track = streamRef && streamRef.getAudioTracks()[0]
  if (!track) return
  if (currentSource === 'mic') { sendTrack = track; return }
  try {
    captureCtx = new AudioContext()
    if (captureCtx.state === 'suspended') {
      try { await captureCtx.resume() } catch {}
    }
    // A MediaStreamDestination track is SILENT while its context is suspended.
    // The offscreen doc has no user gesture, so if resume() didn't take, send
    // the raw track instead — otherwise display capture goes dead (audio meter
    // may still move but nothing reaches OpenAI).
    if (captureCtx.state !== 'running') {
      console.warn('[offscreen] capture AudioContext not running; sending raw track:', captureCtx.state)
      teardownCaptureConditioning()
      sendTrack = track
      return
    }
    captureSource = captureCtx.createMediaStreamSource(streamRef)
    captureGain = captureCtx.createGain()
    captureGain.gain.value = CAPTURE_MAKEUP_GAIN
    captureComp = captureCtx.createDynamicsCompressor()
    captureComp.threshold.value = -24
    captureComp.knee.value = 24
    captureComp.ratio.value = 3
    captureComp.attack.value = 0.003
    captureComp.release.value = 0.25
    captureDest = captureCtx.createMediaStreamDestination()
    captureSource.connect(captureGain)
    captureGain.connect(captureComp)
    captureComp.connect(captureDest)
    sendTrack = captureDest.stream.getAudioTracks()[0] || track
    console.log('[offscreen] capture conditioning active', {
      source: currentSource, displaySuppressed, ctx: captureCtx.state, sendTrack: !!sendTrack,
    })
  } catch (e) {
    console.warn('[offscreen] capture conditioning failed; sending raw track:', e)
    teardownCaptureConditioning()
    sendTrack = track // fall back to the raw track if Web Audio fails
  }
}

function teardownCaptureConditioning() {
  if (originalGain) { try { originalGain.disconnect() } catch {} originalGain = null }
  if (captureSource) { try { captureSource.disconnect() } catch {} captureSource = null }
  if (captureGain) { try { captureGain.disconnect() } catch {} captureGain = null }
  if (captureComp) { try { captureComp.disconnect() } catch {} captureComp = null }
  if (captureCtx) { try { captureCtx.close() } catch {} captureCtx = null }
  captureDest = null
  sendTrack = null
}

function setupOriginalPlayback() {
  // Replay only when playback is ours to control: tab (Chrome already mutes the
  // captured tab) or a display capture that suppressed local playback. When a
  // display capture isn't suppressed, the app plays directly — replaying would
  // double it — so we skip it.
  if (currentSource === 'mic') return
  if (currentSource === 'display' && !displaySuppressed) return

  const vol = desiredOriginalVolume()
  // For a suppressed display capture, an <audio> element often stays silent
  // (Chrome keeps suppressing the captured track). Route the original through
  // the (running) conditioning context's destination with a gain node instead,
  // which gives reliable, controllable playback.
  if (currentSource === 'display' && displaySuppressed && captureCtx && captureSource) {
    try {
      originalGain = captureCtx.createGain()
      originalGain.gain.value = vol
      captureSource.connect(originalGain)
      originalGain.connect(captureCtx.destination)
      console.log('[offscreen] original monitor via Web Audio gain', { ctx: captureCtx.state, vol })
      return
    } catch (e) {
      console.warn('[offscreen] original gain monitor failed; using element:', e)
      originalGain = null
    }
  }

  originalAudioEl = new Audio()
  originalAudioEl.srcObject = streamRef
  originalAudioEl.autoplay = true
  originalAudioEl.volume = vol
  originalAudioEl.play?.().catch(() => {})
}

function setOriginalMode(mode) {
  originalMode = mode === 'mute' || mode === 'duck' ? mode : 'normal'
  // Snap to target on an explicit toggle; the meter loop keeps it ramped after.
  const vol = desiredOriginalVolume()
  if (originalGain) originalGain.gain.value = vol
  if (originalAudioEl) originalAudioEl.volume = vol
  applyDubBoost()
}

// Target volume for the original (computer) audio given the current mode and,
// for ducking, whether we're inside a long silent gap.
function desiredOriginalVolume() {
  if (originalMode === 'mute') return 0
  if (originalMode === 'normal') return 1
  return Date.now() - lastVoicedTs > SILENCE_GAP_MS ? DUCK_SOFT : DUCK_DEEP
}

// Ease the original volume toward its target so duck/mute transitions and the
// silence-gap easing don't click. Called from the level-meter loop.
function rampOriginalVolume() {
  const target = desiredOriginalVolume()
  const step = 0.08
  const ramp = (cur) => (Math.abs(target - cur) <= step ? target : cur + (target > cur ? step : -step))
  if (originalGain) originalGain.gain.value = ramp(originalGain.gain.value)
  if (originalAudioEl) originalAudioEl.volume = ramp(originalAudioEl.volume)
}

// ---------- Audio level meter ----------
function startLevelMeter() {
  stopLevelMeter()
  if (!streamRef || !streamRef.getAudioTracks().length) return
  try {
    // Tap the conditioned signal (what we actually send) when available so the
    // meter reflects the boosted level; otherwise read the raw stream (mic / no
    // conditioning graph).
    if (captureCtx && captureComp) {
      meterAnalyser = captureCtx.createAnalyser()
      meterAnalyser.fftSize = 512
      captureComp.connect(meterAnalyser)
    } else {
      meterCtx = new AudioContext()
      if (meterCtx.state === 'suspended') meterCtx.resume().catch(() => {})
      meterSource = meterCtx.createMediaStreamSource(streamRef)
      meterAnalyser = meterCtx.createAnalyser()
      meterAnalyser.fftSize = 512
      meterSource.connect(meterAnalyser)
    }
    const buf = new Uint8Array(meterAnalyser.fftSize)
    meterInterval = setInterval(() => {
      if (!meterAnalyser) return
      meterAnalyser.getByteTimeDomainData(buf)
      let sum = 0
      for (let i = 0; i < buf.length; i++) {
        const v = (buf[i] - 128) / 128
        sum += v * v
      }
      const rms = Math.sqrt(sum / buf.length)
      const level = paused ? 0 : Math.min(1, rms * 3)
      if (!paused && rms > VOICE_RMS) {
        originalVoiced += 0.06
        lastVoicedTs = Date.now()
      }
      // Keep the original-audio volume eased toward its duck/mute/normal target.
      rampOriginalVolume()
      chrome.runtime.sendMessage({ action: 'audioLevel', level }).catch(() => {})
    }, 60)
  } catch {
    stopLevelMeter()
  }
}

function stopLevelMeter() {
  if (meterInterval) { clearInterval(meterInterval); meterInterval = null }
  if (meterSource) { try { meterSource.disconnect() } catch {} meterSource = null }
  meterAnalyser = null
  if (meterCtx) { try { meterCtx.close() } catch {} meterCtx = null }
}

// ---------- Dub playback graph (loudness boost + limiter) ----------
// ---------- Drift correction (best-effort) ----------
// We can't reschedule a live WebRTC audio track, so we keep the dub from
// falling behind by measuring how much voiced dub audio has played versus
// voiced original audio, and gently raising dub playbackRate to catch up.
// A periodic soft re-anchor bounds the natural length difference between a
// language's speech and its translation.
function startDubMeter(stream) {
  stopDubMeter()
  if (!stream || !stream.getAudioTracks().length) return
  try {
    dubMeterCtx = new AudioContext()
    if (dubMeterCtx.state === 'suspended') dubMeterCtx.resume().catch(() => {})
    dubMeterSource = dubMeterCtx.createMediaStreamSource(stream)
    dubMeterAnalyser = dubMeterCtx.createAnalyser()
    dubMeterAnalyser.fftSize = 512
    dubMeterSource.connect(dubMeterAnalyser)
    const buf = new Uint8Array(dubMeterAnalyser.fftSize)
    dubMeterInterval = setInterval(() => {
      if (!dubMeterAnalyser) return
      dubMeterAnalyser.getByteTimeDomainData(buf)
      let sum = 0
      for (let i = 0; i < buf.length; i++) {
        const v = (buf[i] - 128) / 128
        sum += v * v
      }
      const rms = Math.sqrt(sum / buf.length)
      // Only count when actually audible (muted/paused dub shouldn't accrue).
      if (!paused && dubbingEnabled && rms > VOICE_RMS) dubVoiced += 0.06
    }, 60)
  } catch {
    stopDubMeter()
  }
}

function stopDubMeter() {
  if (dubMeterInterval) { clearInterval(dubMeterInterval); dubMeterInterval = null }
  if (dubMeterSource) { try { dubMeterSource.disconnect() } catch {} dubMeterSource = null }
  dubMeterAnalyser = null
  if (dubMeterCtx) { try { dubMeterCtx.close() } catch {} dubMeterCtx = null }
}

function startDriftLoop() {
  stopDriftLoop()
  driftInterval = setInterval(() => {
    if (!dubAudioEl) return
    if (paused || videoPaused || !dubbingEnabled) {
      dubAudioEl.playbackRate = 1.0
      return
    }
    const drift = originalVoiced - dubVoiced // seconds the dub is behind
    let rate = 1.0
    if (drift >= 3) rate = 1.08
    else if (drift >= 1.5) rate = 1.05
    else if (drift >= 0.4) rate = 1.03
    dubAudioEl.playbackRate = rate

    // Soft re-anchor so translation-length bias can't accumulate forever.
    const base = Math.min(originalVoiced, dubVoiced)
    if (base > 12) {
      originalVoiced -= base
      dubVoiced -= base
    }
  }, 500)
}

function stopDriftLoop() {
  if (driftInterval) { clearInterval(driftInterval); driftInterval = null }
}

function resetDriftAnchor() {
  originalVoiced = 0
  dubVoiced = 0
}

function onVideoTime(t, isPaused) {
  videoPaused = isPaused
  // A seek (large jump in either direction) makes buffered dub stale — re-anchor.
  if (lastVideoTime != null && Math.abs(t - lastVideoTime) > 2) {
    resetDriftAnchor()
  }
  lastVideoTime = t
}

// ---------- WebRTC (persistent session) ----------
async function startWebRTC() {
  let secret
  // Ask the background worker for a freshly-refreshed token (the one captured at
  // start may have expired, e.g. on a reconnect).
  let freshToken = authToken
  try {
    const res = await chrome.runtime.sendMessage({ action: 'getToken' })
    if (res?.token) freshToken = res.token
  } catch {}
  try {
    const res = await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(freshToken ? { Authorization: `Bearer ${freshToken}` } : {}),
        ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
      },
      body: JSON.stringify({ targetLang: currentTargetLang }),
    })
    const data = await res.json().catch(() => null)
    if (!res.ok || !data?.value) throw new Error(data?.error || `token ${res.status}`)
    secret = data.value
    // Hand the server-tracked metering session id to the background worker so it
    // can run the server-authoritative credit heartbeat. On a re-mint (e.g. a
    // language switch reconnect) this replaces the previous session id.
    if (data.sessionId) {
      try { chrome.runtime.sendMessage({ action: 'realtimeSession', sessionId: data.sessionId }) } catch {}
    }
  } catch (e) {
    chrome.runtime.sendMessage({ action: 'proxyError', message: `Token error: ${e?.message || e}` })
    return
  }

  pc = new RTCPeerConnection()

  dubAudioEl = new Audio()
  dubAudioEl.autoplay = true
  // Time-stretch instead of pitch-shifting when drift correction speeds playback,
  // so catch-up no longer sounds "blurred"/distorted.
  dubAudioEl.preservesPitch = true
  pc.ontrack = (ev) => {
    dubAudioEl.srcObject = ev.streams[0]
    // Always play dub via the HTML element — it's a remote WebRTC stream.
    if (dubbingEnabled && !paused) dubAudioEl.muted = false
    else applyDubMute()
    applyDubBoost()
    const playPromise = dubAudioEl.play?.()
    if (playPromise && typeof playPromise.catch === 'function') {
      playPromise.catch((e) => {
        console.warn('[offscreen] dub play failed', e)
      })
    }
    console.log('[offscreen] dub track attached', {
      dubbingEnabled,
      paused,
      muted: dubAudioEl.muted,
    })
    startDubMeter(ev.streams[0])
    startDriftLoop()
  }

  dcOpen = false
  dcQueue = []
  dataChannel = pc.createDataChannel('oai-events')
  dataChannel.onopen = () => {
    dcOpen = true
    for (const queued of dcQueue) {
      try { dataChannel.send(queued) } catch {}
    }
    dcQueue = []
  }
  dataChannel.onclose = () => { dcOpen = false }
  dataChannel.onmessage = onOaiEvent

  // Send the conditioned (boosted) track when available; fall back to raw.
  const track = sendTrack || streamRef.getAudioTracks()[0]
  audioSender = pc.addTrack(track, captureDest ? captureDest.stream : streamRef)

  try {
    const offer = await pc.createOffer()
    await pc.setLocalDescription(offer)

    const sdpRes = await fetch('https://api.openai.com/v1/realtime/translations/calls', {
      method: 'POST',
      headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/sdp' },
      body: offer.sdp,
    })
    if (!sdpRes.ok) throw new Error(`SDP ${sdpRes.status}`)
    const answer = await sdpRes.text()
    await pc.setRemoteDescription({ type: 'answer', sdp: answer })

    chrome.runtime.sendMessage({ action: 'proxyReady' })
  } catch (e) {
    chrome.runtime.sendMessage({ action: 'proxyError', message: `WebRTC error: ${e?.message || e}` })
  }
}

function onOaiEvent(e) {
  let ev
  try { ev = JSON.parse(e.data) } catch { return }

  if (ev.type === 'session.output_transcript.delta') {
    sendCaption('translationUpdate', ev.delta, false)
  } else if (ev.type === 'session.output_transcript.done') {
    sendCaption('translationUpdate', ev.transcript || '', true)
  } else if (ev.type === 'session.input_transcript.delta') {
    sendCaption('transcriptUpdate', ev.delta, false)
  } else if (ev.type === 'session.input_transcript.done') {
    sendCaption('transcriptUpdate', ev.transcript || '', true)
  } else if (ev.type === 'error' || (ev.type || '').endsWith('.error')) {
    chrome.runtime.sendMessage({ action: 'proxyError', message: ev.error?.message || 'OpenAI error' })
  }
}

function sendCaption(action, text, isFinal) {
  if (!text) return
  chrome.runtime.sendMessage({ action, text, isFinal })
}

function applyDubMute() {
  const audible = dubbingEnabled && !paused
  if (dubAudioEl) dubAudioEl.muted = !audible
  applyDubBoost()
}

// ---------- macOS dub boost ----------
// On macOS + window capture, suppression isn't used (it mutes the entire device).
// Instead, when "Lower volume" (duck) is active, we route the dub through a high
// gain so it dominates over the original window audio.
const DUB_BOOST_GAIN = 6.0
let dubBoostCtx = null
let dubBoostSource = null
let dubBoostGain = null

function applyDubBoost() {
  const shouldBoost = platformOS === 'mac' && currentSource === 'display'
    && originalMode === 'duck' && dubbingEnabled && !paused
  if (shouldBoost && dubAudioEl?.srcObject) {
    if (!dubBoostCtx) dubBoostCtx = new AudioContext()
    if (dubBoostCtx.state === 'suspended') dubBoostCtx.resume().catch(() => {})
    if (!dubBoostSource) {
      dubBoostSource = dubBoostCtx.createMediaStreamSource(dubAudioEl.srcObject)
      dubBoostGain = dubBoostCtx.createGain()
      dubBoostSource.connect(dubBoostGain)
      dubBoostGain.connect(dubBoostCtx.destination)
    }
    dubBoostGain.gain.value = DUB_BOOST_GAIN
    dubAudioEl.muted = true
  } else {
    teardownDubBoost()
    if (dubAudioEl) dubAudioEl.muted = !(dubbingEnabled && !paused)
  }
}

function teardownDubBoost() {
  if (dubBoostSource) { try { dubBoostSource.disconnect() } catch {} dubBoostSource = null }
  if (dubBoostGain) { try { dubBoostGain.disconnect() } catch {} dubBoostGain = null }
  if (dubBoostCtx) { try { dubBoostCtx.close() } catch {} dubBoostCtx = null }
}

// ---------- Live language switch (no reconnect) ----------
function setOutputLanguage(lang) {
  const payload = JSON.stringify({
    type: 'session.update',
    session: { audio: { output: { language: lang } } },
  })
  if (dcOpen && dataChannel) {
    try { dataChannel.send(payload) } catch {}
  } else {
    dcQueue.push(payload)
  }
}

// ---------- Pause / resume ----------
function pauseCapture() {
  paused = true
  if (audioSender) { try { audioSender.replaceTrack(null) } catch {} }
  applyDubMute()
  teardownDubBoost()
  // For window/screen: release the captured stream so the OS shows the source
  // as no longer shared, and so resume can re-prompt the picker.
  if (currentSource === 'display') {
    stopLevelMeter()
    teardownCaptureConditioning()
    if (originalAudioEl) { try { originalAudioEl.pause() } catch {} originalAudioEl.srcObject = null; originalAudioEl = null }
    if (originalGain) { try { originalGain.disconnect() } catch {} originalGain = null }
    if (streamRef) { streamRef.getTracks().forEach((t) => t.stop()); streamRef = null }
    displaySuppressed = false
  }
}

async function resumeCapture() {
  paused = false
  if (currentSource === 'display') {
    // Re-prompt the window picker on every resume so the user re-confirms the
    // source. This fully re-acquires the stream and rebuilds the audio graph.
    try {
      await acquireStream({ source: 'display' })
    } catch (e) {
      chrome.runtime.sendMessage({ action: 'proxyError', message: `${e?.message || e}` })
      paused = true
      return
    }
    await setupCaptureConditioning()
    setupOriginalPlayback()
    startLevelMeter()
    // Attach the new conditioned track to the existing WebRTC sender.
    const track = sendTrack || streamRef?.getAudioTracks?.()[0]
    if (audioSender && track) { try { audioSender.replaceTrack(track) } catch {} }
  } else {
    // Tab/mic: simple resume — just re-attach the existing track.
    const track = sendTrack || streamRef?.getAudioTracks?.()[0]
    if (audioSender && track) { try { audioSender.replaceTrack(track) } catch {} }
  }
  applyDubMute()
  resetDriftAnchor()
}

// ---------- Teardown ----------
function stopCapture() {
  stopLevelMeter()
  stopDubMeter()
  teardownDubBoost()
  teardownCaptureConditioning()
  stopDriftLoop()
  resetDriftAnchor()
  videoPaused = false
  lastVideoTime = null
  if (dataChannel) { try { dataChannel.close() } catch {} dataChannel = null }
  if (pc) { try { pc.close() } catch {} pc = null }
  audioSender = null
  dcOpen = false
  dcQueue = []
  if (dubAudioEl) { try { dubAudioEl.pause() } catch {} dubAudioEl.srcObject = null; dubAudioEl = null }
  if (originalAudioEl) { try { originalAudioEl.pause() } catch {} originalAudioEl.srcObject = null; originalAudioEl = null }
  if (streamRef) { streamRef.getTracks().forEach((t) => t.stop()); streamRef = null }
  paused = false
  dubbingEnabled = false
  originalMode = 'normal'
  displaySuppressed = false
  currentTargetLang = null
  authToken = null
}
