/**
 * engine/dispatch-events.js — Single source of truth for dispatch terminal events.
 *
 * W-mpob4nyk0006580e: The live view banner ("✓ Task complete" / "✗ Task ended
 * with …") must mirror `dispatch.result` exactly. Previously the banner was
 * derived in the browser from the JSONL `result` line + the `[process-exit]`
 * sentinel, which disagrees with the engine's terminal state in real-world
 * cases (CLI crash AFTER writing a successful completion report, exit-0 with
 * no result line, hard-kill by timeout sweep, …). This module replaces that
 * heuristic with a push channel rooted in `completeDispatch()`:
 *
 *   completeDispatch() → mutateDispatch writes `result` → emitDispatchTerminal()
 *
 * Subscribers are notified two ways so the channel works in both the
 * in-process test harness and the engine↔dashboard cross-process runtime:
 *
 *   1. EventEmitter — same-process consumers (unit tests, future co-located
 *      callers) subscribe via onDispatchTerminal().
 *   2. JSONL ring at `<engine>/dispatch-terminal-events.jsonl` — the dashboard
 *      process tails this file via fs.watchFile (see dashboard.js
 *      handleAgentLiveStream) and re-broadcasts each new entry as an SSE
 *      `dispatch.terminal` frame to live-view clients.
 *
 * The ring is capped (MAX_RING_ENTRIES, REPLAY_TTL_MS) so a viewer that
 * connects AFTER a dispatch completes still receives the most recent terminal
 * verdict on connect (replay-on-connect).
 */

const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
const shared = require('./shared');

const MAX_RING_ENTRIES = 50;          // bound file size + memory; dev tool
const REPLAY_TTL_MS = 5 * 60 * 1000;  // 5 min — long enough for tab refresh

const _emitter = new EventEmitter();
// Live view streams subscribe per-connection; cap is generous to avoid
// MaxListenersExceeded warnings in dashboards with many open live tabs.
_emitter.setMaxListeners(500);

function _eventsFile() {
  // Re-read shared.MINIONS_DIR at call time so test-isolated minions dirs
  // (createTestMinionsDir) get a tmp-scoped events file rather than the live
  // engine/ directory.
  return path.join(shared.MINIONS_DIR, 'engine', 'dispatch-terminal-events.jsonl');
}

function _startedEventsFile() {
  // W-mq1m1wo5000nd78d — parallel ring for `dispatch.started` events. Kept
  // separate from the terminal ring so a same-agent re-dispatch can clear
  // the prior terminal banner without us having to peek/merge two event
  // types in the dashboard tailer or worry about per-dispatch ordering
  // when one ring rolls over before the other.
  return path.join(shared.MINIONS_DIR, 'engine', 'dispatch-started-events.jsonl');
}

function _now() { return Date.now(); }

function _readLinesFrom(file) {
  try {
    const raw = fs.readFileSync(file, 'utf8');
    return raw.split('\n').filter(Boolean);
  } catch { return []; }
}

function _readLines() {
  return _readLinesFrom(_eventsFile());
}

function _appendEventLineTo(file, event) {
  const dir = path.dirname(file);
  try {
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    let lines = _readLinesFrom(file);
    lines.push(JSON.stringify(event));
    // Drop entries older than REPLAY_TTL_MS, then cap by entry count.
    const cutoff = _now() - REPLAY_TTL_MS;
    lines = lines.filter(line => {
      try { return (JSON.parse(line).ts || 0) >= cutoff; }
      catch { return false; }
    });
    if (lines.length > MAX_RING_ENTRIES) {
      lines = lines.slice(lines.length - MAX_RING_ENTRIES);
    }
    fs.writeFileSync(file, lines.join('\n') + '\n');
  } catch {
    // Best-effort observability channel — engine progress must never block
    // on a failed disk write here.
  }
}

function _appendEventLine(event) {
  _appendEventLineTo(_eventsFile(), event);
}

/**
 * Build + broadcast a dispatch terminal event. Called from completeDispatch()
 * after the dispatch record's `result` is committed. The event payload is the
 * authoritative shape consumed by the live-view banner renderer.
 *
 * Shape:
 *   {
 *     type: 'dispatch.terminal',
 *     ts: <epoch-ms>,
 *     dispatchId: <string>,
 *     agentId: <string>,
 *     result: 'success' | 'error' | 'timeout',
 *     reason: <string>,            // human-readable
 *     failureClass: <string|null>, // FAILURE_CLASS enum or null
 *     prUrl: <string|null>,        // PR URL/id from completion report, or null
 *     summary: <string>            // first line of completion summary (240ch max)
 *   }
 */
function emitDispatchTerminal(event) {
  if (!event || typeof event !== 'object') return;
  if (!event.type) event.type = 'dispatch.terminal';
  if (!event.ts) event.ts = _now();
  _appendEventLine(event);
  try { _emitter.emit('dispatch.terminal', event); } catch { /* never throw */ }
}

