// StellarCaptions — Popup logic

const FREE_CREDITS_TOTAL = 70
const CAPTION_SETTINGS_KEY = 'captionSettings'
const ACTIVE_SESSION_KEY = 'activeSessionId'
const CONTROLS_STATE_KEY = 'controlsState'
const SUPABASE_URL = 'https://gahhoyzmadmdershfmes.supabase.co'
const SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_-EQ1mve5ElZepU1-Ldz0Sg_m2S2O2Ei'
// Deployed backend/website the extension talks to. Change this (and HTTP_BASE in
// background.js) to your local dev server only when developing locally.
const BACKEND_URL = 'https://stellarcaptions.vercel.app'

// Ask the background worker for a valid (auto-refreshed) Supabase access token.
// Returns null when the session can no longer be refreshed; callers treat that
// as signed-out. Falls back to the stored token if the worker is unreachable.
async function authToken() {
  try {
    const res = await chrome.runtime.sendMessage({ action: 'getToken' })
    return res?.token || null
  } catch {
    const { token } = await chrome.storage.local.get(['token'])
    return token || null
  }
}

let isSignedIn = false
const DEFAULT_CAPTION_SETTINGS = {
  opacity: 0.78,
  fontSize: 18,
  color: '#ffffff',
  fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}

let isCapturing = false
let isPaused = false
let sessionId = null
let liveSegments = []
let liveInterim = { text: '', translatedText: '' }
let selectedSource = 'tab'
let viewingSession = null
// Whether the running capture controls original playback (tab always does; a
// display capture only when dubbing/duck/mute were active at start). Drives the
// mid-session re-acquire decision for Window/Screen sources.
let captureSuppressed = false

// Platform detection: on macOS window-mode, mute is hidden and "lower volume"
// boosts the dub instead of suppressing the original (OS limitation).
let platformOS = 'unknown'

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

// ---------- Mini View + PiP Captions ----------
const isMiniView = location.search.includes('miniview=1')
let miniViewWindowId = null

// On load: if this is the mini-view window, register it; hide the mini-view
// button itself (you're already in it).
if (isMiniView) {
  chrome.storage.local.set({ miniViewOpen: true })
  if (btnMiniView) btnMiniView.style.display = 'none'
  window.addEventListener('beforeunload', () => {
    chrome.storage.local.set({ miniViewOpen: false })
  })
}

// Check on load whether the mini-view is already open (for the main popup).
chrome.storage.local.get('miniViewOpen', (r) => {
  if (r.miniViewOpen && !isMiniView && miniWall) miniWall.classList.add('show')
})

// React to mini-view open/close from any context.
chrome.storage.onChanged.addListener((changes) => {
  if (changes.miniViewOpen) {
    const open = !!changes.miniViewOpen.newValue
    if (!isMiniView && miniWall) miniWall.classList.toggle('show', open)
  }
})

// Open Mini View
btnMiniView?.addEventListener('click', () => {
  chrome.windows.create({
    url: chrome.runtime.getURL('popup.html?miniview=1'),
    type: 'popup',
    width: 360,
    height: 560,
    focused: true,
  }, (win) => {
    if (win) miniViewWindowId = win.id
  })
  window.close()
})

// Return to Normal View (close the mini-view window)
btnCloseMini?.addEventListener('click', () => {
  chrome.storage.local.set({ miniViewOpen: false })
  // Find and close the mini-view window
  chrome.windows.getAll({ windowTypes: ['popup'] }, (wins) => {
    for (const w of wins) {
      chrome.tabs.query({ windowId: w.id }, (tabs) => {
        if (tabs?.some((t) => t.url?.includes('miniview=1'))) {
          chrome.windows.remove(w.id).catch(() => {})
        }
      })
    }
  })
})

// ---------- PiP Captions Overlay ----------
let pipWindow = null
let pipCaption = null

async function openCaptionsPiP() {
  if (pipWindow && !pipWindow.closed) { pipWindow.close(); pipWindow = null; return }
  if (!('documentPictureInPicture' in window)) {
    status.textContent = 'PiP not supported in this browser'
    return
  }
  pipWindow = await documentPictureInPicture.requestWindow({ width: 480, height: 90 })
  const style = pipWindow.document.createElement('style')
  style.textContent = `
    body { margin:0; background:rgba(0,0,0,0.82); display:flex; align-items:center; justify-content:center; min-height:100vh; padding:8px 16px; }
    .pip-caption { color:#fff; font-size:18px; line-height:1.4; text-align:center; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; word-break:break-word; }
    .pip-caption.empty { color:rgba(255,255,255,0.4); font-size:14px; }
  `
  pipWindow.document.head.appendChild(style)
  pipCaption = pipWindow.document.createElement('div')
  pipCaption.className = 'pip-caption empty'
  pipCaption.textContent = 'Waiting for captions…'
  pipWindow.document.body.appendChild(pipCaption)
  pipWindow.addEventListener('pagehide', () => { pipWindow = null; pipCaption = null; updatePipBtnState() })
  updatePipBtnState()
}

function updatePipBtnState() {
  if (btnPipCaptions) btnPipCaptions.classList.toggle('active', !!(pipWindow && !pipWindow.closed))
}

btnPipCaptions?.addEventListener('click', () => { openCaptionsPiP() })

let pipTextBuffer = ''
function feedPiP(text, isFinal) {
  if (!pipWindow || pipWindow.closed || !pipCaption) return
  pipCaption.classList.remove('empty')
  if (isFinal) {
    pipTextBuffer = text
  } else {
    pipTextBuffer += text
  }
  pipCaption.textContent = pipTextBuffer
  // Trim to last ~120 chars so old text rolls off
  if (pipTextBuffer.length > 150) {
    const trimmed = pipTextBuffer.slice(-120)
    const spaceIdx = trimmed.indexOf(' ')
    pipTextBuffer = spaceIdx > 0 ? trimmed.slice(spaceIdx + 1) : trimmed
    pipCaption.textContent = pipTextBuffer
  }
}

// Transcript view state
// Auto-follow keeps the live transcript glued to the latest line, but pauses
// when the user scrolls up and resumes when they return to the bottom.
let autoFollow = true
const FOLLOW_THRESHOLD = 28 // px from bottom still counts as "at the bottom"
// When not auto-following, the scroll position the user wants to hold. A live
// re-render restores this (not a mid-animation snapshot) so the view doesn't
// jump. A button-initiated smooth scroll sets `programmaticScrollUntil` so the
// scroll listener won't mistake the animation for manual input.
let intendedTop = 0
let programmaticScrollUntil = 0
let currentSegments = []
// Wall-clock timestamp of the first rendered segment, used to derive a relative
// time for legacy rows that predate per-segment recording offsets (relMs).
let transcriptBaseTs = 0
let viewFilter = 'both' // 'both' | 'original' | 'translated'
let searchQuery = ''
let matchSeq = 0 // incremented while building highlighted HTML
let matchEls = [] // <mark> elements for the current search, in document order
let matchIndex = 0 // index of the currently-focused match
let chatHistory = [] // [{ role, content }] for the open session's chat
let selectMode = false
const selectedIds = new Set()

// DOM refs
const el = (id) => document.getElementById(id)
const btnStart = el('btn-start')
const btnPause = el('btn-pause')
const btnStop = el('btn-stop')
const transport = el('transport')
const recDot = el('rec-dot')
const toggleDubbing = el('toggle-dubbing')
const toggleLower = el('toggle-lower')
const toggleMute = el('toggle-mute')
const muteRow = el('mute-row')
const muteOptLower = el('mute-opt-lower')
const muteOptMute = el('mute-opt-mute')
const btnMiniView = el('btn-mini-view')
const miniWall = el('mini-wall')
const btnCloseMini = el('btn-close-mini')
const btnPipCaptions = el('btn-pip-captions')
const pipRow = el('pip-row')
const status = el('status')
const creditsVal = el('credits-val')
const creditsBar = el('credits-bar')
const creditsValAcct = el('credits-val-acct')
const creditsBarAcct = el('credits-bar-acct')
const uiLocaleSelect = el('ui-locale')
const targetLangSelect = el('target-lang')
const pauseLabel = el('pause-label')

