// --- Headless Terminal Worker Thread ---
// Maintains a @xterm/headless Terminal instance per session (no renderer cost).
// Replaces vterm-worker.js: full xterm.js parser is more accurate for ANSI
// parsing than the custom VTermScreen, and the serialize addon enables instant
// terminal state snapshots for tab-switch restore.
//
// Message protocol:
//   Main → Worker:
//     { type: 'create', sessionId, cols, rows }
//     { type: 'feed', sessionId, data, requestId? }
//     { type: 'resize', sessionId, cols, rows }
//     { type: 'serialize', sessionId, requestId, scrollback? }
//     { type: 'fingerprint', sessionId, requestId, cols, rows }
//     { type: 'destroy', sessionId }
//     { type: 'reset', sessionId }
//     { type: 'recheck', sessionId, requestId }
//     { type: 'getText', sessionId, requestId }
//     { type: 'getViewportText', sessionId, requestId }
//     { type: 'getRows', sessionId, requestId }
//
//   Worker → Main:
//     { type: 'feed-result', sessionId, requestId, hasApproval, text?, rawDetected?, rawText?, debugLines }
//     { type: 'serialized', sessionId, requestId, data, cols, rows, fp }
//     { type: 'fingerprinted', sessionId, requestId, fp, cols, rows, scrollbackLen }
//     { type: 'text', sessionId, requestId, text }
//     { type: 'rows', sessionId, requestId, rows: [{ text, wrapped }] }

const { Terminal } = require('@xterm/headless');
const { SerializeAddon } = require('@xterm/addon-serialize');
const { fingerprintRows, EMPTY_FINGERPRINT } = require('./terminal-fingerprint');

const { detectApproval, detectApprovalWithProvider } = require('../workers/approval-patterns');
const { validateWidget } = require('../workers/approval-widget-validator');
const {
  MAX_SNAPSHOT_SCROLLBACK_ROWS,
  normalizeSnapshotScrollbackRows,
} = require('./terminal-snapshot-options');

let postMessage = () => {};

// Phase 1 temporal thresholds. Tuned empirically: Claude Code stops
// streaming output ~50ms before showing the prompt; cursor settles within
// a few ms of the final widget draw. We require a margin.
const CURSOR_STABLE_MS = 200;
const OUTPUT_QUIESCENT_MS = 100;
const DIRECT_APPROVAL_SCAN_INTERVAL_MS = 150;

// Phase 3 cooldown: if the post-keystroke verification fails, we disable
// auto-approval for this session for this window. Prevents a chain of
// false keystrokes from a persistent false positive.
const VERIFY_FAIL_COOLDOWN_MS = 30 * 1000;

// Per-session headless terminal instances + state
const terminals = new Map(); // sessionId -> { term, serialize, state }

function getOrCreate(sessionId, cols = 120, rows = 30) {
  let entry = terminals.get(sessionId);
  if (!entry) {
    const term = new Terminal({
      cols,
      rows,
      scrollback: MAX_SNAPSHOT_SCROLLBACK_ROWS,
      allowProposedApi: true,
    });
    const serialize = new SerializeAddon();
    term.loadAddon(serialize);
    entry = {
      term,
      serialize,
      state: {
        cursorY: -1, cursorX: -1,
        lastCursorMoveAt: 0,
        lastOutputAt: 0,
        nextDirectApprovalScanAt: 0,
        // Phase 3 verification: set by main thread after it fires a keystroke;
        // worker skips emitting approval-alert while this is in the future.
        verifyUntil: 0,
        // Phase 3 cooldown: set when verification fails; skip all detection
        cooldownUntil: 0,
      },
    };
    terminals.set(sessionId, entry);
  }
  return entry;
}

/**
 * Snapshot the current cursor position; update stability timestamps.
 * Called after each feed. Returns the state object.
 */
function _trackCursor(entry) {
  const now = Date.now();
  const buf = entry.term.buffer.active;
  const y = buf.cursorY;
  const x = buf.cursorX;
  const s = entry.state;
  if (s.cursorY !== y || s.cursorX !== x) {
    s.cursorY = y;
    s.cursorX = x;
    s.lastCursorMoveAt = now;
  }
  s.lastOutputAt = now;
  return s;
}

