'use strict';
// Codex TUI state detector.
//
// Codex continuously repaints a full-screen status region. The raw bytes are
// mostly cursor movement / erase sequences, but small printable fragments leak
// through chunk boundaries. Treat "Working" fragments as a busy signal; they
// are the only server-side evidence available while the session tab is hidden.
// Spinner-only redraws still stay ignored so an idle Codex TUI does not keep
// the sidebar stuck on Running.

const { baseDetector, stripAnsi } = require('./base');

const CODEX_STATUS_FRAGMENT_RE = /^[\s\d•◦·∙●○WwOoRrKkIiNnGg]+$/u;
const CODEX_BUSY_STATUS_LINE_RE = /^(?:(?:waiting\s+for\s+background\s+terminal\b.*)|working(?:\s*\([^)]*\))?(?:\s*[•◦·∙●○]\s*esc\s+to\s+interrupt)?|(?:working\s*)?esc\s+to\s+interrupt)$/iu;
const CODEX_BUSY_COMPACT_STATUS_LINE_RE = /^working\s+(?:\d+(?::\d+){0,2}|\d+(?:\.\d+)?\s*(?:ms|s|sec|secs|m|min|mins|h|hr|hrs))$/iu;
const CODEX_BUSY_WORD = 'working';
const CODEX_BUSY_HINT_RE = /esc\s+to\s+interrupt/i;
const CODEX_PROMPT_LINE_RE = /^(?:›|❯|>)\s*(?:\S.*)?$/;
const CODEX_FOOTER_LINE_RE = /^(?:gpt|claude|codex|gemini|deepseek|kimi|moonshot|qwen|llama|mistral)[\w.+-]*(?:\s+[\w.+-]+){0,8}\s+[·-]\s+(?:~|\/|\.\.?\/)/i;
const CODEX_SKILL_WARNING_RE = /^(?:⚠\s*)?Skipped loading\s+\d+\s+skill\(s\)\s+due to invalid SKILL\.md files\./i;
const CODEX_SKILL_DESCRIPTION_RE = /^\/.*\/SKILL\.md:\s+invalid description:\s+exceeds maximum length of\s+\d+\s+characters$/i;
// An approval/choice widget is pending — NOT a finished turn, even when the
// model footer is still painted at the bottom of the screen.
const CODEX_PENDING_PROMPT_RE = /Would you like to run the following command\?|Press enter to confirm or esc to cancel|tell Codex what to do differently|\b\d+\.\s*Yes\b/i;

function hasCodexBusyStatusFragment(text) {
  if (/waiting\s+for\s+background\s+terminal/i.test(String(text || ''))) return true;
  if (CODEX_BUSY_HINT_RE.test(String(text || ''))) return true;
  const letters = String(text || '').toLowerCase().replace(/[^a-z]/g, '');
  if (letters.length < 3) return false;
  if (letters.includes(CODEX_BUSY_WORD)) return true;
  for (let i = 0; i <= letters.length - 3; i++) {
    const fragment = letters.slice(i, i + 3);
    if (CODEX_BUSY_WORD.includes(fragment)) return true;
  }
  return false;
}