const sessionsView = el('sessions-view')
const sessionView = el('session-view')
const sessionList = el('session-list')
const sessionTitleInput = el('session-title')
const sessionTitleEdit = el('session-title-edit')
const transcriptList = el('transcript-list')
const transcriptScrollBtn = el('transcript-scroll-btn')
const transcriptSearch = el('transcript-search')
const searchCount = el('search-count')
const searchPrev = el('search-prev')
const searchNext = el('search-next')
const chatMessages = el('chat-messages')
const chatInput = el('chat-input')
const chatSend = el('chat-send')
const chatClear = el('chat-clear')
const viewFilterSeg = el('view-filter-seg')
const btnSelect = el('btn-select')
const selectBar = el('select-bar')
const selectCount = el('select-count')
const btnSelectCancel = el('btn-select-cancel')
const btnDeleteSelected = el('btn-delete-selected')

const captionOpacityInput = el('caption-opacity')
const captionOpacityVal = el('caption-opacity-val')
const captionSizeInput = el('caption-size')
const captionSizeVal = el('caption-size-val')
const captionColorInput = el('caption-color')
const captionColorBtn = el('cap-color-btn')
const captionColorMenu = el('cap-color-menu')
const captionColorDot = el('cap-color-dot')
const captionSwatches = el('caption-swatches')
const captionFontSelect = el('caption-font')
const audioMeter = el('audio-meter')
const audioMeterBars = audioMeter ? Array.from(audioMeter.querySelectorAll('span')) : []

// ---------- i18n ----------
function applyI18n() {
  document.querySelectorAll('[data-i18n]').forEach((node) => {
    node.textContent = t(node.dataset.i18n)
  })
  document.querySelectorAll('[data-i18n-ph]').forEach((node) => {
    node.setAttribute('placeholder', t(node.dataset.i18nPh))
  })
  document.querySelectorAll('[data-i18n-title]').forEach((node) => {
    node.setAttribute('title', t(node.dataset.i18nTitle))
  })
}

const uiLocaleGate = el('ui-locale-gate')

loadI18n().then(() => {
  uiLocaleSelect.value = getExtLocale()
  if (uiLocaleGate) uiLocaleGate.value = getExtLocale()
  applyI18n()
  maybeShowLangSuggest()
})

uiLocaleSelect.addEventListener('change', () => {
  setExtLocale(uiLocaleSelect.value)
  if (uiLocaleGate) uiLocaleGate.value = uiLocaleSelect.value
  applyI18n()
  hideLangSuggest()
})

if (uiLocaleGate) {
  uiLocaleGate.addEventListener('change', () => {
    setExtLocale(uiLocaleGate.value)
    uiLocaleSelect.value = uiLocaleGate.value
    applyI18n()
    hideLangSuggest()
  })
}

// ---------- "Switch to English?" suggestion ----------
const langSuggest = el('lang-suggest')
function hideLangSuggest() {
  if (langSuggest) langSuggest.classList.remove('show')
}
async function maybeShowLangSuggest() {
  if (!langSuggest) return
  if (getExtLocale() === 'en') return
  const { 'lang-suggest-dismissed': dismissed } = await chrome.storage.local.get('lang-suggest-dismissed')
  if (dismissed) return
  langSuggest.classList.add('show')
}
const langSuggestSwitch = el('lang-suggest-switch')
const langSuggestClose = el('lang-suggest-close')
if (langSuggestSwitch) {
  langSuggestSwitch.addEventListener('click', () => {
    setExtLocale('en')
    uiLocaleSelect.value = 'en'
    if (uiLocaleGate) uiLocaleGate.value = 'en'
    applyI18n()
    hideLangSuggest()
    chrome.storage.local.set({ 'lang-suggest-dismissed': true })
  })
}
if (langSuggestClose) {
  langSuggestClose.addEventListener('click', () => {
    hideLangSuggest()
    chrome.storage.local.set({ 'lang-suggest-dismissed': true })
  })
}

// ---------- Caption style settings ----------
let captionState = { ...DEFAULT_CAPTION_SETTINGS }

function syncCaptionUI() {
  const opacityPct = Math.round(captionState.opacity * 100)
  captionOpacityInput.value = String(opacityPct)
  if (captionOpacityVal) captionOpacityVal.textContent = String(opacityPct)
  captionSizeInput.value = String(captionState.fontSize)
  if (captionSizeVal) captionSizeVal.textContent = String(captionState.fontSize)
  captionColorInput.value = captionState.color
  if (captionColorDot) captionColorDot.style.background = captionState.color

  // Highlight the active color swatch (or none if it's a custom color).
  captionSwatches.querySelectorAll('.swatch[data-color]').forEach((sw) => {
    sw.classList.toggle('active', sw.dataset.color.toLowerCase() === captionState.color.toLowerCase())
  })
  // Reflect the active font in the dropdown.
  if (captionFontSelect) captionFontSelect.value = captionState.fontFamily
}

async function loadCaptionSettings() {
  const result = await chrome.storage.local.get(CAPTION_SETTINGS_KEY)
  const stored = result[CAPTION_SETTINGS_KEY] || {}
  captionState = { ...DEFAULT_CAPTION_SETTINGS, ...stored }
  syncCaptionUI()
}

async function persistCaptionSettings() {
  await chrome.storage.local.set({ [CAPTION_SETTINGS_KEY]: captionState })
  chrome.runtime.sendMessage({ action: 'updateCaptionSettings', settings: captionState })
}

function updateCaption(patch) {
  captionState = { ...captionState, ...patch }
  syncCaptionUI()
  persistCaptionSettings()
}

// Sliders for opacity (15-100%) and font size (14-40px).
const OPACITY_MIN = 15
const OPACITY_MAX = 100
const SIZE_MIN = 14
const SIZE_MAX = 40
const clamp = (n, min, max) => Math.min(max, Math.max(min, n))

function setOpacityPct(pct) {
  const v = clamp(Math.round(pct), OPACITY_MIN, OPACITY_MAX)
  updateCaption({ opacity: v / 100 })
}
function setFontSize(px) {
  const v = clamp(Math.round(px), SIZE_MIN, SIZE_MAX)
  updateCaption({ fontSize: v })
}

// Opacity is a slider; reflect the live value as it drags.
captionOpacityInput.addEventListener('input', () => {
  const n = parseInt(captionOpacityInput.value, 10)
  if (!Number.isNaN(n)) {
    if (captionOpacityVal) captionOpacityVal.textContent = String(clamp(n, OPACITY_MIN, OPACITY_MAX))
    setOpacityPct(n)
  }
})
// Font size is a slider; reflect the live value as it drags.
captionSizeInput.addEventListener('input', () => {
  const n = parseInt(captionSizeInput.value, 10)
  if (!Number.isNaN(n)) {
    if (captionSizeVal) captionSizeVal.textContent = String(clamp(n, SIZE_MIN, SIZE_MAX))
    setFontSize(n)
  }
})

// ---------- Font color dropdown (overlay so it never resizes the popup) ----------
function closeColorMenu() {
  if (!captionColorMenu) return
  captionColorMenu.classList.remove('open')
  if (captionColorBtn) {
    captionColorBtn.classList.remove('open')
    captionColorBtn.setAttribute('aria-expanded', 'false')
  }
}
if (captionColorBtn && captionColorMenu) {
  captionColorBtn.addEventListener('click', (e) => {
    e.stopPropagation()
    const open = captionColorMenu.classList.toggle('open')
    captionColorBtn.classList.toggle('open', open)
    captionColorBtn.setAttribute('aria-expanded', open ? 'true' : 'false')
  })
  // Keep the menu open while interacting with the custom color picker.
  captionColorMenu.addEventListener('click', (e) => e.stopPropagation())
  document.addEventListener('click', closeColorMenu)
}

captionColorInput.addEventListener('input', () => updateCaption({ color: captionColorInput.value }))

captionSwatches.querySelectorAll('.swatch[data-color]').forEach((sw) => {
  sw.addEventListener('click', () => {
    updateCaption({ color: sw.dataset.color })
    closeColorMenu()
  })
})

if (captionFontSelect) {
  captionFontSelect.addEventListener('change', () => updateCaption({ fontFamily: captionFontSelect.value }))
}

// ---------- Caption Style menu (overlay dropdown) ----------
const settingsGroup = el('settings-group')
el('settings-toggle').addEventListener('click', (e) => {
  e.stopPropagation()
  settingsGroup.classList.toggle('open')
})
// Close when clicking anywhere outside the menu.
document.addEventListener('click', (e) => {
  if (settingsGroup.classList.contains('open') && !e.target.closest('#settings-group')) {
    settingsGroup.classList.remove('open')
  }
})