/**
 * Phase 1 temporal gate: cursor stable, output quiescent.
 */
function _temporalGateOk(state) {
  const now = Date.now();
  const cursorAge = now - state.lastCursorMoveAt;
  const outputAge = now - state.lastOutputAt;
  return cursorAge >= CURSOR_STABLE_MS && outputAge >= OUTPUT_QUIESCENT_MS;
}

/**
 * Full gate check — combines Phase 1 temporal + Phase 2 structural +
 * Phase 3 cooldown. Returns { ok, reason }.
 *
 * The order matters for debugging: cheap checks first.
 */
function _gateApproval(entry, _text) {
  const { state, term } = entry;
  const now = Date.now();

  // Phase 3: cooldown after verification failure
  if (state.cooldownUntil > now) {
    return { ok: false, reason: 'cooldown' };
  }

  // Phase 3: in the verification window from a previous keystroke — skip
  if (state.verifyUntil > now) {
    return { ok: false, reason: 'verify-window' };
  }

  // Phase 1: temporal gate (note: temporal is a SOFT signal — we still
  // require structural widget detection below, so temporal failure alone
  // doesn't reject; we log it as a signal)
  // TEMPORAL IS DISABLED IN RECHECK PATH because the recheck fires on a
  // timer with no fresh output — both ages would always pass. Keep the
  // temporal check advisory only; structural is the primary gate.
  // const temporalOk = _temporalGateOk(state);

  // Phase 1+2: structural widget validation. Requires alt-screen active,
  // "1. Yes" in bottom rows, widget-like formatting (color or ❯).
  const widget = validateWidget(term);
  if (!widget.valid) {
    return { ok: false, reason: `widget:${widget.reason}` };
  }

  return { ok: true, reason: 'ok' };
}

// Throttle log spam — one line per (session, reason) per 10s.
const _gateMissLog = new Map(); // sessionId -> { reason -> lastLoggedAt }
function _debugLinesFromText(text) {
  return String(text || '')
    .split('\n')
    .filter(l => l.trim())
    .slice(-3)
    .map(l => l.trim().slice(0, 80));
}

function _logGateMiss(sessionId, reason, details = {}) {
  const now = Date.now();
  let bySession = _gateMissLog.get(sessionId);
  if (!bySession) { bySession = new Map(); _gateMissLog.set(sessionId, bySession); }
  const last = bySession.get(reason) || 0;
  if (now - last < 10000) return;
  bySession.set(reason, now);
  // Emit a diagnostic to the main thread — main thread logs with session tag
  postMessage({
    type: 'gate-miss',
    sessionId,
    reason,
    source: details.source || 'realtime',
    providerId: details.providerId || null,
    rawDetected: !!details.rawDetected,
    rawText: details.rawText || null,
    debugLines: details.debugLines || _debugLinesFromText(details.rawText || ''),
  });
}

function _approvalOutputHasHint(text) {
  if (!text) return false;
  return /do you want|proceed|approve|approval|permission|allow|\byes\b|\bno\b|run this command|bash|edit|tool use|❯|1\.\s*yes|2\.\s*no/i.test(String(text));
}

// Extract visible text from the terminal buffer (for approval detection).
// Reads the viewport plus a window of scrollback above it so that prompts
// taller than the viewport (e.g. a Codex prompt with a long heredoc command)
// still expose their question/reason/command lines — those scroll above
// viewportY but are still in `buffer.active` for as long as scrollback holds
// them. Without the scrollback window, only the bottom 3-5 lines of the
// option list survive and parsers like codex.parse() fail.
//
// 80 rows = ~2 viewports of headroom on a typical 30-row terminal. Wider
// than any Codex prompt observed in production while bounding the scan cost.
const APPROVAL_SCROLLBACK_ROWS = 80;
function getVisibleText(term) {
  const buf = term.buffer.active;
  const lines = [];
  const start = Math.max(0, buf.viewportY - APPROVAL_SCROLLBACK_ROWS);
  const end = buf.viewportY + term.rows;
  for (let i = start; i < end; i++) {
    const line = buf.getLine(i);
    if (line) lines.push(line.translateToString(true));
  }
  return lines.join('\n');
}

