// dashboard/js/live-stream.js — Live streaming and polling extracted from dashboard.html

let livePollingInterval = null;
let liveEventSource = null;
let liveTerminalSource = null;
let _steerInFlight = false;
let _lastRenderedText = '';
let _runtimeTimer = null;

// W-mq1jbd8z0008dfa0 — scrollback state. The tail-region wrapper keeps
// prepended scrollback chunks safe when the 3s poll re-renders the tail.
//
// _oldestLoadedByte   — byte offset of the OLDEST bytes currently rendered in
//                       #live-messages (anything older lives on disk).
// _logFileSize        — most recent X-Log-Size value from the server. Used to
//                       seed _oldestLoadedByte on the first tail poll.
// _scrollbackFetchInFlight — guard so the scroll handler can't fire concurrent
//                       pagination requests.
// _tailRegionAgentId  — agentId the current tail region belongs to. Switching
//                       agents resets all scrollback state so chunks from a
//                       previous agent never bleed into the new tail.
let _oldestLoadedByte = 0;
let _logFileSize = 0;
let _scrollbackFetchInFlight = false;
let _tailRegionAgentId = null;

const SCROLLBACK_CHUNK = 65536; // 64KB per scroll-up
const SCROLLBACK_TRIGGER_PX = 200; // load older content when within 200px of top
const TAIL_REGION_ID = 'live-tail-region';

function _currentAgentRuntime() {
  var agent = (agentData || []).find(function(a) { return a.id === currentAgentId; });
  return agent && agent.runtime ? agent.runtime : '';
}