// ---------- Capture UI state ----------
// Show the recording controls (Pause + Stop) while a session is active.
function showCapturingUI() {
  // Mitosis: the Start button splits into Pause + End in the same fixed-height
  // row (see .transport CSS) — no display swaps, so the layout never jumps.
  if (transport) transport.classList.add('live')
  if (pipRow) pipRow.style.display = ''
  updateDownloadAvailability()
}

// Return the controls to the idle state. Used by Stop, credits exhaustion, and
// pipeline errors so the UI never claims to be live when capture has ended.
function showIdleUI() {
  isCapturing = false
  isPaused = false
  if (transport) transport.classList.remove('live')
  if (pipRow) pipRow.style.display = 'none'
  btnPause.classList.remove('paused')
  setMeterActive(false)
  updateRecDot()
  updateDownloadAvailability()
  if (pipWindow && !pipWindow.closed) { pipWindow.close(); pipWindow = null }
  updatePipBtnState()
}

// ---------- Audio visualizer ----------
function setMeterActive(on) {
  if (!audioMeter) return
  audioMeter.classList.toggle('live', !!on)
  if (!on) renderMeterLevel(0)
}

function renderMeterLevel(level) {
  if (!audioMeterBars.length) return
  const n = audioMeterBars.length
  const mid = (n - 1) / 2
  for (let i = 0; i < n; i++) {
    // Center bars react strongest; add a little jitter so it feels alive.
    const dist = 1 - Math.abs(i - mid) / (mid + 1)
    const jitter = 0.75 + Math.random() * 0.25
    const h = 2 + level * 11 * dist * jitter
    audioMeterBars[i].style.height = `${Math.max(2, Math.min(13, h))}px`
  }
}

// ---------- Recording indicator dot ----------
function updateRecDot() {
  if (!recDot) return
  recDot.classList.remove('live', 'paused')
  if (isCapturing && isPaused) recDot.classList.add('paused')
  else if (isCapturing) recDot.classList.add('live')
}

// ---------- Source selector ----------
document.querySelectorAll('.source-btn').forEach((btn) => {
  btn.addEventListener('click', () => {
    const src = btn.dataset.source
    // Clicking the mic again turns it off (reverts to tab audio).
    selectedSource = src === 'mic' && selectedSource === 'mic' ? 'tab' : src
    document.querySelectorAll('.source-btn').forEach((b) => {
      b.classList.toggle('active', b.dataset.source === selectedSource)
    })
    updateMuteVisibility()
    saveControlsState()
  })
})

// ---------- Tabs ----------
document.querySelectorAll('.tab').forEach((tab) => {
  tab.addEventListener('click', () => {
    document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'))
    document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'))
    tab.classList.add('active')
    el(`panel-${tab.dataset.tab}`).classList.add('active')

    if (tab.dataset.tab === 'transcript') {
      if (selectMode) exitSelectMode()
      else if (isCapturing && sessionId) openSession(sessionId)
      else showSessionsList()
    }
  })
})

// ---------- Original-audio mode: 'normal' | 'duck' | 'mute' ----------
// Lower-volume (duck) and Mute are mutually exclusive toggles. They only affect
// computer audio (tab/window) — the microphone is external, so they're disabled
// for that source.
function currentOriginalMode() {
  if (toggleMute.classList.contains('on')) return 'mute'
  if (toggleLower.classList.contains('on')) return 'duck'
  return 'normal'
}

function setOriginalModeClasses(mode) {
  toggleLower.classList.toggle('on', mode === 'duck')
  toggleMute.classList.toggle('on', mode === 'mute')
}

function setOriginalMode(mode) {
  setOriginalModeClasses(mode)
  if (isCapturing) chrome.runtime.sendMessage({ action: 'setOriginalMode', mode })
}

// A running Window/Screen capture only suppresses local playback (so we can
// lower/mute it) when dubbing or duck/mute was active at start. If the user
// turns one of those on later, re-acquire the source so suppression applies.
function displayNeedsSuppression() {
  return toggleDubbing.classList.contains('on') || currentOriginalMode() !== 'normal'
}


function maybeReacquireDisplay() {
  if (!isCapturing || selectedSource !== 'display' || captureSuppressed) return false
  if (!displayNeedsSuppression()) return false
  captureSuppressed = true
  status.textContent = t('status.reselect')
  chrome.runtime.sendMessage({
    action: 'reacquireDisplay',
    dubbing: toggleDubbing.classList.contains('on'),
    originalMode: currentOriginalMode(),
  })
  return true
}

// ---------- Dubbing + original-audio toggles ----------
toggleDubbing.addEventListener('click', () => {
  if (!targetLangSelect.value) return
  toggleDubbing.classList.toggle('on')
  const dubOn = toggleDubbing.classList.contains('on')

  // Auto-duck: faintly lower the original when dubbing turns on so the dub stays
  // clear; restore full volume when dubbing turns off. (UI only for now.)
  if (dubOn) {
    if (currentOriginalMode() === 'normal') setOriginalModeClasses('duck')
  } else if (currentOriginalMode() !== 'normal') {
    setOriginalModeClasses('normal')
  }
  updateMuteVisibility()

  // A re-acquire restarts capture with the full new state, so skip the
  // incremental messages (they'd briefly play the dub over un-suppressed audio).
  if (maybeReacquireDisplay()) return

  if (isCapturing) {
    chrome.runtime.sendMessage({ action: 'updateDubbing', dubbing: dubOn })
    chrome.runtime.sendMessage({ action: 'setOriginalMode', mode: currentOriginalMode() })
  }
})

toggleLower.addEventListener('click', () => {
  setOriginalModeClasses(toggleLower.classList.contains('on') ? 'normal' : 'duck')
  if (maybeReacquireDisplay()) return
  if (isCapturing) chrome.runtime.sendMessage({ action: 'setOriginalMode', mode: currentOriginalMode() })
})

toggleMute.addEventListener('click', () => {
  setOriginalModeClasses(toggleMute.classList.contains('on') ? 'normal' : 'mute')
  if (maybeReacquireDisplay()) return
  if (isCapturing) chrome.runtime.sendMessage({ action: 'setOriginalMode', mode: currentOriginalMode() })
})

targetLangSelect.addEventListener('change', () => {
  updateDubbingAvailability()
  saveControlsState()

  if (isCapturing) {
    const targetLang = targetLangSelect.value || null
    if (window.DB && sessionId && window.DB.updateSession) {
      window.DB.updateSession(sessionId, { targetLang }).catch(() => {})
    }
    chrome.runtime.sendMessage({ action: 'updateTargetLang', targetLang })
  }
})

function updateDubbingAvailability() {
  const hasTarget = !!targetLangSelect.value
  const dubRow = toggleDubbing.closest('.control-row')
  if (hasTarget) {
    if (dubRow) dubRow.style.opacity = '1'
    toggleDubbing.style.pointerEvents = 'auto'
  } else {
    const wasOn = toggleDubbing.classList.contains('on')
    toggleDubbing.classList.remove('on')
    if (dubRow) dubRow.style.opacity = '0.45'
    toggleDubbing.style.pointerEvents = 'none'
    if (wasOn && isCapturing) {
      chrome.runtime.sendMessage({ action: 'updateDubbing', dubbing: false })
      // Restore full original volume when dubbing is disabled via Keep-original.
      if (currentOriginalMode() !== 'normal') setOriginalMode('normal')
    }
  }
  updateMuteVisibility()
}

function updateMuteVisibility() {
  const dubOn = toggleDubbing.classList.contains('on')
  if (dubOn) muteRow.classList.add('visible')
  else muteRow.classList.remove('visible')
  // Original-audio controls only apply to computer audio (tab/window); the mic
  // is external, so grey them out for that source.
  muteRow.classList.toggle('disabled', selectedSource === 'mic')
  // On macOS + window mode, hide the Mute toggle entirely (suppression causes
  // device-wide mute on macOS). Lower stays visible and boosts the dub instead.
  if (muteOptMute) {
    const hideMute = platformOS === 'mac' && selectedSource === 'display'
    muteOptMute.style.display = hideMute ? 'none' : ''
    // If mute was on and we're switching to a hidden state, revert to normal
    if (hideMute && toggleMute.classList.contains('on')) {
      toggleMute.classList.remove('on')
    }
  }
}