function getViewportText(term) {
  const buf = term.buffer.active;
  const lines = [];
  const start = buf.viewportY;
  const end = buf.viewportY + term.rows;
  for (let i = start; i < end; i++) {
    const line = buf.getLine(i);
    if (line) lines.push(line.translateToString(true));
  }
  return lines.join('\n');
}

// Fingerprint of the visible grid at the terminal's CURRENT dims. Shared by the
// `serialize` and `fingerprint` ops so a snapshot can carry its own content fp and
// the client can skip a destructive re-render when its buffer already matches.
function viewportFingerprint(term) {
  const buf = term.buffer.active;
  const rows = term.rows;
  const start = buf.viewportY;
  const rowStrings = [];
  for (let i = 0; i < rows; i++) {
    const line = buf.getLine(start + i);
    rowStrings.push(line ? line.translateToString(true) : '');
  }
  const scrollbackLen = Math.max(0, buf.length - rows);
  return fingerprintRows(rowStrings, { cols: term.cols, rows, scrollbackLen });
}

function getVisibleRows(term) {
  const buf = term.buffer.active;
  const rows = [];
  const start = Math.max(0, buf.viewportY - APPROVAL_SCROLLBACK_ROWS);
  const end = buf.viewportY + term.rows;
  for (let i = start; i < end; i++) {
    const line = buf.getLine(i);
    if (line) {
      rows.push({
        text: line.translateToString(true),
        wrapped: !!line.isWrapped,
      });
    }
  }
  return rows;
}

// Queue for pending writes — headless xterm.js write() is async (uses microtask),
// so we need to sequence operations that depend on write completion.
const writeQueues = new Map(); // sessionId -> Promise chain

// Bound how long a single xterm.js write can block the queue. In rare cases
// (memory pressure, specific ANSI sequences that trip an internal bug)
// `entry.term.write(data, callback)` fails to invoke the callback, which would
// otherwise permanently stall the session's write chain — and with it, all
// approval detection (both direct-feed and recheck await the queue). When
// this fires we force-advance: the terminal state may be slightly stale for
// this one chunk, but new writes unblock and detection resumes.
const WRITE_TIMEOUT_MS = 5000;
// Telemetry: how many times each session tripped the timeout. Surfaced to
// the main thread via 'write-stall' messages so the UI can show a degraded
// badge rather than leaving the user guessing why auto-approve is silent.
const _writeStallCounts = new Map(); // sessionId -> count

function enqueueWrite(sessionId, data) {
  const entry = getOrCreate(sessionId);
  const prev = writeQueues.get(sessionId) || Promise.resolve();
  const next = prev.then(() => new Promise(resolve => {
    let settled = false;
    const timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      const count = (_writeStallCounts.get(sessionId) || 0) + 1;
      _writeStallCounts.set(sessionId, count);
      try {
        postMessage({ type: 'write-stall', sessionId, count, timeoutMs: WRITE_TIMEOUT_MS });
      } catch {}
      resolve();
    }, WRITE_TIMEOUT_MS);
    entry.term.write(data, () => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      resolve();
    });
  }));
  // Periodically reset the promise chain to prevent unbounded growth.
  // After the write completes, replace the chain with a fresh resolved promise.
  const reset = next.then(() => {
    if (writeQueues.get(sessionId) === reset) writeQueues.set(sessionId, Promise.resolve());
  });
  writeQueues.set(sessionId, reset);
  return next;
}

// Bounded wait for the pending-writes queue before reading terminal state.
// Routine detection passes intentionally use a short wait so they stay cheap.
// Authoritative snapshots, especially the final Codex exit snapshot, can opt
// into a longer bounded wait because they become the persisted terminal truth.
const QUEUE_WAIT_TIMEOUT_MS = 2000;
const MAX_QUEUE_WAIT_TIMEOUT_MS = 15000;
function normalizeQueueWaitTimeoutMs(value) {
  if (!Number.isFinite(Number(value))) return QUEUE_WAIT_TIMEOUT_MS;
  return Math.max(0, Math.min(MAX_QUEUE_WAIT_TIMEOUT_MS, Number(value)));
}