function isCursorAddressedRedraw(data) {
  const s = String(data || '');
  return (
    /\x1b\[[0-9]+;[0-9]+H/.test(s) &&
    /\x1b\[[0-9;?]*K/.test(s)
  ) || /\x1b\[\?2026h/.test(s);
}

function stripAnsiPreserveLines(data) {
  return String(data || '')
    .replace(/\x1b\[[0-9]+;[0-9]+[Hf]/g, '\n')
    .replace(/\x1b\[[0-9]+[ABCD]/g, '\n')
    .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
    .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
    .replace(/\r/g, '\n')
    .replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, '');
}

function printableLines(data) {
  return stripAnsiPreserveLines(data)
    .replace(/\u00a0/g, ' ')
    .split(/\n+/)
    .map(line => line.trim())
    .filter(Boolean);
}

function isCodexPassiveDiagnosticChunk(data) {
  const lines = printableLines(data);
  return !!(lines.length && lines.every(line => (
    CODEX_SKILL_WARNING_RE.test(line) ||
    CODEX_SKILL_DESCRIPTION_RE.test(line)
  )));
}

function isCodexIdlePromptRedraw(data) {
  if (!isCursorAddressedRedraw(data)) return false;
  const lines = printableLines(data);
  if (!lines.length) return false;
  const compact = lines.join(' ');
  // A live busy status line ("Working (5s) • esc to interrupt") means still
  // working — not an idle composer. Use the PRECISE per-line busy-status check
  // (same as detectTurnFinished), NOT the loose 3-letter fragment matcher: this
  // classifier already requires a full composer frame (prompt + footer), so a
  // coalesced full-screen repaint carries the conversation history above the
  // composer, and the loose matcher trips on ordinary words (coding / going /
  // rendering / work → wor/ork/rki/ing). That false-busy match mis-scored the
  // idle repaint as active and flipped idle Codex sessions to "Running".
  if (lines.some(line => textHasCodexBusyStatus(line))) return false;
  const hasPrompt = lines.some(line => CODEX_PROMPT_LINE_RE.test(line)) ||
    /(?:^|\s)(?:›|❯|>)\s*\S/.test(compact);
  if (!hasPrompt) return false;
  const hasFooter = lines.some(line => CODEX_FOOTER_LINE_RE.test(line)) ||
    /\b(?:gpt-[\w.-]+|claude|sonnet|opus|haiku|deepseek|gemini|qwen|llama|mistral|kimi|moonshot)\b/i.test(compact) &&
      /(?:\b(?:x?high|medium|low|fast)\b|\s[-·]\s|[~/])/.test(compact);
  return hasFooter;
}

// Positive "the turn is over" signal: Codex has repainted its ready composer
// (prompt line + model footer) with NO live "Working… esc to interrupt" status
// line. Unlike detectWaitingInput (which means Needs-You), this means the agent
// finished and is idle. Server uses it to close the active turn so a finished
// Codex session settles to Idle instead of riding the long open-turn hold.
// Operates on viewport text (already stripped) or a raw ANSI tail.
function detectTurnFinished(text) {
  const raw = String(text || '');
  if (!raw.trim()) return false;
  if (CODEX_PENDING_PROMPT_RE.test(raw)) return false;
  // printableLines preserves line structure (base.stripAnsi strips newlines, so
  // a whole-buffer busy check would miss the status line). Check busy per line.
  const lines = printableLines(raw);
  if (!lines.length) return false;
  // A live busy status line ("Working (5s) • esc to interrupt") means still
  // working, even though the composer is also on screen.
  if (lines.some(line => textHasCodexBusyStatus(line))) return false;
  const hasPrompt = lines.some(line => CODEX_PROMPT_LINE_RE.test(line));
  if (!hasPrompt) return false;
  return lines.some(line => CODEX_FOOTER_LINE_RE.test(line));
}

// The unambiguous live Codex approval/confirm widgets — the actual interactive
// prompt text, distinct from prose the agent may print. Kept narrow on purpose
// (hide-unless-confident): the run-command question and the confirm footer are
// real widget chrome; a numbered Yes/No PAIR is a menu. We deliberately do NOT
// fire on "tell Codex/the model what to do differently" alone, since that line
// also appears in the passive "Conversation interrupted" message after an Esc.
const CODEX_RUN_APPROVAL_RE = /Would you like to run the following command\?/i;
const CODEX_CONFIRM_WIDGET_RE = /Press enter to confirm or esc to cancel/i;
const CODEX_YESNO_MENU_RE = /\b1\.\s*Yes\b[\s\S]{0,80}\b2\.\s*No\b/i;

// Positive "the agent is blocked at a live approval/choice widget" signal.
// Returns {reason:'approval'|'choice'} ONLY when a real widget is in the active
// prompt region (bottom of the viewport) and the agent is not mid-work. Returns
// null otherwise — an idle/finished composer is reported by detectTurnFinished,
// not here. Operates on viewport text or a raw ANSI tail.
function detectWaitingInput(text) {
  const raw = String(text || '');
  if (!raw.trim()) return null;
  const lines = printableLines(raw);
  if (!lines.length) return null;
  // Still working ("Working (5s) • esc to interrupt") → not waiting on the user.
  if (lines.some(line => textHasCodexBusyStatus(line))) return null;
  const tail = lines.slice(-10).join('\n');
  if (CODEX_RUN_APPROVAL_RE.test(tail)) return { reason: 'approval' };
  if (CODEX_CONFIRM_WIDGET_RE.test(tail) || CODEX_YESNO_MENU_RE.test(tail)) return { reason: 'choice' };
  return null;
}

function isCodexStatusRedraw(data) {
  const stripped = stripAnsi(data).trim();
  return (
    stripped.length <= 160 &&
    (CODEX_STATUS_FRAGMENT_RE.test(stripped) ||
      textHasCodexBusyStatus(stripped)) &&
    isCursorAddressedRedraw(data)
  );
}

function normalizeCodexStatusLine(text) {
  return String(text || '').replace(/^[\s•◦·∙●○]+/u, '').trim();
}

function isCodexBusyStatusLine(text) {
  const compact = normalizeCodexStatusLine(text);
  return CODEX_BUSY_STATUS_LINE_RE.test(compact) ||
    CODEX_BUSY_COMPACT_STATUS_LINE_RE.test(compact);
}

function textHasCodexBusyStatus(text) {
  const clean = stripAnsi(text).replace(/\r/g, '\n');
  return clean.split('\n').some((line) => isCodexBusyStatusLine(line));
}

// The agent is mid-turn when its "Working… • esc to interrupt" status line (or a
// background-terminal wait) is on screen. server.js's generating-gate uses this to
// suppress the idle-notify so a long, output-quiet Codex turn whose composer matches
// a global prompt pattern is not mislabeled "Needs You". Same signal Codex's own
// detectWaitingInput/detectTurnFinished already trust.
function detectActivelyGenerating(text) {
  // Per line: base.stripAnsi (inside textHasCodexBusyStatus) drops newlines, so a
  // whole-buffer call would miss the status line — mirror detectWaitingInput.
  return printableLines(text).some((line) => textHasCodexBusyStatus(line));
}

module.exports = {
  ...baseDetector,
  id: 'codex',
  // Codex's ratatui status frames arrive in bursts. A short Claude-style
  // debounce lets the sidebar bounce between Running and Waiting/Idle while the
  // terminal still says "Working". Keep the busy state stable long enough for
  // multiple server heartbeats to confirm or renew it, while explicit
  // approval/choice prompts still bypass this elsewhere.
  idleDebounceMs: 15000,

  isActiveChunk(data) {
    if (!baseDetector.isActiveChunk(data)) return false;
    if (isCodexIdlePromptRedraw(data) || isCodexPassiveDiagnosticChunk(data)) return false;
    if (isCodexStatusRedraw(data)) {
      // Tiny status-region redraws (≤160 chars of WORKING-alphabet content):
      // keep the LOOSE fragment matcher — "Working" routinely splits across
      // chunk boundaries ("king", "Wo7") and these fragments are the only
      // busy signal while the tab is hidden. The alphabet pre-filter in
      // isCodexStatusRedraw bounds the false-positive surface.
      return hasCodexBusyStatusFragment(stripAnsi(data).trim());
    }
    if (isCursorAddressedRedraw(data)) {
      // Positive-evidence rule: a cursor-addressed frame/region repaint is
      // agent work ONLY when a live busy-status line is in it (strict
      // per-line match — printableLines preserves the row structure that
      // stripAnsi would flatten). Everything else — MCP startup status,
      // welcome animation, update nudges, the history half of a split
      // full-frame repaint — is TUI chrome an IDLE codex paints too, and one
      // mis-scored chunk holds the sidebar on "Running" for the whole 15s
      // codex hold. Genuine work keeps its busy row on screen and
      // repainting, so per-chunk strictness costs nothing (split fragments
      // are reassembled by the server's rolling busy-evidence buffer).
      return detectActivelyGenerating(data);
    }
    return true;
  },
  isStatusOnlyChunk(data) {
    return isCodexStatusRedraw(data) ||
      isCodexIdlePromptRedraw(data) ||
      isCodexPassiveDiagnosticChunk(data) ||
      (isCursorAddressedRedraw(data) && !detectActivelyGenerating(data));
  },
  isBusyStatusChunk(data) {
    return isCodexStatusRedraw(data) && textHasCodexBusyStatus(data);
  },
  hasCodexBusyStatusFragment,
  textHasCodexBusyStatus,
  isCodexIdlePromptRedraw,
  detectTurnFinished,
  detectWaitingInput,
  detectActivelyGenerating,
  _test: {
    printableLines,
    isCodexPassiveDiagnosticChunk,
  },
};