// ---------- Microphone permission ----------
// Neither the offscreen document nor the action popup can reliably show the mic
// permission prompt, so we open a tiny permission window. Once granted for the
// extension origin, the offscreen capture works without prompting again, and the
// background resumes the pending start automatically.
async function micPermissionState() {
  try {
    const p = await navigator.permissions.query({ name: 'microphone' })
    return p.state
  } catch {
    return 'unknown'
  }
}

async function micGrantedNow() {
  const st = await micPermissionState()
  if (st === 'granted') return true
  if (st === 'unknown') {
    // permissions API unavailable — best-effort inline check.
    try {
      const s = await navigator.mediaDevices.getUserMedia({ audio: true })
      s.getTracks().forEach((tr) => tr.stop())
      return true
    } catch {
      return false
    }
  }
  return false
}

// Sequential default title ("Recording 1", "Recording 2", …) backed by a
// persistent counter so numbers never collide even after deletions.
async function nextRecordingTitle() {
  let n = 1
  try {
    const { recordingSeq } = await chrome.storage.local.get('recordingSeq')
    n = (typeof recordingSeq === 'number' ? recordingSeq : 0) + 1
    await chrome.storage.local.set({ recordingSeq: n })
  } catch {}
  return `${t('session.recording')} ${n}`
}

// ---------- Start / Stop / Pause ----------
btnStart.addEventListener('click', async () => {
  // Sign-in wall: require a logged-in user before capture can start.
  if (!isSignedIn) {
    promptSignIn()
    return
  }

  // Microphone source needs an explicit permission grant before capture. Open the
  // permission window and let the background resume the start once it's allowed.
  if (selectedSource === 'mic' && !(await micGrantedNow())) {
    const tl = targetLangSelect.value
    let tabId
    try {
      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
      tabId = tab?.id
    } catch {}
    await chrome.storage.local.set({
      pendingMicStart: {
        source: 'mic',
        mode: tl ? 'translate' : 'transcribe',
        targetLang: tl || undefined,
        dubbing: tl ? toggleDubbing.classList.contains('on') : false,
        originalMode: currentOriginalMode(),
        sessionId: `session_${Date.now()}`,
        title: await nextRecordingTitle(),
        tabId,
      },
    })
    chrome.windows.create({
      url: chrome.runtime.getURL('request-mic.html'),
      type: 'popup',
      width: 440,
      height: 360,
    })
    status.textContent = t('status.micPrompt')
    return
  }

  const targetLang = targetLangSelect.value
  const dubbing = targetLang ? toggleDubbing.classList.contains('on') : false
  const originalMode = currentOriginalMode()
  const mode = targetLang ? 'translate' : 'transcribe'

  // If a session is somehow still active, stop it first so we start clean.
  if (isCapturing) {
    chrome.runtime.sendMessage({ action: 'stopCapture' })
    await new Promise((r) => setTimeout(r, 150))
  }

  sessionId = `session_${Date.now()}`
  liveSegments = []
  liveInterim = { text: '', translatedText: '' }
  await chrome.storage.local.set({ [ACTIVE_SESSION_KEY]: sessionId })

  if (window.DB) {
    await window.DB.createSession(sessionId, {
      title: await nextRecordingTitle(),
      sourceLang: null,
      targetLang: targetLang || null,
    })
  }

  let tabId
  try {
    const tabs = await chrome.tabs.query({ active: true, currentWindow: true })
    tabId = tabs && tabs[0] ? tabs[0].id : undefined
  } catch {}

  chrome.runtime.sendMessage({
    action: 'startCapture',
    source: selectedSource,
    mode,
    targetLang: targetLang || undefined,
    dubbing,
    originalMode,
    sessionId,
    tabId,
  })

  isCapturing = true
  isPaused = false
  autoFollow = true // glue the live transcript to the latest line on a fresh start
  // Tab is always controllable; display only when it suppressed local playback
  // at start (dubbing or duck/mute on); mic has no original to control.
  captureSuppressed = selectedSource === 'tab' || (selectedSource === 'display' && (dubbing || originalMode !== 'normal'))
  showCapturingUI()
  pauseLabel.textContent = t('btn.pause')
  btnPause.classList.remove('paused')
  status.textContent = ''
  setMeterActive(true)
  updateRecDot()
  saveControlsState()

  const transcriptTabActive = document.querySelector('.tab[data-tab="transcript"]')?.classList.contains('active')
  if (transcriptTabActive) showSessionsList()
})

btnPause.addEventListener('click', () => {
  if (!isPaused) {
    chrome.runtime.sendMessage({ action: 'pauseCapture' })
    isPaused = true
    pauseLabel.textContent = t('btn.resume')
    btnPause.classList.add('paused')
    status.textContent = t('status.paused')
    setMeterActive(false)
  } else {
    chrome.runtime.sendMessage({ action: 'resumeCapture' })
    isPaused = false
    pauseLabel.textContent = t('btn.pause')
    btnPause.classList.remove('paused')
    status.textContent = ''
    setMeterActive(true)
  }
  updateRecDot()
})

btnStop.addEventListener('click', () => {
  // Fully end the session so capture stops and captions no longer appear when
  // audio plays. (Pause only suspends; Stop tears the whole pipeline down.)
  chrome.runtime.sendMessage({ action: 'stopCapture' })
  chrome.storage.local.remove(ACTIVE_SESSION_KEY)
  status.textContent = t('status.stopped')
  showIdleUI()
})

// ---------- Incoming transcript / translation ----------
chrome.runtime.onMessage.addListener((msg) => {
  // Raw deltas only drive the interim tail; committed timestamped chunks are
  // owned by the background chunker and arrive via segmentCommit.
  if (msg.action === 'transcriptUpdate' && msg.text) {
    if (msg.isFinal) liveInterim.text = ''
    else liveInterim.text += msg.text
    renderLiveInterim()
    feedPiP(msg.text, msg.isFinal)
  }
  if (msg.action === 'translationUpdate' && msg.text) {
    if (msg.isFinal) liveInterim.translatedText = ''
    else liveInterim.translatedText += msg.text
    renderLiveInterim()
    feedPiP(msg.text, msg.isFinal)
  }
  if (msg.action === 'segmentCommit') {
    // The interim tail produced this chunk; clear it so text isn't shown twice.
    liveInterim.text = ''
    liveInterim.translatedText = ''
    appendCommittedSegment({
      text: msg.text || '',
      translatedText: msg.translatedText || null,
      timestamp: msg.timestamp || Date.now(),
      relMs: typeof msg.relMs === 'number' ? msg.relMs : null,
    })
  }
  if (msg.action === 'audioLevel') {
    if (isCapturing && !isPaused) {
      renderMeterLevel(msg.level || 0)
    }
  }
  if (msg.action === 'proxyError') {
    // Genuine pipeline failure (token/SDP/WS error). proxyClosed is intentionally
    // ignored here because it also fires during deliberate teardown (e.g. a live
    // language switch tears down the old connection). Tell the background to stop
    // so it doesn't leave a half-alive capture running.
    chrome.runtime.sendMessage({ action: 'stopCapture' })
    chrome.storage.local.remove(ACTIVE_SESSION_KEY)
    // The Window/Screen picker was dismissed without choosing a source — return
    // to idle with a clear hint instead of a generic error.
    if (typeof msg.message === 'string' && msg.message.includes('NO_WINDOW_SELECTED')) {
      status.textContent = t('status.windowNotSelected')
    } else {
      status.textContent = msg.message || t('status.error')
    }
    showIdleUI()
  }
  if (msg.action === 'creditsExhausted') {
    status.textContent = t('status.exhausted')
    chrome.storage.local.remove(ACTIVE_SESSION_KEY)
    showIdleUI()
  }
  if (msg.action === 'captureStatus' && typeof msg.text === 'string') {
    // Non-fatal notice (e.g. captions unavailable on a chrome:// page).
    status.textContent = msg.text
  }
})

// A fully-paired chunk from the background chunker. Persistence (IndexedDB) is
// owned by the background service worker so transcripts survive the popup being
// closed; here we only track the chunk for live display.
function appendCommittedSegment(segment) {
  if (!sessionId) return
  liveSegments.push({
    text: segment.text || '',
    translatedText: segment.translatedText || null,
    timestamp: segment.timestamp || Date.now(),
    relMs: typeof segment.relMs === 'number' ? segment.relMs : null,
  })
  if (viewingSession === sessionId) {
    currentSegments = liveSegments
    renderTranscript(true)
  }
}

