// 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

// Dub loudness boost: WebRTC remote audio plays back fairly quietly, so we route
// the dub element through a Web Audio gain node (an <audio>.volume maxes at 1.0).
let dubCtx = null
let dubGainNode = null
let dubSourceNode = null
const DUB_GAIN = 1.6

// ---------- Fallback (non-realtime) language pipeline ----------
// Languages gpt-realtime-translate can't translate to. For these we run the
// realtime session in transcribe-only mode (original-language transcript), then
// translate via /api/translate and synthesize a dub via /api/tts.
const FALLBACK_LANGS = new Set(['nl', 'ar', 'pl', 'tr'])
function isFallbackLang(lang) { return FALLBACK_LANGS.has((lang || '').toLowerCase()) }
let transcribeMode = false
// Fallback dub is a cascade (translate + TTS) that plays at 1.0x and lags behind
// the source, sounding slow/drawn-out. Speak it fast (pitch preserved) so it
// keeps pace. Arabic is the slowest, so it gets the highest rate.
const FALLBACK_DUB_RATE = { ar: 1.5, nl: 1.5, pl: 1.5, tr: 1.5 }
function fallbackDubRate(lang) { return FALLBACK_DUB_RATE[(lang || '').toLowerCase()] || 1.5 }

// Guard against dubbing/captioning garbage: the MT model can leak an English
// apology ("sorry, I was unable to translate"), echo untranslatable noise, or
// return filler like "aah" that the TTS then vocalizes oddly.
const REFUSAL_RE = /\b(unable to translate|can'?t translate|cannot translate|no translation|i (?:was|am|'m) (?:unable|not able) to)\b/i
function hasLetters(s) { return /[\p{L}]/u.test(s || '') }
function isBadTranslation(translated, source) {
  const t = (translated || '').trim()
  if (t.length < 2 || !hasLetters(t)) return true // punctuation / "aah" / empty
  if (REFUSAL_RE.test(t)) return true // English apology leaked into the output
  if (t.toLowerCase() === (source || '').trim().toLowerCase()) return true // untranslated echo
  return false
}
// Server-tracked metering session id (from /api/realtime/token). Passed to
// /api/translate and /api/tts so they skip per-call billing — the realtime
// heartbeat already meters this session at 1 credit/min.
let meterSessionId = null

// Fallback sentence assembly: gpt-realtime-translate streams input_transcript
// DELTAS but does not reliably emit a `.done` event. Waiting for a long silence
// stalls captions/dub for ~20s during continuous speech, so we flush eagerly: on
// a sentence end, a char cap, a short silence, or a hard max-hold (so continuous
// speech still flushes regularly).
let fbBuffer = ''
let fbSilenceTimer = null // reset on each delta — fires on the tail of speech
let fbMaxTimer = null      // set once per buffer — forces a flush during long runs
const FB_SILENCE_MS = 700
const FB_MAX_HOLD_MS = 2500
const FB_MAX_CHARS = 120
// Sentence-final punctuation across our fallback source languages.
const FB_SENTENCE_END = /[.!?…。！？؟]["'”’)\]]?\s*$/

// Sequential TTS dub player (blob -> element -> gain -> destination).
let ttsAudioEl = null
let ttsCtx = null
let ttsGainNode = null
let ttsSourceNode = null
let ttsQueue = []
let ttsPlaying = false
const TTS_QUEUE_MAX = 6

// 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'
    transcribeMode = isFallbackLang(currentTargetLang)
    // Fallback langs: keep the realtime session transcribing (output pinned to
    // English, which we ignore) and dub via TTS. Supported langs translate
    // directly through the realtime engine.
    setOutputLanguage(transcribeMode ? 'en' : currentTargetLang)
    fbReset() // drop any half-assembled transcript from the previous language
    stopTts() // drop any dub queued for the previous language
    applyDubMute() // re-evaluate realtime dub muting under the new mode
    // 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'
  const vol = desiredOriginalVolume()
  if (originalGain) originalGain.gain.value = vol
  if (originalAudioEl) originalAudioEl.volume = vol
}

