// StellarCaptions — Content script overlay (YouTube-style rolling captions, draggable)

(() => {
  // Re-injection after extension reload: the old script context is orphaned so
  // its listeners are dead. Remove the stale overlay and rebuild from scratch.
  const existing = document.getElementById('sc-overlay')
  if (existing) existing.remove()

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

  // Idle time after which the caption band clears (like YouTube).
  const IDLE_CLEAR_MS = 4000

  const overlay = document.createElement('div')
  overlay.id = 'sc-overlay'

  const pausedBadge = document.createElement('div')
  pausedBadge.id = 'sc-paused'
  pausedBadge.className = 'sc-paused'
  pausedBadge.textContent = 'PAUSED'
  pausedBadge.style.display = 'none'

  // Each channel is a single rolling line box (clamped to 2 lines via CSS).
  const transcriptLine = document.createElement('div')
  transcriptLine.id = 'sc-transcript'
  transcriptLine.className = 'sc-line'
  transcriptLine.style.display = 'none'

  const translationLine = document.createElement('div')
  translationLine.id = 'sc-translation'
  translationLine.className = 'sc-line sc-translation'
  translationLine.style.display = 'none'

  overlay.appendChild(pausedBadge)
  overlay.appendChild(transcriptLine)
  overlay.appendChild(translationLine)
  document.body.appendChild(overlay)

  let captionSettings = { ...DEFAULT_SETTINGS }
  let overlayPosition = null
  // Which channel to display on-screen: in translate mode show only the
  // translated line; in transcribe mode show only the source transcript.
  let displayChannel = 'transcript'

  // Rolling word stream per channel: committed words + current interim partial.
  const channels = {
    transcript: { committed: '', partial: '', el: transcriptLine },
    translation: { committed: '', partial: '', el: translationLine },
  }
  let idleTimer = null
  // True while the pulsing "connecting" placeholder is shown (before any real
  // caption text arrives), so the first real delta can clear it.
  let loadingActive = false

  chrome.storage.local.get(['captionSettings', 'captionPosition'], (result) => {
    captionSettings = { ...DEFAULT_SETTINGS, ...(result.captionSettings || {}) }
    overlayPosition = result.captionPosition || null
    applyCaptionStyles()
    applyOverlayPosition()
  })

  chrome.runtime.onMessage.addListener((msg) => {
    if (msg.action === 'setCaptionMode') {
      displayChannel = msg.mode === 'translate' ? 'translation' : 'transcript'
      resetChannels()
    } else if (msg.action === 'captionsStarting') {
      // Show the band with a pulsing placeholder immediately on Start so the
      // user sees it's working during the WebRTC connect delay (before the
      // first transcript delta arrives).
      showLoadingDots()
    } else if (msg.action === 'transcriptUpdate') {
      // Self-correct the channel from the mode carried on the message so a
      // missed/late setCaptionMode never silently drops captions. Both channels
      // are ingested; only the active one is shown (see renderChannel).
      if (msg.mode) setDisplayChannel(msg.mode)
      ingest('transcript', msg.text || '', msg.isFinal)
    } else if (msg.action === 'translationUpdate') {
      if (msg.mode) setDisplayChannel(msg.mode)
      ingest('translation', msg.text || '', msg.isFinal)
    } else if (msg.action === 'hideOverlay') {
      overlay.style.display = 'none'
      resetChannels()
    } else if (msg.action === 'showPaused') {
      pausedBadge.style.display = 'block'
    } else if (msg.action === 'hidePaused') {
      pausedBadge.style.display = 'none'
    } else if (msg.action === 'updateCaptionSettings' && msg.settings) {
      captionSettings = { ...captionSettings, ...msg.settings }
      applyCaptionStyles()
    }
  })

  // Switch which channel is shown on-screen without discarding either channel's
  // text — lets a late mode signal correct the display mid-stream.
  function setDisplayChannel(mode) {
    const next = mode === 'translate' ? 'translation' : 'transcript'
    if (next === displayChannel) return
    displayChannel = next
    renderChannel('transcript')
    renderChannel('translation')
  }

  function ingest(channelName, text, isFinal) {
    const ch = channels[channelName]
    if (!text) return

    // First real text replaces the pulsing "connecting" placeholder.
    if (loadingActive) clearLoading()

    if (isFinal) {
      ch.committed = joinText(ch.committed, text.trim())
      ch.partial = ''
    } else {
      ch.partial += text
    }
    // Re-render both channels so fallback logic stays consistent.
    renderChannel('transcript')
    renderChannel('translation')
    // Show the overlay whenever any channel has visible text.
    if (channels.transcript.el.style.display !== 'none' || channels.translation.el.style.display !== 'none') {
      showOverlay()
      scheduleIdleClear()
    }
  }

  function joinText(a, b) {
    if (!a) return b
    if (!b) return a
    return `${a} ${b}`
  }

  function renderChannel(name) {
    const ch = channels[name]
    const active = name === displayChannel
    const full = ch.partial ? joinText(ch.committed, ch.partial.trim()) : ch.committed
    ch.el.textContent = full
    // Show the active channel when it has text. As a fallback, if the active
    // channel is empty but the other has text, show the other (covers mode
    // mismatch races where only one stream delivers).
    const displayCh = channels[displayChannel]
    const displayHasText = !!(displayCh.committed || displayCh.partial)
    const show = active ? !!full : (!displayHasText && !!full)
    ch.el.style.display = show ? '' : 'none'
    if (show) trimToTwoLines(ch)
  }

  // Drop oldest words from the committed buffer until the rendered text fits
  // within two lines, so old words roll off the top like YouTube captions.
  function trimToTwoLines(ch) {
    let guard = 0
    while (isOverflowing(ch.el) && guard < 200) {
      guard++
      // Prefer trimming committed words; once exhausted, trim the partial.
      if (ch.committed.includes(' ')) {
        ch.committed = ch.committed.slice(ch.committed.indexOf(' ') + 1)
      } else if (ch.committed) {
        ch.committed = ''
      } else if (ch.partial.includes(' ')) {
        ch.partial = ch.partial.slice(ch.partial.indexOf(' ') + 1)
      } else {
        break
      }
      const full = ch.partial ? joinText(ch.committed, ch.partial.trim()) : ch.committed
      ch.el.textContent = full
    }
  }

  function isOverflowing(node) {
    // The line box is clamped to 2 lines via CSS; overflow means >2 lines.
    return node.scrollHeight - node.clientHeight > 2
  }

  function scheduleIdleClear() {
    if (idleTimer) clearTimeout(idleTimer)
    idleTimer = setTimeout(() => {
      resetChannels()
    }, IDLE_CLEAR_MS)
  }

  function resetChannels() {
    loadingActive = false
    for (const key of Object.keys(channels)) {
      channels[key].committed = ''
      channels[key].partial = ''
      channels[key].el.textContent = ''
      channels[key].el.style.display = 'none'
    }
  }

  // Pulsing three-dot placeholder shown in the active channel from Start until
  // the first real caption arrives, so the band is visible during connect.
  function showLoadingDots() {
    const ch = channels[displayChannel]
    ch.committed = ''
    ch.partial = ''
    ch.el.innerHTML = '<span class="sc-dots"><span></span><span></span><span></span></span>'
    ch.el.style.display = ''
    loadingActive = true
    showOverlay()
  }

  function clearLoading() {
    loadingActive = false
    // The dots live as innerHTML; renderChannel's textContent assignment that
    // follows will overwrite them, but clear here too in case no text renders.
    // Clear on every channel in case the mode corrected after the dots showed.
    for (const key of Object.keys(channels)) {
      const ch = channels[key]
      if (ch.el.querySelector('.sc-dots')) {
        ch.el.textContent = ''
        ch.el.style.display = 'none'
      }
    }
  }

  function showOverlay() {
    overlay.style.display = 'flex'
  }

  function applyCaptionStyles() {
    overlay.style.setProperty('--sc-bg-opacity', String(captionSettings.opacity))
    overlay.style.setProperty('--sc-font-size', `${captionSettings.fontSize}px`)
    overlay.style.setProperty('--sc-font-color', captionSettings.color)
    overlay.style.setProperty('--sc-font-family', captionSettings.fontFamily)
  }

  function applyOverlayPosition() {
    if (!overlayPosition || typeof overlayPosition.x !== 'number' || typeof overlayPosition.y !== 'number') {
      overlay.style.left = '50%'
      overlay.style.top = 'auto'
      overlay.style.bottom = '60px'
      overlay.style.transform = 'translateX(-50%)'
      return
    }
    overlay.style.left = `${overlayPosition.x}px`
    overlay.style.top = `${overlayPosition.y}px`
    overlay.style.bottom = 'auto'
    overlay.style.transform = 'none'
  }

  // ---------- Drag the caption box itself ----------
  let dragging = false
  let armed = false
  let startX = 0
  let startY = 0
  let dragOffsetX = 0
  let dragOffsetY = 0
  const DRAG_THRESHOLD = 4 // px before a click becomes a drag

  overlay.addEventListener('mousedown', startDrag)
  overlay.addEventListener('touchstart', startDrag, { passive: false })
  window.addEventListener('mousemove', moveDrag)
  window.addEventListener('touchmove', moveDrag, { passive: false })
  window.addEventListener('mouseup', endDrag)
  window.addEventListener('touchend', endDrag)

  function getPoint(evt) {
    if (evt.touches && evt.touches[0]) return evt.touches[0]
    return evt
  }

  function startDrag(evt) {
    const point = getPoint(evt)
    armed = true
    dragging = false
    startX = point.clientX
    startY = point.clientY
    const rect = overlay.getBoundingClientRect()
    dragOffsetX = point.clientX - rect.left
    dragOffsetY = point.clientY - rect.top
  }

  function moveDrag(evt) {
    if (!armed) return
    const point = getPoint(evt)

    if (!dragging) {
      const dx = Math.abs(point.clientX - startX)
      const dy = Math.abs(point.clientY - startY)
      if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return
      // Promote to an actual drag and pin the overlay to absolute coords.
      dragging = true
      overlay.classList.add('sc-dragging')
      const rect = overlay.getBoundingClientRect()
      overlay.style.left = `${rect.left}px`
      overlay.style.top = `${rect.top}px`
      overlay.style.bottom = 'auto'
      overlay.style.transform = 'none'
    }

    evt.preventDefault()
    const maxX = window.innerWidth - overlay.offsetWidth
    const maxY = window.innerHeight - overlay.offsetHeight
    const x = Math.max(0, Math.min(maxX, point.clientX - dragOffsetX))
    const y = Math.max(0, Math.min(maxY, point.clientY - dragOffsetY))
    overlay.style.left = `${x}px`
    overlay.style.top = `${y}px`
  }

  function endDrag() {
    armed = false
    if (!dragging) return
    dragging = false
    overlay.classList.remove('sc-dragging')
    const rect = overlay.getBoundingClientRect()
    overlayPosition = { x: rect.left, y: rect.top }
    chrome.storage.local.set({ captionPosition: overlayPosition })
  }

  // ---------- Video clock reporting (for dub drift correction) ----------
  // Report the page video's playback clock so the offscreen dub can keep pace
  // and pause/resume with the video. This is the only page-reading we do.
  function pickVideo() {
    const vids = Array.from(document.querySelectorAll('video'))
    if (!vids.length) return null
    // Prefer a playing video, else the largest one on the page.
    const playing = vids.filter((v) => !v.paused && !v.ended && v.readyState > 2)
    const pool = playing.length ? playing : vids
    return pool.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight)[0] || null
  }

  let clockTimer = null

  function startClockReporting() {
    stopClockReporting()
    clockTimer = setInterval(() => {
      const v = pickVideo()
      if (v && !Number.isNaN(v.currentTime)) {
        chrome.runtime.sendMessage({ action: 'videoTime', t: v.currentTime, paused: v.paused }).catch(() => {})
      }
    }, 500)
  }

  function stopClockReporting() {
    if (clockTimer) { clearInterval(clockTimer); clockTimer = null }
  }

  // ---------- Capture-mode control from the background ----------
  chrome.runtime.onMessage.addListener((msg) => {
    if (msg.action === 'setCaptureMode') {
      if (msg.mode === 'audio') startClockReporting()
      else stopClockReporting()
    }
  })

  // Announce we're live so the background re-pushes caption mode/settings and
  // (if a capture is active) the loading placeholder — closes any race where
  // the first messages were sent before this listener existed.
  try { chrome.runtime.sendMessage({ action: 'contentReady' }) } catch {}
})()