function renderLiveInterim() {
  if (viewingSession === sessionId && isCapturing) {
    currentSegments = liveSegments
    renderTranscript(true)
  }
}

// Unified renderer for both the live session (with interim + auto-scroll) and
// saved sessions, honoring the search query and the Original/Translated/Both
// view filter.
function escapeRegExp(s) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

// Escape text for HTML, then wrap occurrences of the search query in <mark>
// tags with a sequential index so we can navigate between matches.
function highlight(text) {
  const safe = escapeHtml(text || '')
  const q = searchQuery.trim()
  if (!q) return safe
  const re = new RegExp(escapeRegExp(escapeHtml(q)), 'gi')
  return safe.replace(re, (m) => `<mark class="sc-match" data-mi="${matchSeq++}">${m}</mark>`)
}

function renderTranscript(isLive) {
  matchSeq = 0
  // Rebuilding innerHTML resets scrollTop to 0; remember where we were so we can
  // restore it (no teleport) and glide only the small delta to the new bottom.
  const prevTop = transcriptList.scrollTop
  const rows = currentSegments || []
  transcriptBaseTs = rows.length ? (rows[0].timestamp || 0) : 0
  const q = searchQuery.trim()
  let html = rows.map(rowHtml).join('')

  if (isLive && !q) {
    const hasInterim = !!(liveInterim.text || liveInterim.translatedText)
    if (hasInterim) {
      html += `<div class="transcript-item interim"><span class="time">•••</span>`
      if (viewFilter !== 'translated' && liveInterim.text) html += `<div>${escapeHtml(liveInterim.text)}</div>`
      if (viewFilter !== 'original' && liveInterim.translatedText) html += `<div class="translated">${escapeHtml(liveInterim.translatedText)}</div>`
      html += `</div>`
    }
  }

  if (!html) {
    transcriptList.innerHTML = `<p class="empty-state">${t('transcript.empty')}</p>`
    matchEls = []
    updateMatchUI()
    return
  }
  transcriptList.innerHTML = html

  matchEls = Array.from(transcriptList.querySelectorAll('.sc-match'))
  if (matchIndex >= matchEls.length) matchIndex = 0
  applyCurrentMatch(false)
  updateMatchUI()

  const animating = Date.now() < programmaticScrollUntil
  if (isLive && !q && autoFollow) {
    // Restore instantly to the previous position, then glide the new content's
    // height down to the latest line (smooth, not a teleport).
    transcriptList.scrollTop = prevTop
    transcriptList.scrollTo({ top: transcriptList.scrollHeight, behavior: 'smooth' })
  } else if (animating) {
    // A button-initiated smooth scroll is in flight. Restore the pre-rebuild
    // position for continuity, then re-issue the glide toward the intended
    // target so the live re-render can't yank it back to where it started.
    transcriptList.scrollTop = prevTop
    transcriptList.scrollTo({ top: intendedTop, behavior: 'smooth' })
  } else {
    // Not following (user scrolled up, or searching): hold their position.
    transcriptList.scrollTop = intendedTop
  }
  updateScrollBtn()
}

function distFromBottom() {
  return transcriptList.scrollHeight - transcriptList.scrollTop - transcriptList.clientHeight
}

// Show the floating button only when the list scrolls; point it toward whichever
// end is farther (down to latest when near the top, up to start when near the
// bottom). Clicking down also re-arms auto-follow.
function updateScrollBtn() {
  if (!transcriptScrollBtn) return
  const range = transcriptList.scrollHeight - transcriptList.clientHeight
  if (range <= 8) { transcriptScrollBtn.classList.remove('visible'); return }
  transcriptScrollBtn.classList.add('visible')
  const goUp = transcriptList.scrollTop > range / 2
  transcriptScrollBtn.classList.toggle('up', goUp)
  transcriptScrollBtn.dataset.dir = goUp ? 'up' : 'down'
}

// Format a recording-relative offset (ms) as m:ss, or h:mm:ss past an hour.
function formatRelTime(ms) {
  const total = Math.max(0, Math.round((ms || 0) / 1000))
  const h = Math.floor(total / 3600)
  const m = Math.floor((total % 3600) / 60)
  const s = total % 60
  const pad = (n) => String(n).padStart(2, '0')
  return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}

function rowHtml(s) {
  // Recording-relative time (pause-excluded). Legacy rows lack relMs, so fall
  // back to the offset from the first segment's wall-clock timestamp.
  const ms = typeof s.relMs === 'number' ? s.relMs : Math.max(0, (s.timestamp || 0) - transcriptBaseTs)
  const time = formatRelTime(ms)
  let body = ''
  if (viewFilter !== 'translated' && s.text) body += `<div>${highlight(s.text)}</div>`
  if (viewFilter !== 'original' && s.translatedText) body += `<div class="translated">${highlight(s.translatedText)}</div>`
  if (!body) return ''
  return `<div class="transcript-item"><span class="time">${highlight(time)}</span>${body}</div>`
}

function updateMatchUI() {
  if (!searchCount) return
  const total = matchEls.length
  searchCount.textContent = `${total ? matchIndex + 1 : 0}/${total}`
}

function applyCurrentMatch(scroll) {
  matchEls.forEach((m) => m.classList.remove('current'))
  const cur = matchEls[matchIndex]
  if (!cur) return
  cur.classList.add('current')
  if (scroll) cur.scrollIntoView({ block: 'center', behavior: 'smooth' })
}

function stepMatch(dir) {
  if (!matchEls.length) return
  matchIndex = (matchIndex + dir + matchEls.length) % matchEls.length
  applyCurrentMatch(true)
  updateMatchUI()
}

// ---------- Sessions list ----------
async function showSessionsList() {
  sessionView.classList.remove('active')
  sessionsView.style.display = 'block'
  viewingSession = null

  if (!window.DB) return
  let sessions = await window.DB.getAllSessions()

  // Pin the live session to the top (and ensure it appears even with 0 saved
  // segments yet).
  const liveId = isCapturing ? sessionId : null
  if (liveId) {
    const idx = sessions.findIndex((s) => s.id === liveId)
    let liveEntry
    if (idx >= 0) liveEntry = sessions.splice(idx, 1)[0]
    else
      liveEntry = {
        id: liveId,
        title: new Date().toLocaleString(),
        segmentCount: liveSegments.length,
        sourceLang: null,
        targetLang: targetLangSelect.value || null,
        startTime: Date.now(),
      }
    sessions.unshift(liveEntry)
  }

  renderSessionList(sessions, liveId)
}

function renderSessionList(sessions, liveId) {
  if (sessions.length === 0) {
    sessionList.innerHTML = `<p class="empty-state">${t('transcript.empty')}</p>`
    return
  }

  sessionList.innerHTML = sessions
    .map((s) => {
      const isLive = s.id === liveId
      // Sub-line is the total pause-excluded recording time. Live sessions show
      // the LIVE badge instead, so a dash is enough until they're saved.
      const sub = typeof s.durationMs === 'number'
        ? formatRelTime(s.durationMs)
        : (isLive ? t('live') : '—')
      const check =
        selectMode && !isLive
          ? `<span class="select-check ${selectedIds.has(s.id) ? 'checked' : ''}"></span>`
          : ''
      const liveDot = isLive ? `<span class="session-live-dot"></span>` : ''
      const liveBadge = isLive ? `<span class="live-badge">${t('live')}</span>` : ''
      const chev = selectMode
        ? ''
        : `<svg class="chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`
      return `
      <div class="session-item ${isLive ? 'live-item' : ''}" data-id="${s.id}" data-live="${isLive ? '1' : '0'}">
        ${check}
        <div class="session-meta-wrap">
          <div class="meta">${liveDot}${escapeHtml(s.title)}${liveBadge}</div>
          <div class="sub">${sub}</div>
        </div>
        ${chev}
      </div>`
    })
    .join('')

  sessionList.querySelectorAll('.session-item').forEach((item) => {
    item.addEventListener('click', () => {
      const id = item.dataset.id
      const isLive = item.dataset.live === '1'
      if (selectMode) {
        if (isLive) return // can't delete the live session
        toggleSelect(id)
      } else {
        openSession(id)
      }
    })
  })
}