// 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
    // Server decides transcribe-only mode (fallback languages). In this mode the
    // realtime dub is ignored and we translate + synthesize the dub ourselves.
    transcribeMode = data.transcribeOnly === true || isFallbackLang(currentTargetLang)
    // 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) {
      meterSessionId = 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) => {
    const stream = ev.streams[0]
    dubAudioEl.srcObject = stream
    // The gain graph (built from the MediaStream) is the only audible path, so
    // keep the element muted — a MediaStream-backed <audio> can otherwise leak
    // directly to the speakers and bypass the mute when dubbing is off.
    dubAudioEl.muted = true
    setupDubGain(stream)
    applyDubMute()
    if (!dubGainNode) {
      // Fallback (no Web Audio): play the dub through the element directly.
      dubAudioEl.muted = !(dubbingEnabled && !paused && !transcribeMode)
      const playPromise = dubAudioEl.play?.()
      if (playPromise && typeof playPromise.catch === 'function') {
        playPromise.catch((e) => {
          console.warn('[offscreen] dub play failed', e)
        })
      }
      // Drift correction only affects the element's playback, so it is only
      // meaningful in this fallback path.
      startDriftLoop()
    }
    console.log('[offscreen] dub track attached', { dubbingEnabled, paused, transcribeMode })
    startDubMeter(stream)
  }

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

// ---------- Echo dedup ----------
// Prevents feedback-loop echoes in window mode where dub audio can leak back
// into the capture stream and get re-processed by OpenAI. We must NOT drop
// legitimate fast speech, so we only suppress a line when it closely matches a
// RECENT line (echo) — not merely because it arrived soon after the previous one.
const DEDUP_HISTORY_SIZE = 6
const DEDUP_HISTORY_EXPIRE_MS = 12000
// Only treat similar text as an echo if it recurs within this window; a genuine
// repeated phrase later on is allowed through.
const DEDUP_ECHO_WINDOW_MS = 4000
const DEDUP_SIMILARITY = 0.80

const dedupState = {
  output: { history: [], lastTs: 0 },
  input: { history: [], lastTs: 0 },
}

function isSimilar(a, b) {
  if (!a || !b) return false
  if (a === b) return true
  const longer = a.length >= b.length ? a : b
  const shorter = a.length < b.length ? a : b
  if (longer.length === 0) return true
  if (longer.includes(shorter) && shorter.length > longer.length * 0.7) return true
  let matches = 0
  const len = Math.min(a.length, b.length)
  for (let i = 0; i < len; i++) { if (a[i] === b[i]) matches++ }
  return matches / Math.max(a.length, b.length) > DEDUP_SIMILARITY
}

function isDuplicate(channel, text) {
  if (!text) return false
  const st = dedupState[channel]
  const now = Date.now()
  st.history = st.history.filter(e => now - e.ts < DEDUP_HISTORY_EXPIRE_MS)
  // Drop only true echoes: text closely matching a line seen within the echo
  // window. Time alone never suppresses, so fast back-to-back speech is kept.
  for (const entry of st.history) {
    if ((now - entry.ts) < DEDUP_ECHO_WINDOW_MS && isSimilar(text, entry.text)) return true
  }
  // Pass — record it
  st.lastTs = now
  st.history.push({ text, ts: now })
  if (st.history.length > DEDUP_HISTORY_SIZE) st.history.shift()
  return false
}

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

  if (ev.type === 'session.output_transcript.delta') {
    // In transcribe-only mode the engine emits English we don't want — ignore it.
    if (transcribeMode) return
    sendCaption('translationUpdate', ev.delta, false)
  } else if (ev.type === 'session.output_transcript.done') {
    if (transcribeMode) return
    const text = ev.transcript || ''
    if (isDuplicate('output', text)) return
    sendCaption('translationUpdate', text, true)
  } else if (ev.type === 'session.input_transcript.delta') {
    sendCaption('transcriptUpdate', ev.delta, false)
    // Fallback: assemble from deltas and flush eagerly (sentence end / char cap /
    // short silence / hard max-hold), since `.done` is not emitted reliably here.
    if (transcribeMode && ev.delta) {
      fbBuffer += ev.delta
      if (FB_SENTENCE_END.test(fbBuffer) || fbBuffer.length >= FB_MAX_CHARS) {
        fbFlush()
      } else {
        fbArm()
      }
    }
  } else if (ev.type === 'session.input_transcript.done') {
    const text = ev.transcript || ''
    if (transcribeMode) {
      // Authoritative final arrived — cancel the timers and use it.
      fbReset()
      if (!text || isDuplicate('input', text)) return
      sendCaption('transcriptUpdate', text, true)
      translateAndDub(text)
      return
    }
    if (isDuplicate('input', text)) return
    sendCaption('transcriptUpdate', text, 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 })
}