async function _awaitQueueBounded(sessionId, timeoutMs = QUEUE_WAIT_TIMEOUT_MS) {
  const queue = writeQueues.get(sessionId);
  if (!queue) return;
  const boundedTimeoutMs = normalizeQueueWaitTimeoutMs(timeoutMs);
  let timer;
  const timeoutPromise = new Promise(resolve => {
    timer = setTimeout(resolve, boundedTimeoutMs);
  });
  await Promise.race([queue, timeoutPromise]);
  if (timer) clearTimeout(timer);
}

const opQueues = new Map(); // sessionId -> Promise chain for feed/serialize/destroy ordering

function enqueueSessionOp(sessionId, fn) {
  const prev = opQueues.get(sessionId) || Promise.resolve();
  let next;
  next = prev
    .catch(() => {})
    .then(fn)
    .catch((err) => {
      try {
        postMessage({
          type: 'worker-error',
          sessionId,
          message: err && err.message ? err.message : String(err),
        });
      } catch {}
    })
    .finally(() => {
      if (opQueues.get(sessionId) === next) opQueues.delete(sessionId);
    });
  opQueues.set(sessionId, next);
  return next;
}

async function handleMessage(msg) {
  switch (msg.type) {
    case 'create': {
      getOrCreate(msg.sessionId, msg.cols, msg.rows);
      break;
    }

    case 'feed': {
      // Feed data into the headless terminal buffer. Two modes:
      //
      // 1. Direct feed (no requestId): from PTY onData — keeps headless terminal
      //    current for snapshots. Also runs lightweight approval detection and sends
      //    an immediate 'approval-alert' if found. This catches prompts the instant
      //    they render, regardless of main-thread CPU load or throttle state.
      //
      // 2. Throttled feed (has requestId): from the approval engine's recheck cycle.
      //    Sends full feed-result with hasApproval flag. Acts as safety-net fallback
      //    for prompts missed by the direct-feed path (e.g. multi-chunk rendering).
      await enqueueWrite(msg.sessionId, msg.data);

      const entry = terminals.get(msg.sessionId);
      if (!entry) break;

      // Phase 1: track cursor/output movement for temporal gating below.
      // Called for BOTH direct-feed and throttled-feed so state is always fresh.
      _trackCursor(entry);

      if (msg.requestId == null) {
        // Direct feed — run real-time approval detection.
        if (!_approvalOutputHasHint(msg.data)) break;
        const now = Date.now();
        if (entry.state.nextDirectApprovalScanAt && now < entry.state.nextDirectApprovalScanAt) break;
        entry.state.nextDirectApprovalScanAt = now + DIRECT_APPROVAL_SCAN_INTERVAL_MS;
        const text = getVisibleText(entry.term);
        const { detected, providerId } = detectApprovalWithProvider(text);
        if (!detected) break;
        // Phase 1/2/3 gates — see _gateApproval for reasoning.
        const gate = _gateApproval(entry, text);
        if (!gate.ok) {
          // Log once per reason per session to avoid spam.
          _logGateMiss(msg.sessionId, gate.reason, {
            source: 'realtime',
            providerId,
            rawDetected: true,
            rawText: text,
          });
          break;
        }
        postMessage({
          type: 'approval-alert',
          sessionId: msg.sessionId,
          providerId,
          text,
        });
        break;
      }

      // Throttled recheck path (safety net)
      const text = getVisibleText(entry.term);
      const { detected: hasApprovalRaw, providerId } = detectApprovalWithProvider(text);
      const hintDetected = _approvalOutputHasHint(text);
      // Apply same gating to the throttled path — hasApproval reflects gated decision.
      let gatedApproval = hasApprovalRaw;
      let gateReason = null;
      if (hasApprovalRaw) {
        const gate = _gateApproval(entry, text);
        if (!gate.ok) { gatedApproval = false; gateReason = gate.reason; }
      }
      const debugLines = _debugLinesFromText(text);

      postMessage({
        type: 'feed-result',
        sessionId: msg.sessionId,
        requestId: msg.requestId,
        hasApproval: gatedApproval,
        providerId,
        text: gatedApproval ? text : null,
        rawDetected: hasApprovalRaw,
        hintDetected,
        rawText: hasApprovalRaw || hintDetected ? text : null,
        debugLines,
        gateReason,
      });
      break;
    }

    case 'recheck': {
      // Re-evaluate current screen without feeding new data
      const entry = terminals.get(msg.sessionId);
      if (!entry) {
        // Send empty result so main thread's _workerPending gets cleared
        if (msg.requestId != null) {
          postMessage({
            type: 'feed-result', sessionId: msg.sessionId,
            requestId: msg.requestId, hasApproval: false, providerId: null, text: null,
            rawDetected: false, rawText: null, debugLines: [],
          });
        }
        break;
      }
      // Wait for any pending writes to complete (bounded — do not hang forever
      // on a stuck write queue)
      await _awaitQueueBounded(msg.sessionId);

      const text = getVisibleText(entry.term);
      const { detected: hasApprovalRaw, providerId } = detectApprovalWithProvider(text);
      const hintDetected = _approvalOutputHasHint(text);
      // Apply full gating on recheck too (structural + cooldown).
      // Temporal gate is skipped here because recheck fires without fresh
      // output — the structural widget validator is the primary signal.
      let hasApproval = hasApprovalRaw;
      let gateReason = null;
      if (hasApprovalRaw) {
        const gate = _gateApproval(entry, text);
        if (!gate.ok) { hasApproval = false; gateReason = gate.reason; }
      }
      const debugLines = _debugLinesFromText(text);

      postMessage({
        type: 'feed-result',
        sessionId: msg.sessionId,
        requestId: msg.requestId,
        hasApproval,
        providerId,
        text: hasApproval ? text : null,
        rawDetected: hasApprovalRaw,
        hintDetected,
        rawText: hasApprovalRaw || hintDetected ? text : null,
        debugLines,
        gateReason,
      });
      break;
    }

    case 'verify-start': {
      // Phase 3: main thread just wrote a keystroke. Enter verification
      // window — skip all approval detection until verify-end or window expires.
      const entry = terminals.get(msg.sessionId);
      if (!entry) break;
      entry.state.verifyUntil = Date.now() + (msg.windowMs || 500);
      break;
    }

    case 'verify-end': {
      // Phase 3: main thread observed outcome. If verification failed
      // (the keystroke produced no state transition), enter cooldown.
      const entry = terminals.get(msg.sessionId);
      if (!entry) break;
      entry.state.verifyUntil = 0;
      if (msg.failed) {
        entry.state.cooldownUntil = Date.now() + VERIFY_FAIL_COOLDOWN_MS;
      }
      break;
    }

    case 'resize': {
      const entry = terminals.get(msg.sessionId);
      if (entry) entry.term.resize(msg.cols, msg.rows);
      break;
    }

    case 'serialize': {
      const entry = terminals.get(msg.sessionId);
      if (!entry) {
        postMessage({
          type: 'serialized', sessionId: msg.sessionId,
          requestId: msg.requestId, data: '', cols: 120, rows: 30,
          fp: EMPTY_FINGERPRINT,
        });
        break;
      }
      // Wait for any pending writes to complete before serializing (bounded).
      // Final-exit snapshots pass a longer wait so a large final PTY burst
      // cannot be serialized before xterm has applied it.
      await _awaitQueueBounded(msg.sessionId, msg.queueWaitTimeoutMs);

      const scrollback = normalizeSnapshotScrollbackRows(msg.scrollback);
      const data = entry.serialize.serialize({ scrollback });
      postMessage({
        type: 'serialized',
        sessionId: msg.sessionId,
        requestId: msg.requestId,
        data,
        cols: entry.term.cols,
        rows: entry.term.rows,
        scrollback,
        fp: viewportFingerprint(entry.term),
      });
      break;
    }

    case 'fingerprint': {
      const entry = terminals.get(msg.sessionId);
      if (!entry) {
        postMessage({
          type: 'fingerprinted', sessionId: msg.sessionId,
          requestId: msg.requestId, fp: EMPTY_FINGERPRINT,
          cols: Number(msg.cols) || 0, rows: Number(msg.rows) || 0, scrollbackLen: 0,
        });
        break;
      }
      // Wait for pending writes so we fingerprint settled state, not mid-write.
      await _awaitQueueBounded(msg.sessionId, msg.queueWaitTimeoutMs);
      // Measure at the CLIENT's dims so client and server fingerprint the same
      // grid (foreign-width content is real divergence). We resize, sample, then
      // resize BACK so the fingerprint probe never permanently mutates the
      // authoritative dims that serialize()/other ops depend on. Guard against
      // NaN/zero dims (hidden/zero-size client container).
      // NOTE: once the dimension-authority pillar (P2) keeps the headless term
      // at the client's dims at all times, this resize round-trip is a no-op.
      const origCols = entry.term.cols;
      const origRows = entry.term.rows;
      const wantResize = Number.isFinite(msg.cols) && Number.isFinite(msg.rows)
        && msg.cols >= 1 && msg.rows >= 1
        && (origCols !== msg.cols || origRows !== msg.rows);
      if (wantResize) entry.term.resize(msg.cols, msg.rows);
      const sampleCols = entry.term.cols;
      const sampleRows = entry.term.rows;
      const scrollbackLen = Math.max(0, entry.term.buffer.active.length - sampleRows);
      const fp = viewportFingerprint(entry.term);
      if (wantResize) entry.term.resize(origCols, origRows);
      postMessage({
        type: 'fingerprinted', sessionId: msg.sessionId,
        requestId: msg.requestId, fp,
        cols: sampleCols, rows: sampleRows, scrollbackLen,
      });
      break;
    }

    case 'getText': {
      const entry = terminals.get(msg.sessionId);
      if (!entry) {
        postMessage({ type: 'text', sessionId: msg.sessionId, requestId: msg.requestId, text: '' });
        break;
      }
      await _awaitQueueBounded(msg.sessionId);
      const text = getVisibleText(entry.term);
      postMessage({ type: 'text', sessionId: msg.sessionId, requestId: msg.requestId, text });
      break;
    }

    case 'getViewportText': {
      const entry = terminals.get(msg.sessionId);
      if (!entry) {
        postMessage({ type: 'viewport-text', sessionId: msg.sessionId, requestId: msg.requestId, text: '' });
        break;
      }
      await _awaitQueueBounded(msg.sessionId);
      const text = getViewportText(entry.term);
      postMessage({ type: 'viewport-text', sessionId: msg.sessionId, requestId: msg.requestId, text });
      break;
    }

    case 'getRows': {
      const entry = terminals.get(msg.sessionId);
      if (!entry) {
        postMessage({ type: 'rows', sessionId: msg.sessionId, requestId: msg.requestId, rows: [] });
        break;
      }
      await _awaitQueueBounded(msg.sessionId);
      const rows = getVisibleRows(entry.term);
      postMessage({ type: 'rows', sessionId: msg.sessionId, requestId: msg.requestId, rows });
      break;
    }

    case 'reset': {
      const entry = terminals.get(msg.sessionId);
      if (entry) {
        entry.term.reset();
        writeQueues.delete(msg.sessionId);
      }
      break;
    }

    case 'destroy': {
      const entry = terminals.get(msg.sessionId);
      if (entry) {
        await _awaitQueueBounded(msg.sessionId);
        entry.term.dispose();
        terminals.delete(msg.sessionId);
        writeQueues.delete(msg.sessionId);
      }
      break;
    }
  }
}

function dispatchHeadlessMessage(msg) {
  const sessionId = msg && msg.sessionId;
  if (!sessionId) {
    handleMessage(msg).catch((err) => {
      try {
        postMessage({
          type: 'worker-error',
          sessionId: '',
          message: err && err.message ? err.message : String(err),
        });
      } catch {}
    });
    return;
  }
  enqueueSessionOp(sessionId, () => handleMessage(msg));
}

function bindHeadlessTerminalService(options = {}) {
  if (typeof options.postMessage !== 'function') {
    throw new Error('bindHeadlessTerminalService requires postMessage');
  }
  postMessage = options.postMessage;
  if (typeof options.onMessage === 'function') options.onMessage(dispatchHeadlessMessage);
  return {
    handleMessage: dispatchHeadlessMessage,
    close() {
      for (const [sessionId, entry] of terminals.entries()) {
        try { entry.term.dispose(); } catch {}
        terminals.delete(sessionId);
      }
      writeQueues.clear();
      opQueues.clear();
    },
  };
}

module.exports = {
  bindHeadlessTerminalService,
  handleMessage: dispatchHeadlessMessage,
};