async function openSession(id) {
  if (!window.DB) return
  viewingSession = id
  searchQuery = ''
  matchIndex = 0
  if (transcriptSearch) transcriptSearch.value = ''
  resetChat()

  const isLive = id === sessionId && isCapturing
  let saved = []
  try { saved = await window.DB.getTranscript(id) } catch {}
  if (isLive) {
    // Always rebuild from the persisted chunks (covers the popup being closed
    // for part of the session), then keep any in-memory commits the DB read
    // missed so nothing disappears on revisit.
    const seen = new Set(saved.map((s) => s.timestamp))
    const extra = liveSegments.filter((s) => !seen.has(s.timestamp))
    liveSegments = saved.concat(extra)
    currentSegments = liveSegments
  } else {
    currentSegments = saved
  }

  sessionsView.style.display = 'none'
  sessionView.classList.add('active')
  await loadSessionTitle(id)
  renderTranscript(isLive)
  updateDownloadAvailability()
}

// ---------- Editable session title ----------
async function loadSessionTitle(id) {
  if (!sessionTitleInput) return
  let title = ''
  try {
    const all = await window.DB.getAllSessions()
    const s = all.find((x) => x.id === id)
    if (s) title = s.title
  } catch {}
  sessionTitleInput.value = title
}

async function saveSessionTitle() {
  if (!window.DB || !viewingSession || !sessionTitleInput) return
  const title = sessionTitleInput.value.trim()
  if (!title) {
    // Don't allow an empty name; restore whatever was saved.
    await loadSessionTitle(viewingSession)
    return
  }
  try { await window.DB.updateSession(viewingSession, { title }) } catch {}
}

if (sessionTitleInput) {
  sessionTitleInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') { e.preventDefault(); sessionTitleInput.blur() }
    if (e.key === 'Escape') { loadSessionTitle(viewingSession); sessionTitleInput.blur() }
  })
  sessionTitleInput.addEventListener('blur', saveSessionTitle)
}
if (sessionTitleEdit) {
  sessionTitleEdit.addEventListener('click', () => {
    if (!sessionTitleInput) return
    sessionTitleInput.focus()
    sessionTitleInput.select()
  })
}

// ---------- Multi-select + delete ----------
function enterSelectMode() {
  selectMode = true
  selectedIds.clear()
  if (selectBar) selectBar.style.display = 'flex'
  if (btnSelect) btnSelect.style.display = 'none'
  updateSelectBar()
  showSessionsList()
}

function exitSelectMode() {
  selectMode = false
  selectedIds.clear()
  if (selectBar) selectBar.style.display = 'none'
  if (btnSelect) btnSelect.style.display = ''
  showSessionsList()
}

function toggleSelect(id) {
  if (selectedIds.has(id)) selectedIds.delete(id)
  else selectedIds.add(id)
  updateSelectBar()
  showSessionsList()
}

function updateSelectBar() {
  if (selectCount) selectCount.textContent = String(selectedIds.size)
  if (btnDeleteSelected) btnDeleteSelected.disabled = selectedIds.size === 0
}

async function deleteSelected() {
  if (!window.DB || selectedIds.size === 0) return
  for (const id of selectedIds) {
    try { await window.DB.deleteSession(id) } catch {}
  }
  exitSelectMode()
}

if (btnSelect) btnSelect.addEventListener('click', enterSelectMode)
if (btnSelectCancel) btnSelectCancel.addEventListener('click', exitSelectMode)
if (btnDeleteSelected) btnDeleteSelected.addEventListener('click', deleteSelected)

// ---------- Search + view filter ----------
if (transcriptSearch) {
  transcriptSearch.addEventListener('input', () => {
    searchQuery = transcriptSearch.value
    matchIndex = 0
    renderTranscript(viewingSession === sessionId && isCapturing)
    applyCurrentMatch(true)
  })
  transcriptSearch.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      e.preventDefault()
      stepMatch(e.shiftKey ? -1 : 1)
    }
  })
}
if (searchPrev) searchPrev.addEventListener('click', () => stepMatch(-1))
if (searchNext) searchNext.addEventListener('click', () => stepMatch(1))

// ---------- Transcript scroll follow + floating button ----------
transcriptList.addEventListener('scroll', () => {
  // Returning to the bottom re-arms auto-follow (works for programmatic glide
  // and manual scroll alike). Scrolling away never gets disabled here — only by
  // explicit user input below — so a glide can't disable itself mid-animation.
  if (distFromBottom() < FOLLOW_THRESHOLD) autoFollow = true
  // Track where the user is resting so a live re-render can restore it — but not
  // while a button glide is animating (those intermediate positions aren't the
  // intended target and would derail the glide).
  if (Date.now() >= programmaticScrollUntil) {
    intendedTop = transcriptList.scrollTop
  }
  updateScrollBtn()
})
transcriptList.addEventListener('wheel', (e) => {
  if (e.deltaY < 0) autoFollow = false // scrolling up = stop following
}, { passive: true })
transcriptList.addEventListener('touchmove', () => {
  if (distFromBottom() >= FOLLOW_THRESHOLD) autoFollow = false
}, { passive: true })
if (transcriptScrollBtn) {
  transcriptScrollBtn.addEventListener('click', () => {
    if (transcriptScrollBtn.dataset.dir === 'up') {
      autoFollow = false
      intendedTop = 0
      // Guard the smooth scroll so a live re-render re-issues the glide toward
      // the top instead of snapping back to the old position.
      programmaticScrollUntil = Date.now() + 700
      transcriptList.scrollTo({ top: 0, behavior: 'smooth' })
    } else {
      autoFollow = true
      programmaticScrollUntil = Date.now() + 700
      transcriptList.scrollTo({ top: transcriptList.scrollHeight, behavior: 'smooth' })
    }
  })
}

// ---------- Chat with transcript ----------
const chatBox = el('chat-box')
const chatCollapse = el('chat-collapse')
// Collapsed = card temporarily tucked back into the input bar while keeping the
// conversation history intact (distinct from Clear, which wipes it).
let chatCollapsed = false

function resetChat() {
  chatHistory = []
  chatCollapsed = false
  if (chatMessages) chatMessages.innerHTML = ''
  if (chatBox) chatBox.classList.remove('active')
  if (chatClear) chatClear.classList.remove('show')
  if (chatCollapse) chatCollapse.classList.remove('show', 'up')
}

function renderChat(thinking) {
  if (!chatMessages) return
  let html = chatHistory
    .map((m) => `<div class="chat-msg ${m.role === 'user' ? 'user' : 'bot'}">${escapeHtml(m.content)}</div>`)
    .join('')
  if (thinking) html += `<div class="chat-msg bot thinking">${escapeHtml(t('chat.thinking'))}</div>`
  chatMessages.innerHTML = html
  const hasConvo = chatHistory.length > 0 || thinking
  // The whole chat section glides up as one card while expanded; collapsing
  // tucks it back to the input bar without losing the history.
  const expanded = hasConvo && !chatCollapsed
  if (chatBox) chatBox.classList.toggle('active', expanded)
  if (chatClear) chatClear.classList.toggle('show', hasConvo)
  if (chatCollapse) {
    chatCollapse.classList.toggle('show', hasConvo)
    chatCollapse.classList.toggle('up', chatCollapsed)
    chatCollapse.title = chatCollapsed ? t('chat.expand') : t('chat.collapse')
  }
  // Show the TOP of the latest answer (not the bottom) so it reads top-down.
  const lastBot = chatMessages.querySelector('.chat-msg.bot:last-child')
  if (lastBot) chatMessages.scrollTop = lastBot.offsetTop
  else chatMessages.scrollTop = 0
}

if (chatClear) {
  chatClear.addEventListener('click', () => {
    resetChat()
  })
}

if (chatCollapse) {
  chatCollapse.addEventListener('click', () => {
    chatCollapsed = !chatCollapsed
    renderChat(false)
  })
}

function transcriptForChat() {
  return (currentSegments || [])
    .map((s) => [s.text, s.translatedText].filter(Boolean).join(' / '))
    .filter(Boolean)
    .join('\n')
}

