// 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 fixed-viewport "band" (max ~3 lines, overflow hidden) with
  // an inner rolling element anchored to the bottom. New text appears at the
  // bottom and older lines scroll up and off the top, like YouTube captions.
  function makeLine(id, extraClass) {
    const view = document.createElement('div')
    view.id = id
    view.className = 'sc-line' + (extraClass ? ' ' + extraClass : '')
    view.style.display = 'none'
    const roll = document.createElement('div')
    roll.className = 'sc-roll'
    // Let the browser pick text direction per content so RTL languages
    // (Arabic) render correctly.
    roll.dir = 'auto'
    view.appendChild(roll)
    return { view, roll }
  }

  const transcriptLine = makeLine('sc-transcript')
  const translationLine = makeLine('sc-translation', 'sc-translation')

  overlay.appendChild(pausedBadge)
  overlay.appendChild(transcriptLine.view)
  overlay.appendChild(translationLine.view)
  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.
  // `el` is the viewport band; `roll` is the bottom-anchored animated inner box.
  const channels = {
    transcript: { committed: '', partial: '', el: transcriptLine.view, roll: transcriptLine.roll, lastH: 0 },
    translation: { committed: '', partial: '', el: translationLine.view, roll: translationLine.roll, lastH: 0 },
  }
  // Keep the rolling buffer bounded so the inner element can't grow forever.
  const MAX_BUFFER_WORDS = 60
  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')
  }

  const lastFinal = { transcript: '', translation: '' }

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

    if (isFinal) {
      const trimmed = text.trim()
      // Suppress duplicate final segments (feedback loop protection)
      if (trimmed && trimmed === lastFinal[channelName]) return
      lastFinal[channelName] = trimmed
      ch.committed = joinText(ch.committed, trimmed)
      ch.partial = ''
      // Bound the buffer by dropping the oldest words (already scrolled off the
      // top of the band), so memory/layout stays flat over a long session.
      const words = ch.committed.split(' ')
      if (words.length > MAX_BUFFER_WORDS) {
        ch.committed = words.slice(words.length - MAX_BUFFER_WORDS).join(' ')
      }
    } else {
      ch.partial += text
    }

    // While the "connecting" placeholder is up, only the channel that will be
    // shown (the active display channel) may replace it. In translate mode the
    // English original arrives first but must stay hidden, so we store it above
    // and keep the dots until the translation lands — no English flash.
    if (loadingActive && channelName !== displayChannel) return
    if (loadingActive) clearLoading()

    // 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
    // Show only the active display channel. In translate mode that's the
    // translation, so the English original is never shown as a fallback while
    // the (chunked) translation is still pending — matching realtime languages.
    // The per-message `mode` keeps displayChannel correct, so no cross-channel
    // fallback is needed.
    const show = active && !!full
    ch.el.style.display = show ? '' : 'none'
    if (!show) {
      ch.roll.textContent = full
      ch.lastH = ch.roll.scrollHeight
      return
    }
    rollTo(ch, full)
  }

  // Update a channel's text and, when the rendered height grows (a new line),
  // smoothly slide the content upward so older lines roll off the top — the way
  // YouTube captions move, rather than vanishing abruptly.
  function rollTo(ch, full) {
    const prevH = ch.lastH || ch.roll.scrollHeight
    ch.roll.textContent = full
    const newH = ch.roll.scrollHeight
    const delta = newH - prevH
    if (delta > 1) {
      // Start shifted down by the growth amount (its pre-growth visual position),
      // then animate back to rest so the new bottom line rises into view.
      ch.roll.style.transition = 'none'
      ch.roll.style.transform = `translateY(${delta}px)`
      // Force a reflow so the starting transform is committed before animating.
      void ch.roll.offsetHeight
      ch.roll.style.transition = 'transform 300ms cubic-bezier(0.22, 0.61, 0.36, 1)'
      ch.roll.style.transform = 'translateY(0)'
    }
    ch.lastH = newH
  }

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

  function resetChannels() {
    loadingActive = false
    for (const key of Object.keys(channels)) {
      const ch = channels[key]
      ch.committed = ''
      ch.partial = ''
      ch.lastH = 0
      ch.roll.style.transition = 'none'
      ch.roll.style.transform = 'translateY(0)'
      ch.roll.textContent = ''
      ch.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.lastH = 0
    ch.roll.style.transition = 'none'
    ch.roll.style.transform = 'translateY(0)'
    ch.roll.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.roll.querySelector('.sc-dots')) {
        ch.roll.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 {}
})()