function _updateRuntimeCounter() {
  var el = document.getElementById('live-runtime');
  if (!el) return;
  var started = el.dataset.started;
  if (!started) return;
  var ms = Date.now() - new Date(started).getTime();
  if (ms < 0) ms = 0;
  var sec = Math.floor(ms / 1000) % 60;
  var min = Math.floor(ms / 60000) % 60;
  var hr = Math.floor(ms / 3600000);
  el.textContent = (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
}

// Resolve (or lazily create) the inner tail-region wrapper. All tail-poll
// renders write into this region; scrollback chunks live OUTSIDE it (above),
// so a tail re-render never clobbers the prepended scrollback.
function _ensureTailRegion() {
  const container = document.getElementById('live-messages');
  if (!container) return null;
  let region = document.getElementById(TAIL_REGION_ID);
  if (!region) {
    region = document.createElement('div');
    region.id = TAIL_REGION_ID;
    region.style.display = 'contents'; // transparent flex container
    container.appendChild(region);
  }
  return region;
}

function renderLiveChatMessage(raw) {
  const container = document.getElementById('live-messages');
  if (!container) return;
  const region = _ensureTailRegion();
  if (!region) return;
  const html = renderAgentOutput(raw);
  // eslint-disable-next-line no-unsanitized/method -- reason: renderAgentOutput() escapes all user-controlled fields before assembling HTML (see dashboard/js/render-utils.js)
  if (html) region.insertAdjacentHTML('beforeend', html);

  // Auto-scroll
  if (container.scrollHeight - container.scrollTop - container.clientHeight < 150) {
    container.scrollTop = container.scrollHeight;
  }
}

function startLiveStream(agentId) {
  stopLiveStream();
  if (!agentId) return;

  const msgEl = document.getElementById('live-messages');
  if (msgEl) msgEl.innerHTML = '';
  _lastRenderedText = '';
  _oldestLoadedByte = 0;
  _logFileSize = 0;
  _scrollbackFetchInFlight = false;
  _tailRegionAgentId = agentId;
  _attachScrollHandler();
  _updateJumpToLatestButton();

  // W-mpob4nyk0006580e — clear any stale banner from a previous agent.
  const bannerEl = document.getElementById('live-terminal-banner');
  if (bannerEl) bannerEl.innerHTML = '';

  // Start runtime counter
  _updateRuntimeCounter();
  _runtimeTimer = setInterval(_updateRuntimeCounter, 1000);

  // Use polling for log content (avoids HTTP/1.1 connection exhaustion that
  // a full SSE log stream would cause — see W-mpob4nyk0006580e note below).
  startLivePolling();

  // W-mpob4nyk0006580e — terminal banner is driven by the engine's
  // `dispatch.terminal` SSE event, NOT by parsing the JSONL log tail.
  // The /live-stream endpoint multiplexes log frames (default `data:`
  // messages) and terminal events (named `event: dispatch.terminal`); we
  // open the SSE connection but only consume the terminal event. The
  // stream stays mostly idle (one event per dispatch lifecycle), so this
  // does not regress the connection-exhaustion fix that switched log
  // tailing to polling.
  startTerminalEventSource(agentId);
}

function startTerminalEventSource(agentId) {
  if (typeof window.EventSource !== 'function') return;
  try {
    liveTerminalSource = new EventSource('/api/agent/' + encodeURIComponent(agentId) + '/live-stream?tail=0');
  } catch (e) { console.error('terminal-event source open:', e.message); return; }
  liveTerminalSource.addEventListener('dispatch.terminal', function(ev) {
    let payload;
    try { payload = JSON.parse(ev.data); } catch { return; }
    renderTerminalBannerForAgent(agentId, payload);
  });
  // W-mq1m1wo5000nd78d — when the same agent starts a new dispatch, clear
  // the prior dispatch's terminal banner. Without this, the stale banner
  // from the previous dispatch lingers across same-agent re-dispatch
  // because startLiveStream() only clears on tab switch / agent change.
  liveTerminalSource.addEventListener('dispatch.started', function(ev) {
    let payload;
    try { payload = JSON.parse(ev.data); } catch { return; }
    // Mirror the same guards used by renderTerminalBannerForAgent —
    // a late-arriving started event for a stale agent / non-live tab
    // must not wipe the current banner.
    if (typeof currentAgentId !== 'undefined' && currentAgentId !== agentId) return;
    if (typeof currentTab !== 'undefined' && currentTab !== 'live') return;
    // Server-side guard already filters by agentId, but defend in depth
    // for replay paths or future per-stream routing changes.
    if (payload && payload.agentId && payload.agentId !== agentId) return;
    const bannerEl = document.getElementById('live-terminal-banner');
    if (bannerEl) bannerEl.innerHTML = '';
  });
  // Ignore default `data:` log frames — log content arrives via polling.
  liveTerminalSource.onerror = function() {
    // EventSource auto-reconnects; nothing to do.
  };
}

function renderTerminalBannerForAgent(agentId, payload) {
  // Guard against late-arriving events for a previous agent (tab switch race).
  if (typeof currentAgentId !== 'undefined' && currentAgentId !== agentId) return;
  if (typeof currentTab !== 'undefined' && currentTab !== 'live') return;
  const bannerEl = document.getElementById('live-terminal-banner');
  if (!bannerEl) return;
  const html = renderTerminalBanner(payload);
  // eslint-disable-next-line no-unsanitized/property -- reason: renderTerminalBanner() escapes all user-controlled fields (summary, reason, prUrl) before assembling HTML (see dashboard/js/render-utils.js)
  bannerEl.innerHTML = html;
}

function stopLiveStream() {
  if (liveEventSource) {
    liveEventSource.close();
    liveEventSource = null;
  }
  if (liveTerminalSource) {
    try { liveTerminalSource.close(); } catch { /* optional */ }
    liveTerminalSource = null;
  }
  if (_runtimeTimer) { clearInterval(_runtimeTimer); _runtimeTimer = null; }
  stopLivePolling();
}

function startLivePolling() {
  stopLivePolling();
  refreshLiveOutput();
  livePollingInterval = setInterval(refreshLiveOutput, 3000);
}

function stopLivePolling() {
  if (livePollingInterval) { clearInterval(livePollingInterval); livePollingInterval = null; }
}

async function refreshLiveOutput() {
  if (!currentAgentId || currentTab !== 'live') { stopLivePolling(); return; }
  if (_steerInFlight) return; // Don't clobber immediate steering feedback
  try {
    const r = await safeFetch('/api/agent/' + currentAgentId + '/live?tail=16384');
    const text = await r.text();
    // X-Log-Size lets us seed _oldestLoadedByte on the first tick + know
    // whether more older content exists above what's currently rendered.
    const logSizeHeader = r.headers.get('X-Log-Size');
    if (logSizeHeader != null) _logFileSize = parseInt(logSizeHeader, 10) || 0;
    const el = document.getElementById('live-messages');
    if (!el) return;
    const region = _ensureTailRegion();
    if (!region) return;
    // Seed _oldestLoadedByte the first time we have a server file-size.
    // The tail fetch covers the last min(16384, _logFileSize) bytes, so the
    // oldest byte currently rendered is that lower bound.
    if (_logFileSize > 0 && _oldestLoadedByte === 0 && _lastRenderedText === '') {
      _oldestLoadedByte = Math.max(0, _logFileSize - 16384);
    }
    const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
    const savedScrollTop = el.scrollTop;
    const savedScrollLeft = el.scrollLeft;
    const incrementalSafe = _currentAgentRuntime() !== 'copilot';
    // Incremental render: only parse new content if text is an extension of previous
    if (incrementalSafe && _lastRenderedText && text.length > _lastRenderedText.length && text.startsWith(_lastRenderedText.slice(0, 200))) {
      renderLiveChatMessage(text.slice(_lastRenderedText.length));
    } else {
      // Clear the tail region only (NOT the whole #live-messages) so
      // scrollback chunks prepended above the region survive the rerender.
      region.innerHTML = '';
      renderLiveChatMessage(text);
    }
    _lastRenderedText = text;
    if (wasAtBottom) el.scrollTop = el.scrollHeight;
    else {
      el.scrollTop = savedScrollTop;
      el.scrollLeft = savedScrollLeft;
    }
    _updateJumpToLatestButton();
  } catch (e) { console.error('live-stream reload:', e.message); }
}

// W-mq1jbd8z0008dfa0 — scrollback: load older transcript content when the
// user scrolls near the top.
async function _loadOlderScrollback() {
  if (_scrollbackFetchInFlight) return;
  if (_oldestLoadedByte <= 0) return;
  if (!currentAgentId || _tailRegionAgentId !== currentAgentId) return;
  _scrollbackFetchInFlight = true;
  const container = document.getElementById('live-messages');
  if (!container) { _scrollbackFetchInFlight = false; return; }
  // Loading indicator at the top — structural HTML string literal, no user
  // data interpolated, so lint passes without a disable directive.
  const loadingId = '_scrollback-loading';
  container.insertAdjacentHTML('afterbegin', '<div id="' + loadingId + '" style="padding:4px 8px;color:var(--muted);font-size:var(--text-sm);font-style:italic;text-align:center">Loading older\u2026</div>');
  const from = Math.max(0, _oldestLoadedByte - SCROLLBACK_CHUNK);
  const to = _oldestLoadedByte;
  try {
    const r = await safeFetch('/api/agent/' + currentAgentId + '/live-output?from=' + from + '&to=' + to);
    const text = await r.text();
    const logSizeHeader = r.headers.get('X-Log-Size');
    if (logSizeHeader != null) _logFileSize = parseInt(logSizeHeader, 10) || _logFileSize;
    const html = renderAgentOutput(text);
    // Capture scrollHeight BEFORE prepend so we can restore the user's visible
    // position afterward — otherwise the scrollbar jumps as content stacks up.
    const savedScrollHeight = container.scrollHeight;
    const savedScrollTop = container.scrollTop;
    // Remove the loading indicator
    const loadingEl = document.getElementById(loadingId);
    if (loadingEl) loadingEl.remove();
    if (html) {
      // eslint-disable-next-line no-unsanitized/method -- reason: renderAgentOutput() escapes all user-controlled fields before assembling HTML
      container.insertAdjacentHTML('afterbegin', html);
    }
    // Anchor the visible position so prepending doesn't visually jump the user.
    container.scrollTop = savedScrollTop + (container.scrollHeight - savedScrollHeight);
    _oldestLoadedByte = from;
  } catch (e) {
    console.error('scrollback fetch:', e.message);
    const loadingEl = document.getElementById(loadingId);
    if (loadingEl) loadingEl.remove();
  } finally {
    _scrollbackFetchInFlight = false;
  }
}

// Wire scroll handler + "Jump to latest" button. Idempotent: re-attaches
// when the live tab re-renders the container.
function _attachScrollHandler() {
  const container = document.getElementById('live-messages');
  if (!container || container.dataset.scrollbackHooked === '1') return;
  container.dataset.scrollbackHooked = '1';
  container.addEventListener('scroll', function() {
    // Load older content when within 200px of the top.
    if (container.scrollTop <= SCROLLBACK_TRIGGER_PX && _oldestLoadedByte > 0 && !_scrollbackFetchInFlight) {
      _loadOlderScrollback();
    }
    _updateJumpToLatestButton();
  });
}

function _ensureJumpToLatestButton() {
  // The button is positioned absolute inside #live-chat (already
  // position:relative-friendly because of its flex column layout). Inject
  // once; #live-chat is re-rendered on tab switch, which removes it, so
  // this function lazy-creates as needed.
  const liveChat = document.getElementById('live-chat');
  if (!liveChat) return null;
  // Ensure positioning context for absolute child.
  if (liveChat.style.position !== 'relative' && getComputedStyle(liveChat).position === 'static') {
    liveChat.style.position = 'relative';
  }
  let btn = document.getElementById('live-jump-latest');
  if (!btn) {
    btn = document.createElement('button');
    btn.id = 'live-jump-latest';
    btn.type = 'button';
    btn.textContent = '\u2193 Jump to latest';
    btn.style.cssText = [
      'position:absolute',
      'right:16px',
      'bottom:96px',
      'padding:6px 12px',
      'background:var(--blue)',
      'color:#fff',
      'border:none',
      'border-radius:var(--radius-sm)',
      'font-size:var(--text-sm)',
      'cursor:pointer',
      'box-shadow:0 2px 6px rgba(0,0,0,0.25)',
      'z-index:5',
      'display:none',
    ].join(';');
    btn.addEventListener('click', function() {
      const container = document.getElementById('live-messages');
      if (!container) return;
      container.scrollTop = container.scrollHeight;
      _updateJumpToLatestButton();
    });
    liveChat.appendChild(btn);
  }
  return btn;
}

function _updateJumpToLatestButton() {
  const container = document.getElementById('live-messages');
  if (!container) return;
  const btn = _ensureJumpToLatestButton();
  if (!btn) return;
  const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
  btn.style.display = atBottom ? 'none' : 'block';
}

async function sendSteering() {
  const input = document.getElementById('live-steer-input');
  if (!input || !input.value.trim() || !currentAgentId) return;
  const message = input.value.trim();
  input.value = '';

  // Pause polling so the immediate feedback isn't clobbered
  _steerInFlight = true;

  // Immediate feedback — show the message right away. Render INTO the tail
  // region so a subsequent scrollback prepend stays above the steering bubble.
  const region = _ensureTailRegion();
  const container = document.getElementById('live-messages');
  if (region) {
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: steering message)
    region.insertAdjacentHTML('beforeend', '<div style="align-self:flex-end;background:var(--blue);color:#fff;padding:6px 12px;border-radius:12px 12px 2px 12px;max-width:80%;margin:4px 0;font-size:var(--text-md)">' + escHtml(message) +
      '<div id="steer-pending" style="font-size:var(--text-xs);opacity:0.7;margin-top:2px">\u2197 Sending...</div></div>');
    if (container) container.scrollTop = container.scrollHeight;
  }
  showToast('cmd-toast', 'Steering message sent to ' + currentAgentId, true);

  try {
    const res = await fetch('/api/agents/steer', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ agent: currentAgentId, message })
    });
    const pending = document.getElementById('steer-pending');
    if (!res.ok) {
      const d = await res.json().catch(() => ({}));
      if (pending) { pending.textContent = '\u26A0 Failed: ' + (d.error || 'unknown'); pending.style.opacity = '1'; }
    } else {
      if (pending) {
        pending.textContent = '\u2713 Sent — waiting for agent to respond...';
        // Poll for agent acknowledgment (new output after steering)
        let ackChecks = 0;
        const ackInterval = setInterval(async () => {
          ackChecks++;
          if (ackChecks > 30) { // Give up after 30s
            clearInterval(ackInterval);
            if (pending.textContent.includes('waiting')) pending.textContent = '\u2713 Sent — agent may respond shortly';
            return;
          }
          try {
            const liveRes = await fetch('/api/agent/' + encodeURIComponent(currentAgentId) + '/live-output');
            const text = await liveRes.text();
            // Check if there's runtime output after the [human-steering] line.
            // Claude emits "assistant"; Copilot emits "assistant.message_delta"
            // / "assistant.message", and either one proves the resume turn ran.
            const steerIdx = text.lastIndexOf('[human-steering]');
            if (steerIdx >= 0) {
              const afterSteer = text.slice(steerIdx + '[human-steering]'.length);
              const sawRuntimeOutput = /"type"\s*:\s*"(assistant(?:\.|")|tool\.|session\.task_complete"|result")/.test(afterSteer);
              if (afterSteer.length > 20 && sawRuntimeOutput) {
                clearInterval(ackInterval);
                pending.textContent = '\u2713 Agent acknowledged';
                pending.style.color = 'var(--green)';
              }
            }
          } catch {}
        }, 1000);
      }
    }
  } catch (e) {
    const pending = document.getElementById('steer-pending');
    if (pending) { pending.textContent = '\u26A0 ' + e.message; pending.style.opacity = '1'; }
  } finally {
    // Resume polling — delay slightly so the [human-steering] line is in the log
    setTimeout(() => {
      _steerInFlight = false;
      refreshLiveOutput();
      // Ensure polling continues (may have stopped if tab switched during steering)
      if (!livePollingInterval && currentTab === 'live') startLivePolling();
    }, 1000);
  }
}

window.MinionsLive = { renderLiveChatMessage, renderTerminalBannerForAgent, startLiveStream, stopLiveStream, startLivePolling, stopLivePolling, refreshLiveOutput, sendSteering };