async function sendChat() {
  if (!chatInput) return
  const text = chatInput.value.trim()
  if (!text) return
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  const token = await authToken()
  if (!token) {
    chatHistory.push({ role: 'assistant', content: t('chat.signInNeeded') })
    renderChat(false)
    return
  }

  chatInput.value = ''
  chatHistory.push({ role: 'user', content: text })
  // A new question always re-expands the card so the answer is visible.
  chatCollapsed = false
  renderChat(true)
  chatSend.disabled = true

  try {
    const res = await fetch(`${BACKEND_URL}/api/chat`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
      },
      body: JSON.stringify({ messages: chatHistory, transcript: transcriptForChat() }),
    })
    const data = await res.json().catch(() => ({}))
    if (res.status === 429) {
      chatHistory.push({ role: 'assistant', content: t('chat.outOfCredits') })
    } else if (!res.ok) {
      chatHistory.push({ role: 'assistant', content: data.error || t('chat.error') })
    } else {
      chatHistory.push({ role: 'assistant', content: data.reply || '' })
    }
  } catch {
    chatHistory.push({ role: 'assistant', content: t('chat.error') })
  } finally {
    chatSend.disabled = false
    renderChat(false)
    updateCredits()
  }
}

if (chatSend) chatSend.addEventListener('click', sendChat)
if (chatInput) {
  chatInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      e.preventDefault()
      sendChat()
    }
  })
}
if (viewFilterSeg) {
  viewFilterSeg.querySelectorAll('button').forEach((b) => {
    b.addEventListener('click', () => {
      viewFilter = b.dataset.view
      viewFilterSeg.querySelectorAll('button').forEach((x) => x.classList.toggle('active', x === b))
      renderTranscript(viewingSession === sessionId && isCapturing)
    })
  })
}

// Copy transcript to clipboard respecting current view filter
const btnCopyTranscript = el('btn-copy-transcript')
if (btnCopyTranscript) {
  btnCopyTranscript.addEventListener('click', async () => {
    const lines = []
    for (const s of currentSegments) {
      if (viewFilter !== 'translated' && s.text) lines.push(s.text)
      if (viewFilter !== 'original' && s.translatedText) lines.push(s.translatedText)
    }
    const text = lines.join('\n')
    try {
      await navigator.clipboard.writeText(text)
      btnCopyTranscript.classList.add('copied')
      setTimeout(() => btnCopyTranscript.classList.remove('copied'), 1500)
    } catch {}
  })
}

el('session-back').addEventListener('click', showSessionsList)

el('btn-new-session').addEventListener('click', () => {
  document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'))
  document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'))
  document.querySelector('.tab[data-tab="controls"]').classList.add('active')
  el('panel-controls').classList.add('active')
})

// ---------- Export (overlay dropdown) ----------
const dlBtn = el('dl-btn')
const dlMenu = el('dl-menu')

function closeDlMenu() {
  if (dlMenu) dlMenu.classList.remove('open')
}

// Downloads are only meaningful once a session has ended: while live, segments
// are still being committed/encrypted and an export could capture scrambled or
// partial text. Disable the button (with an explanatory tooltip) until the
// viewed session is no longer the actively-capturing one.
function updateDownloadAvailability() {
  if (!dlBtn) return
  const live = !!viewingSession && viewingSession === sessionId && isCapturing
  dlBtn.disabled = live
  dlBtn.title = live ? t('export.afterEnd') : ''
  if (live) closeDlMenu()
}

if (dlBtn && dlMenu) {
  dlBtn.addEventListener('click', (e) => {
    e.stopPropagation()
    if (dlBtn.disabled) return
    dlMenu.classList.toggle('open')
  })
  dlMenu.querySelectorAll('.dl-item').forEach((btn) => {
    btn.addEventListener('click', () => {
      closeDlMenu()
      if (!viewingSession || !currentSegments.length) return
      const fmt = btn.dataset.export
      const title = `StellarCaptions_${viewingSession}`
      if (fmt === 'txt') window.Exporter.toTxt(currentSegments, title)
      else if (fmt === 'doc') window.Exporter.toDoc(currentSegments, title)
      else if (fmt === 'pdf') window.Exporter.toPdf(currentSegments, title)
    })
  })
  // Close on any outside click.
  document.addEventListener('click', (e) => {
    if (dlMenu.classList.contains('open') && !e.target.closest('.dl-wrap')) closeDlMenu()
  })
}

function escapeHtml(s) {
  return (s || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}

// ---------- Credits ----------
// Write both the Controls-tab and Account-tab credit displays from one source.
function setCreditsDisplay(text, pct) {
  if (creditsVal) creditsVal.textContent = text
  if (creditsBar) creditsBar.style.width = `${pct}%`
  if (creditsValAcct) creditsValAcct.textContent = text
  if (creditsBarAcct) creditsBarAcct.style.width = `${pct}%`
}

async function updateCredits() {
  const { localCreditsUsed = 0 } = await chrome.storage.local.get(['localCreditsUsed'])
  const token = await authToken()
  // A signed-in user whose token can no longer be refreshed must re-login,
  // rather than silently showing the free fallback.
  if (isSignedIn && !token) {
    showLoggedOut()
    return
  }

  if (token) {
    // Signed in: consult the server before showing anything. We never guess a
    // free total here, so the display stays on its "…" placeholder until the
    // real plan amount arrives (no false "70 / 70" flash on first open).
    try {
      const res = await fetch(`${BACKEND_URL}/api/usage`, {
        headers: { Authorization: `Bearer ${token}` },
      })
      if (res.ok) {
        const data = await res.json()
        const total = data.credits_total
        const remaining = typeof data.credits_remaining === 'number'
          ? data.credits_remaining
          : Math.max(0, total - data.credits_used)
        const planEl = el('account-plan')
        if (planEl && data.tier) {
          const tier = data.tier.charAt(0).toUpperCase() + data.tier.slice(1)
          planEl.textContent = `${tier} · ${Math.round(remaining)} credits left`
        }
        const pct = total > 0 ? Math.min(100, (remaining / total) * 100) : 0
        setCreditsDisplay(`${remaining.toFixed(0)} / ${total}`, pct)
      }
    } catch {}
    // On failure, keep the existing display (placeholder or last known value).
    return
  }

  // Not signed in: local free-credit fallback only.
  const total = FREE_CREDITS_TOTAL
  const remaining = Math.max(0, total - localCreditsUsed)
  const pct = total > 0 ? Math.min(100, (remaining / total) * 100) : 0
  setCreditsDisplay(`${remaining.toFixed(0)} / ${total}`, pct)
}

// Reconcile entitlement straight from Stripe so a fresh upgrade or credit-pack
// purchase shows up immediately, even before the webhook fires.
async function syncBilling() {
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  const token = await authToken()
  if (!token) return
  try {
    await fetch(`${BACKEND_URL}/api/stripe/sync`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, ...(deviceId ? { 'X-Device-Id': deviceId } : {}) },
    })
  } catch {}
  updateCredits()
}

updateCredits()
setInterval(updateCredits, 10000)
// Re-check Stripe whenever the popup regains focus (e.g. after returning from a
// checkout tab in the browser).
window.addEventListener('focus', syncBilling)

// ---------- Account / Google sign-in ----------
el('btn-google-signin').addEventListener('click', async () => {
  const redirectUrl = chrome.identity.getRedirectURL()
  const authUrl = `${SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to=${encodeURIComponent(redirectUrl)}`

  chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, async (callbackUrl) => {
    if (chrome.runtime.lastError || !callbackUrl) {
      status.textContent = 'Sign-in cancelled'
      return
    }
    const hash = new URL(callbackUrl).hash.substring(1)
    const params = new URLSearchParams(hash)
    const accessToken = params.get('access_token')
    const refreshToken = params.get('refresh_token')

    if (accessToken) {
      await chrome.storage.local.set({
        token: accessToken,
        refreshToken,
        backendUrl: BACKEND_URL,
        supabaseUrl: SUPABASE_URL,
      })
      showLoggedIn(accessToken)
      await registerDevice(accessToken)
      updateCredits()
    }
  })
})

// ---------- Device session (single active login) ----------
// NOTE: deviceId is a client-generated id in chrome.storage.local. It is a
// useful signal for the single-active-device UX, but NOT proof of identity
// (an attacker can reset/spoof it). Server-side decisions also record IP,
// user-agent, and timezone on device_sessions; treat deviceId as one signal,
// not an authorization boundary.
async function ensureDeviceId() {
  let { deviceId } = await chrome.storage.local.get(['deviceId'])
  if (!deviceId) {
    deviceId = (crypto.randomUUID && crypto.randomUUID()) || `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`
    await chrome.storage.local.set({ deviceId })
  }
  return deviceId
}