// ---------- Fallback translate + TTS dub ----------
// Arm both timers: the silence timer resets on every delta (catches the tail of
// speech); the max-hold timer is set once per buffer (forces a flush during a
// long uninterrupted run so captions/dub don't stall).
function fbArm() {
  if (fbSilenceTimer) clearTimeout(fbSilenceTimer)
  fbSilenceTimer = setTimeout(fbFlush, FB_SILENCE_MS)
  if (!fbMaxTimer) fbMaxTimer = setTimeout(fbFlush, FB_MAX_HOLD_MS)
}

// Flush the accumulated input transcript as one final line: caption + dub.
function fbFlush() {
  if (fbSilenceTimer) { clearTimeout(fbSilenceTimer); fbSilenceTimer = null }
  if (fbMaxTimer) { clearTimeout(fbMaxTimer); fbMaxTimer = null }
  const text = fbBuffer.trim()
  fbBuffer = ''
  if (!text || isDuplicate('input', text)) return
  sendCaption('transcriptUpdate', text, true)
  translateAndDub(text)
}

function fbReset() {
  if (fbSilenceTimer) { clearTimeout(fbSilenceTimer); fbSilenceTimer = null }
  if (fbMaxTimer) { clearTimeout(fbMaxTimer); fbMaxTimer = null }
  fbBuffer = ''
}

function apiUrl(path) {
  try { return `${new URL(tokenUrl).origin}${path}` } catch { return path }
}

async function getActiveToken() {
  try {
    const res = await chrome.runtime.sendMessage({ action: 'getToken' })
    if (res?.token) return res.token
  } catch {}
  return authToken
}

// Translate one final transcript line to the target language for captions, then
// (when dubbing is on) synthesize speech and enqueue it for sequential playback.
async function translateAndDub(text) {
  const lang = (currentTargetLang || '').toLowerCase()
  if (!text || !isFallbackLang(lang)) return

  let translated = text
  try {
    const token = await getActiveToken()
    const res = await fetch(apiUrl('/api/translate'), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
      },
      body: JSON.stringify({ segments: [text], targetLang: lang, sessionId: meterSessionId }),
    })
    const data = await res.json().catch(() => null)
    if (res.ok && data?.translations?.[0]) translated = data.translations[0]
  } catch {}

  // Late-switch guard: bail if the language changed while we were translating.
  if ((currentTargetLang || '').toLowerCase() !== lang) return
  // Drop refusals/echoes/noise from both the caption and the dub — show nothing
  // rather than speaking "sorry I was unable to translate" or an "aah".
  if (isBadTranslation(translated, text)) return
  sendCaption('translationUpdate', translated, true)

  if (!dubbingEnabled || paused) return
  try {
    const token = await getActiveToken()
    const res = await fetch(apiUrl('/api/tts'), {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
      },
      body: JSON.stringify({ text: translated, lang, sessionId: meterSessionId }),
    })
    if (res.ok && (currentTargetLang || '').toLowerCase() === lang) {
      enqueueTts(await res.arrayBuffer())
    }
  } catch {}
}

function setupTtsPlayer() {
  if (ttsAudioEl) return
  ttsAudioEl = new Audio()
  ttsAudioEl.preservesPitch = true
  try {
    ttsCtx = new AudioContext()
    if (ttsCtx.state === 'suspended') ttsCtx.resume().catch(() => {})
    ttsSourceNode = ttsCtx.createMediaElementSource(ttsAudioEl)
    ttsGainNode = ttsCtx.createGain()
    ttsGainNode.gain.value = DUB_GAIN
    ttsSourceNode.connect(ttsGainNode)
    ttsGainNode.connect(ttsCtx.destination)
  } catch {
    ttsCtx = null; ttsGainNode = null; ttsSourceNode = null
  }
}

function enqueueTts(arrayBuffer) {
  if (!arrayBuffer || !arrayBuffer.byteLength) return
  const url = URL.createObjectURL(new Blob([arrayBuffer], { type: 'audio/mpeg' }))
  ttsQueue.push(url)
  // Bound the backlog so a long burst of speech can't grow latency unbounded.
  while (ttsQueue.length > TTS_QUEUE_MAX) {
    try { URL.revokeObjectURL(ttsQueue.shift()) } catch {}
  }
  playNextTts()
}