function onDispatchTerminal(listener) {
  _emitter.on('dispatch.terminal', listener);
  return () => _emitter.off('dispatch.terminal', listener);
}

function readRecentEvents() {
  const cutoff = _now() - REPLAY_TTL_MS;
  return _readLines()
    .map(line => { try { return JSON.parse(line); } catch { return null; } })
    .filter(e => e && (e.ts || 0) >= cutoff);
}

function getLastTerminalForAgent(agentId) {
  if (!agentId) return null;
  const events = readRecentEvents();
  for (let i = events.length - 1; i >= 0; i--) {
    if (events[i].agentId === agentId) return events[i];
  }
  return null;
}

function getLastTerminalForDispatch(dispatchId) {
  if (!dispatchId) return null;
  const events = readRecentEvents();
  for (let i = events.length - 1; i >= 0; i--) {
    if (events[i].dispatchId === dispatchId) return events[i];
  }
  return null;
}

/**
 * Build the terminal event payload from a freshly-completed dispatch entry +
 * the inputs to completeDispatch(). Extracted so both the engine emitter and
 * the dashboard's JSONL tailer reach the same shape.
 */
function buildTerminalEvent({ dispatchId, agentId, result, reason, failureClass, structuredCompletion, resultSummary }) {
  let prUrl = null;
  let summary = '';
  if (structuredCompletion && typeof structuredCompletion === 'object') {
    const sc = structuredCompletion;
    if (sc.pr && typeof sc.pr === 'string' && sc.pr !== 'N/A') prUrl = sc.pr;
    const rawSummary = typeof sc.summary === 'string' ? sc.summary : '';
    if (rawSummary) summary = rawSummary.split('\n')[0].slice(0, 240);
  }
  if (!summary && typeof resultSummary === 'string' && resultSummary) {
    summary = resultSummary.split('\n')[0].slice(0, 240);
  }
  return {
    type: 'dispatch.terminal',
    dispatchId,
    agentId: agentId || null,
    result,
    reason: reason || '',
    failureClass: failureClass || null,
    prUrl,
    summary,
  };
}

/**
 * W-mq1m1wo5000nd78d — symmetric counterpart to `dispatch.terminal`. Called
 * exactly once per FRESH spawn from engine.js#spawnAgent (NOT from cli.js
 * re-attach paths, which inherit a still-live previous-dispatch banner that
 * must not be clobbered by a synthetic restart-time started event).
 *
 * Sole consumer today: dashboard/js/live-stream.js — clears
 * `#live-terminal-banner` so a stale "✓ Task complete" / "✗ Task ended"
 * banner from the agent's prior dispatch doesn't linger across same-agent
 * re-dispatch (e.g. Ripley finishing one scheduled run and immediately
 * starting another).
 *
 * Shape:
 *   {
 *     type: 'dispatch.started',
 *     ts: <epoch-ms>,
 *     dispatchId: <string>,
 *     agentId: <string>,
 *     taskTitle: <string>,
 *     startedAt: <ISO string>,
 *   }
 *
 * No `replay-on-connect`: the banner-clear is the only consumer, and
 * replaying a stale started event when a viewer opens AFTER terminal has
 * already rendered would silently wipe the terminal banner. Live broadcast
 * only — there's no stale banner to clear when the page just loaded.
 */
function emitDispatchStarted(event) {
  if (!event || typeof event !== 'object') return;
  if (!event.type) event.type = 'dispatch.started';
  if (!event.ts) event.ts = _now();
  _appendEventLineTo(_startedEventsFile(), event);
  try { _emitter.emit('dispatch.started', event); } catch { /* never throw */ }
}

function onDispatchStarted(listener) {
  _emitter.on('dispatch.started', listener);
  return () => _emitter.off('dispatch.started', listener);
}

function readRecentStartedEvents() {
  const cutoff = _now() - REPLAY_TTL_MS;
  return _readLinesFrom(_startedEventsFile())
    .map(line => { try { return JSON.parse(line); } catch { return null; } })
    .filter(e => e && (e.ts || 0) >= cutoff);
}

function buildStartedEvent({ dispatchId, agentId, taskTitle, startedAt }) {
  let safeTitle = '';
  if (typeof taskTitle === 'string' && taskTitle) {
    safeTitle = taskTitle.split('\n')[0].slice(0, 240);
  }
  return {
    type: 'dispatch.started',
    dispatchId,
    agentId: agentId || null,
    taskTitle: safeTitle,
    startedAt: startedAt || null,
  };
}

module.exports = {
  emitDispatchTerminal,
  onDispatchTerminal,
  readRecentEvents,
  getLastTerminalForAgent,
  getLastTerminalForDispatch,
  buildTerminalEvent,
  // dispatch.started (W-mq1m1wo5000nd78d)
  emitDispatchStarted,
  onDispatchStarted,
  readRecentStartedEvents,
  buildStartedEvent,
  // exposed for testing / dashboard tailer
  _eventsFile,
  _startedEventsFile,
  MAX_RING_ENTRIES,
  REPLAY_TTL_MS,
};