async function registerDevice(token, force = false) {
  const deviceId = await ensureDeviceId()
  const timezone = (Intl.DateTimeFormat().resolvedOptions().timeZone) || ''
  // Use a freshly-refreshed token (the one passed in may be stale on load).
  const freshToken = (await authToken()) || token
  try {
    const res = await fetch(`${BACKEND_URL}/api/session/register`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${freshToken}`, 'X-Device-Id': deviceId },
      body: JSON.stringify({ deviceId, timezone, force }),
    })
    if (res.status === 410) {
      // Account was finalized (deletion window elapsed).
      await chrome.storage.local.remove(['token', 'refreshToken'])
      showLoggedOut()
      alert(t('account.deletedFinal'))
      return false
    }
    if (res.status === 409) {
      if (confirm(t('device.conflict'))) return registerDevice(token, true)
      await chrome.storage.local.remove(['token', 'refreshToken'])
      showLoggedOut()
      return false
    }
    return res.ok
  } catch {
    return false
  }
}

async function deactivateDevice() {
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  const token = await authToken()
  if (!token || !deviceId) return
  try {
    await fetch(`${BACKEND_URL}/api/session/logout`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'X-Device-Id': deviceId },
      body: JSON.stringify({ deviceId }),
    })
  } catch {}
}

// ---------- Email / password sign-in ----------
const authErrorEl = el('auth-error')

el('email-login-form').addEventListener('submit', async (e) => {
  e.preventDefault()
  const email = el('login-email').value.trim()
  const password = el('login-password').value
  if (!email || !password) return

  const btn = el('btn-email-signin')
  btn.disabled = true
  if (authErrorEl) authErrorEl.style.display = 'none'

  try {
    const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', apikey: SUPABASE_PUBLISHABLE_KEY },
      body: JSON.stringify({ email, password }),
    })
    const data = await res.json().catch(() => null)
    if (!res.ok || !data?.access_token) {
      throw new Error(data?.error_description || data?.msg || t('auth.invalid'))
    }
    await chrome.storage.local.set({
      token: data.access_token,
      refreshToken: data.refresh_token,
      backendUrl: BACKEND_URL,
      supabaseUrl: SUPABASE_URL,
    })
    el('login-password').value = ''
    showLoggedIn(data.access_token)
    await registerDevice(data.access_token)
    updateCredits()
  } catch (err) {
    if (authErrorEl) {
      authErrorEl.textContent = err?.message || t('auth.invalid')
      authErrorEl.style.display = 'block'
    }
  } finally {
    btn.disabled = false
  }
})

el('btn-signout').addEventListener('click', async () => {
  await deactivateDevice()
  await chrome.storage.local.remove(['token', 'refreshToken'])
  showLoggedOut()
})

// Billing buttons open the website (the extension can't host Stripe checkout).
// We mint a single-use, 60s handoff code server-side and pass only that code in
// the URL; the website exchanges it for a cookie session server-side, so no
// tokens ever travel through the page.
async function openWebsite(path) {
  const base = BACKEND_URL
  const token = await authToken()
  const { refreshToken, deviceId } = await chrome.storage.local.get(['refreshToken', 'deviceId'])
  if (token && refreshToken) {
    try {
      const res = await fetch(`${base}/api/auth/handoff`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
          ...(deviceId ? { 'X-Device-Id': deviceId } : {}),
        },
        body: JSON.stringify({ refresh_token: refreshToken }),
      })
      const data = await res.json().catch(() => null)
      if (res.ok && data?.code) {
        const url = `${base}/api/auth/handoff/exchange?code=${encodeURIComponent(data.code)}&next=${encodeURIComponent(path)}`
        chrome.tabs.create({ url })
        return
      }
    } catch {}
  }
  chrome.tabs.create({ url: `${base}${path}` })
}
el('btn-upgrade').addEventListener('click', () => openWebsite('/pricing'))
el('btn-buy-credits').addEventListener('click', () => openWebsite('/pricing'))
el('btn-manage-billing').addEventListener('click', () => openWebsite('/account'))
el('btn-upgrade-credits').addEventListener('click', () => openWebsite('/pricing'))

el('btn-delete-account').addEventListener('click', async () => {
  if (!confirm(t('account.deleteConfirm'))) return
  const { deviceId } = await chrome.storage.local.get(['deviceId'])
  const token = await authToken()
  if (token) {
    try {
      await fetch(`${BACKEND_URL}/api/account/delete`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, ...(deviceId ? { 'X-Device-Id': deviceId } : {}) },
      })
    } catch {}
  }
  await chrome.storage.local.remove(['token', 'refreshToken'])
  showLoggedOut()
  alert(t('account.deleteScheduled'))
})

// Show the full app, hide the gate.
async function showLoggedIn(token) {
  try {
    const payload = JSON.parse(atob(token.split('.')[1]))
    el('account-email').textContent = payload.email || 'Signed in'
  } catch {
    el('account-email').textContent = 'Signed in'
  }
  isSignedIn = true
  el('auth-gate').classList.remove('active')
  el('app-content').style.display = 'block'
  syncBilling()
}

// Hide the entire app behind the gate.
function showLoggedOut() {
  isSignedIn = false
  el('app-content').style.display = 'none'
  el('auth-gate').classList.add('active')
  showGateChoice()
}

// Gate stays the wall; promptSignIn just reveals it (no-op while gated).
function promptSignIn() {
  showLoggedOut()
}

// ---------- Gate: choice vs email views ----------
function showGateChoice() {
  el('gate-choice').style.display = 'block'
  el('gate-email').style.display = 'none'
  if (authErrorEl) authErrorEl.style.display = 'none'
}

function showGateEmail() {
  el('gate-choice').style.display = 'none'
  el('gate-email').style.display = 'block'
  if (authErrorEl) authErrorEl.style.display = 'none'
  el('login-email').focus()
}

el('btn-use-email').addEventListener('click', showGateEmail)
el('btn-email-back').addEventListener('click', showGateChoice)

ensureDeviceId()
chrome.storage.local.get(['token'], async ({ token }) => {
  if (token) {
    showLoggedIn(token)
    await registerDevice(token)
  } else {
    showLoggedOut()
  }
})

// ---------- Persist controls state ----------
function saveControlsState() {
  chrome.storage.local.set({
    [CONTROLS_STATE_KEY]: {
      selectedSource,
      targetLang: targetLangSelect.value || '',
      dubbing: toggleDubbing.classList.contains('on'),
      originalMode: currentOriginalMode(),
    },
  })
}

async function restoreControlsState() {
  const result = await chrome.storage.local.get(CONTROLS_STATE_KEY)
  const state = result[CONTROLS_STATE_KEY]
  if (!state) return

  if (state.selectedSource) {
    selectedSource = state.selectedSource
    document.querySelectorAll('.source-btn').forEach((b) => {
      b.classList.toggle('active', b.dataset.source === selectedSource)
    })
  }
  if (state.targetLang) {
    targetLangSelect.value = state.targetLang
  }
  if (state.dubbing) {
    toggleDubbing.classList.add('on')
  } else {
    toggleDubbing.classList.remove('on')
  }
  // Restore original-audio mode (back-compat: older state stored a `mute` flag).
  const mode = state.originalMode || (state.mute ? 'mute' : 'normal')
  toggleLower.classList.toggle('on', mode === 'duck')
  toggleMute.classList.toggle('on', mode === 'mute')
  updateDubbingAvailability()
  updateMuteVisibility()
}

// ---------- Sync state with background ----------
chrome.runtime.sendMessage({ action: 'getState' }, async (response) => {
  await restoreControlsState()

  if (response?.active) {
    isCapturing = true
    isPaused = false
    // Infer suppression from the restored controls (matches how it was started).
    captureSuppressed = selectedSource === 'tab' ||
      (selectedSource === 'display' && (toggleDubbing.classList.contains('on') || currentOriginalMode() !== 'normal'))
    showCapturingUI()
    status.textContent = ''
    setMeterActive(true)
    updateRecDot()
    const stored = await chrome.storage.local.get(ACTIVE_SESSION_KEY)
    if (stored[ACTIVE_SESSION_KEY]) sessionId = stored[ACTIVE_SESSION_KEY]
    const transcriptTabActive = document.querySelector('.tab[data-tab="transcript"]')?.classList.contains('active')
    if (transcriptTabActive) showSessionsList()
  }
})

updateDubbingAvailability()
loadCaptionSettings()