function playNextTts() {
  if (ttsPlaying) return
  if (!dubbingEnabled || paused) { clearTtsQueue(); return }
  const url = ttsQueue.shift()
  if (!url) return
  setupTtsPlayer()
  if (ttsCtx && ttsCtx.state === 'suspended') ttsCtx.resume().catch(() => {})
  ttsPlaying = true
  // Setting src runs the media load algorithm, which resets playbackRate to
  // defaultPlaybackRate — so set defaultPlaybackRate first (the load copies it
  // into playbackRate) and reassert after, or the fast dub silently plays at 1x.
  const rate = fallbackDubRate(currentTargetLang)
  ttsAudioEl.defaultPlaybackRate = rate
  ttsAudioEl.src = url
  ttsAudioEl.playbackRate = rate
  ttsAudioEl.onloadedmetadata = () => { ttsAudioEl.playbackRate = rate }
  ttsAudioEl.onended = () => { try { URL.revokeObjectURL(url) } catch {}; ttsPlaying = false; playNextTts() }
  ttsAudioEl.onerror = () => { try { URL.revokeObjectURL(url) } catch {}; ttsPlaying = false; playNextTts() }
  const p = ttsAudioEl.play?.()
  if (p && typeof p.catch === 'function') p.catch(() => { ttsPlaying = false })
}

function clearTtsQueue() {
  while (ttsQueue.length) { try { URL.revokeObjectURL(ttsQueue.shift()) } catch {} }
}

function stopTts() {
  clearTtsQueue()
  ttsPlaying = false
  if (ttsAudioEl) { try { ttsAudioEl.pause() } catch {} ttsAudioEl.removeAttribute('src') }
}

// Build the dub gain graph: MediaStreamSource -> gain -> destination. Routing
// the remote stream itself (not the <audio> element) through Web Audio lets us
// push past the 1.0 ceiling of <audio>.volume AND guarantees the gain node is
// the single audible path, so gain=0 reliably mutes (a MediaStream-backed
// element can otherwise leak directly to the speakers).
function setupDubGain(stream) {
  teardownDubGain()
  if (!stream || !stream.getAudioTracks().length) return
  try {
    dubCtx = new AudioContext()
    if (dubCtx.state === 'suspended') dubCtx.resume().catch(() => {})
    dubSourceNode = dubCtx.createMediaStreamSource(stream)
    dubGainNode = dubCtx.createGain()
    dubGainNode.gain.value = 0
    dubSourceNode.connect(dubGainNode)
    dubGainNode.connect(dubCtx.destination)
  } catch (e) {
    // Fallback: no Web Audio routing; the caller plays the element directly.
    teardownDubGain()
  }
}

function teardownDubGain() {
  if (dubSourceNode) { try { dubSourceNode.disconnect() } catch {} dubSourceNode = null }
  if (dubGainNode) { try { dubGainNode.disconnect() } catch {} dubGainNode = null }
  if (dubCtx) { try { dubCtx.close() } catch {} dubCtx = null }
}

function applyDubMute() {
  // The realtime dub track is only the audible dub for realtime-supported
  // languages. In transcribe-only (fallback) mode it carries ignored English,
  // so it stays muted and the TTS player provides the dub instead.
  const realtimeAudible = dubbingEnabled && !paused && !transcribeMode
  if (dubGainNode) {
    // Gain graph is the sole audible path; keep the element muted so it can
    // never leak directly to the speakers.
    dubGainNode.gain.value = realtimeAudible ? DUB_GAIN : 0
    if (dubAudioEl) dubAudioEl.muted = true
  } else if (dubAudioEl) {
    // Fallback: element is the audible path, so mute via the element.
    dubAudioEl.muted = !realtimeAudible
  }
  // Pause/clear the fallback dub when it shouldn't be audible.
  if (!dubbingEnabled || paused) {
    if (ttsAudioEl) { try { ttsAudioEl.pause() } catch {} }
    ttsPlaying = false
    clearTtsQueue()
  }
}

// ---------- 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
  fbReset() // discard any partial transcript so it isn't dubbed after resume
  if (audioSender) { try { audioSender.replaceTrack(null) } catch {} }
  applyDubMute()
  // 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()
  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 = []
  teardownDubGain()
  stopTts()
  fbReset()
  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
  transcribeMode = false
  meterSessionId = null
  originalMode = 'normal'
  displaySuppressed = false
  currentTargetLang = null
  authToken = null
}
