'use strict';

// Server-side detection and keystroke planning for interactive terminal choice
// menus (Claude/Codex/etc. numbered selectors). This mirrors the phone client's
// detection in public/m/app.js (terminalChoiceOptionFromLine / extractLiveTerminalChoice)
// so the two agree on what the menu is — but here it runs against the authoritative
// headless xterm buffer and produces the keystrokes that actually drive the agent.
//
// Why this exists: phone selection taps used to be routed through the free-form
// reply pipeline (session.send_message), which is blocked/swallowed when the agent
// is parked on a menu. CTM already drives live prompts elsewhere by writing keys to
// the PTY (Esc, prompt-history arrows, approval keystrokes). This module is the
// menu-aware equivalent: read the live options, find the highlight, and move it to
// the tapped option with arrow keys + Enter (with a number-key fallback).

const SELECTOR_RE = /^(?:›|❯|➜|▶|▸)\s*(.+)$/;
const NUMBERED_RE = /^(\d{1,2})[.)]\s+(.+)$/;
const CHECKBOX_PREFIX_RE = /^\[\s*([ xX✓✔])\s*\]\s*/;
const CHECKBOX_GLYPH_RE = /^([☐□☑☒✓✔])\s*/;
const SUBMIT_CONTROL_RE = /(?:^|[\s✓✔])submit\s*(?:→|$|\b)/i;

function stripAnsi(value) {
  return String(value || '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
}

function normalizeLine(line) {
  return stripAnsi(String(line || '')).replace(/\u00a0/g, " ").replace(/\s+$/, '');
}

function checkboxFromLabel(label) {
  const raw = String(label || '').trim();
  let match = raw.match(CHECKBOX_PREFIX_RE);
  if (match) {
    const marker = match[1];
    return {
      checkbox: true,
      checked: /[xX✓✔]/.test(marker),
      label: raw.slice(match[0].length).trim(),
      rawLabel: raw,
    };
  }
  match = raw.match(CHECKBOX_GLYPH_RE);
  if (match) {
    return {
      checkbox: true,
      checked: /[☑☒✓✔]/.test(match[1]),
      label: raw.slice(match[0].length).trim(),
      rawLabel: raw,
    };
  }
  return { checkbox: false, checked: false, label: raw, rawLabel: raw };
}

function fingerprintLabel(label) {
  return checkboxFromLabel(label).label.replace(/\s+/g, ' ').trim();
}

// Parse a single buffer line into a numbered option, capturing whether it is the
// currently highlighted row (prefixed by a selector glyph like ❯).
// A widget rendered inside a box frame prefixes (and suffixes) each row with a
// vertical border ("│ ❯ 1. Moderate │"). Strip surrounding box-frame chars so a
// boxed menu parses like a borderless one — otherwise the leading │ defeats both
// SELECTOR_RE and NUMBERED_RE and the whole AskUserQuestion classifies as `none`,
// stranding a stale approval banner over it.
const BOX_BORDER_PREFIX_RE = /^[│┃║▏▕|]\s*/;
const BOX_BORDER_SUFFIX_RE = /\s*[│┃║▏▕|]$/;

function choiceOptionFromLine(line) {
  const raw = normalizeLine(line).trim().replace(BOX_BORDER_PREFIX_RE, '').replace(BOX_BORDER_SUFFIX_RE, '').trim();
  if (!raw) return null;
  const selector = raw.match(SELECTOR_RE);
  const text = selector ? selector[1].trim() : raw;
  const numbered = text.match(NUMBERED_RE);
  if (!numbered) return null;
  const parsed = checkboxFromLabel(numbered[2]);
  const label = parsed.label;
  if (!label) return null;
  return {
    value: numbered[1],
    label,
    rawLabel: parsed.rawLabel,
    checkbox: parsed.checkbox,
    checked: parsed.checked,
    selected: !!selector,
    text,
  };
}

// Window (in buffer rows) we scan above/below the highlighted row for sibling
// options. Claude's AskUserQuestion widget puts a description line (sometimes
// wrapped to two) under every option plus a divider before the trailing
// "Chat about this", so a tight window drops real options. 12 comfortably spans
// a 5-6 option menu with descriptions while staying local to the menu block.
// Mirrored by terminalChoiceOptionsNear in public/m/app.js.
const OPTION_SCAN_RADIUS = 12;
function optionsNear(lines, selectedRow) {
  const options = [];
  const start = Math.max(0, selectedRow - OPTION_SCAN_RADIUS);
  const end = Math.min(lines.length - 1, selectedRow + OPTION_SCAN_RADIUS);
  for (let row = start; row <= end; row += 1) {
    const option = choiceOptionFromLine(lines[row]);
    if (!option) continue;
    options.push({ ...option, row });
  }
  return options;
}

// A row qualifies as the menu's selected row when it is a highlighted numbered
// option with at least one more numbered option nearby (so a lone "❯ 1. foo" in
// prose is not mistaken for a menu).
function isSelectorRow(lines, row) {
  const option = choiceOptionFromLine(lines[row]);
  if (!option || !option.selected) return false;
  return optionsNear(lines, row).length >= 2;
}

// Deterministic short fingerprint over the option set (value+label, in order).
// Mirrored byte-for-byte in public/m/app.js so client and server agree on whether
// the menu the user tapped still matches the live menu. FNV-1a (no Node crypto, so
// the browser can compute the identical value).
function choiceFingerprint(options) {
  const basis = (Array.isArray(options) ? options : [])
    .map((option) => `${option.value}:${fingerprintLabel(option.label || option.rawLabel || '')}`)
    .join('|');
  let hash = 0x811c9dc5;
  for (let i = 0; i < basis.length; i += 1) {
    hash ^= basis.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, '0');
}

function hasSubmitControl(lines) {
  return (Array.isArray(lines) ? lines : []).some((line) => SUBMIT_CONTROL_RE.test(normalizeLine(line)));
}

// Extract the active choice menu from raw terminal text. Returns null when no
// numbered selection menu is present. `selectedIndex` is the ordinal of the
// highlighted option within `options` (not a buffer row), which is what the
// keystroke planner navigates against.
function extractNumberedChoice(lines) {
  let selectedRow = -1;
  for (let row = 0; row < lines.length; row += 1) {
    if (isSelectorRow(lines, row)) { selectedRow = row; break; }
  }
  if (selectedRow < 0) return null;
  const near = optionsNear(lines, selectedRow).sort((a, b) => a.row - b.row);
  if (near.length < 2) return null;

  const seen = new Set();
  const options = [];
  for (const option of near) {
    const key = `${option.value}\n${option.label}`;
    if (seen.has(key)) continue;
    seen.add(key);
    options.push({
      value: option.value,
      label: option.label,
      rawLabel: option.rawLabel,
      checkbox: !!option.checkbox,
      checked: !!option.checked,
      selected: option.selected,
    });
  }
  const selectedIndex = options.findIndex((option) => option.selected);
  const requiresSubmit = options.some((option) => option.checkbox) && hasSubmitControl(lines);
  return {
    kind: requiresSubmit ? 'form' : 'numbered',
    inline: false,
    requiresSubmit,
    submitAvailable: requiresSubmit,
    options,
    selectedValue: options.find((option) => option.selected)?.value || '',
    selectedIndex,
    fingerprint: choiceFingerprint(options),
  };
}

// Inline approval prompts (e.g. Gemini's "Approve? (y/n/always)" or a shell's
// "Overwrite? [y/N]") are un-numbered: there is no navigable list or highlight,
// you answer by typing a shortcut. We detect the parenthesised "/"-separated
// option group near the tail and turn each token into a tappable option whose
// value IS the keystroke to type. The keystroke planner's no-highlight path then
// types that token + Enter — the same thing the per-provider approval engine does.
const INLINE_QUESTION_RE = /(?:approve|proceed|continue|allow|overwrite|apply|replace|confirm|delete|ok to|do you want|would you like|run this|execute)\b[^?\n]*\?\s*[([]\s*([A-Za-z][A-Za-z]*(?:\s*\/\s*[A-Za-z]+)+)\s*[)\]]/i;

const INLINE_TOKEN_LABELS = {
  y: 'Yes',
  yes: 'Yes',
  n: 'No',
  no: 'No',
  a: 'Always allow',
  always: 'Always allow',
  all: 'Allow all',
  s: 'Skip',
  skip: 'Skip',
  q: 'Cancel',
  quit: 'Cancel',
  c: 'Cancel',
  cancel: 'Cancel',
  d: 'Show diff',
  diff: 'Show diff',
};

function inlineTokenLabel(token) {
  const key = token.toLowerCase();
  if (INLINE_TOKEN_LABELS[key]) return INLINE_TOKEN_LABELS[key];
  return token.length === 1 ? token.toUpperCase() : `${token[0].toUpperCase()}${token.slice(1)}`;
}

function extractInlineChoice(lines) {
  // Only consider the active prompt: scan the last few non-empty lines so a
  // scrolled-up, already-answered prompt is never matched.
  const nonEmpty = [];
  for (let i = lines.length - 1; i >= 0 && nonEmpty.length < 6; i -= 1) {
    const text = normalizeLine(lines[i]).trim();
    if (text) nonEmpty.push(text);
  }
  for (const line of nonEmpty) {
    const match = line.match(INLINE_QUESTION_RE);
    if (!match) continue;
    const tokens = match[1].split('/').map((token) => token.trim()).filter(Boolean);
    if (tokens.length < 2) continue;
    const seen = new Set();
    const options = [];
    for (const token of tokens) {
      const value = token.length === 1 ? token.toLowerCase() : token;
      if (seen.has(value)) continue;
      seen.add(value);
      options.push({ value, label: inlineTokenLabel(token), selected: false });
    }
    if (options.length < 2) continue;
    return {
      kind: 'inline',
      inline: true,
      options,
      selectedValue: '',
      selectedIndex: -1,
      fingerprint: choiceFingerprint(options),
    };
  }
  return null;
}

// Extract the active choice prompt from raw terminal text. Returns null when no
// numbered selection menu is present. `selectedIndex` is the ordinal of the
// highlighted option within `options` (not a buffer row), which is what the
// keystroke planner navigates against. Inline prompts report selectedIndex -1 so
// the planner types the option's shortcut instead of navigating.
function extractTerminalChoice(text) {
  const lines = stripAnsi(String(text || '')).split('\n');
  return extractNumberedChoice(lines) || extractInlineChoice(lines);
}

function findTargetOrdinal(options, targetValue, targetLabel) {
  const value = targetValue == null ? '' : String(targetValue).trim();
  const label = String(targetLabel || '').trim();
  if (value) {
    const byValue = options.findIndex((option) => String(option.value) === value);
    if (byValue >= 0) return byValue;
  }
  if (label) {
    const byLabel = options.findIndex((option) => option.label === label);
    if (byLabel >= 0) return byLabel;
  }
  return -1;
}

const ARROW_DOWN = '\x1b[B';
const ARROW_UP = '\x1b[A';
const ARROW_RIGHT = '\x1b[C';
const ENTER = '\r';

// Plan the keystrokes that move the highlight to the tapped option and confirm it.
// Preferred strategy is arrow-relative navigation from the live highlight (works
// across Claude/Codex/un-numbered menus). When the highlight can't be located we
// fall back to typing the option's number — the same thing CTM's approval engine
// does for Claude. The final frame is always Enter; callers may verify the
// highlight landed before sending it.
function planChoiceKeystrokes({ options, selectedIndex, targetValue, targetLabel } = {}) {
  if (!Array.isArray(options) || options.length === 0) return null;
  const targetOrdinal = findTargetOrdinal(options, targetValue, targetLabel);
  if (targetOrdinal < 0) return null;

  const hasHighlight = Number.isInteger(selectedIndex) && selectedIndex >= 0;
  if (!hasHighlight) {
    return {
      strategy: 'number',
      targetOrdinal,
      navFrames: [String(options[targetOrdinal].value)],
      commitFrame: ENTER,
    };
  }

  const delta = targetOrdinal - selectedIndex;
  const arrow = delta >= 0 ? ARROW_DOWN : ARROW_UP;
  const navFrames = [];
  for (let i = 0; i < Math.abs(delta); i += 1) navFrames.push(arrow);
  return {
    strategy: 'arrows',
    targetOrdinal,
    delta,
    navFrames,
    commitFrame: ENTER,
  };
}

// Normalize a label for matching a phone tap against the live menu: strip the
// checkbox marker, collapse whitespace, lowercase. Used by tappedOptionStillPresent.
function normalizeChoiceLabel(label) {
  return checkboxFromLabel(label).label.replace(/\s+/g, ' ').trim().toLowerCase();
}

// Reconcile a phone tap against the live menu when the full-set fingerprint
// disagrees. The phone parses the cleaned terminal tail (dividers/non-content
// rows removed) while the server parses the raw headless grid, so for the SAME
// live menu the two can capture a different trailing-option SET (e.g. the phone
// includes "Chat about this" that the server's window cut off behind a divider).
// A strict fingerprint match false-rejects every such tap as stale. Instead we
// confirm the SPECIFIC tapped option is still present in the live menu by
// value+label — that (together with the post-navigation closed-loop verify in
// the caller) is the real correctness guarantee. Returns false only when the
// tapped option cannot be confirmed, i.e. the menu genuinely changed.
function tappedOptionStillPresent(menu, targetValue, targetLabel) {
  const options = Array.isArray(menu && menu.options) ? menu.options : [];
  if (!options.length) return false;
  const value = targetValue == null ? '' : String(targetValue).trim();
  const label = normalizeChoiceLabel(targetLabel);
  if (value) {
    const byValue = options.find((option) => String(option.value) === value);
    // Same slot AND same text => same option. A label that no longer matches the
    // option at this value means the menu was replaced, so fall through to reject.
    if (byValue) return label ? normalizeChoiceLabel(byValue.label) === label : true;
  }
  // No value match (e.g. the server's window excluded that ordinal): accept only
  // an unambiguous single label match so we never drive the wrong option.
  if (label) {
    return options.filter((option) => normalizeChoiceLabel(option.label) === label).length === 1;
  }
  return false;
}

function planFormSubmitKeystrokes(menu = {}) {
  if (!menu.requiresSubmit && menu.kind !== 'form') return null;
  if (!Array.isArray(menu.options) || !menu.options.some((option) => option.checked)) return null;
  return {
    strategy: 'form-submit',
    frames: [ARROW_RIGHT, ENTER],
  };
}

module.exports = {
  stripAnsi,
  choiceOptionFromLine,
  extractTerminalChoice,
  choiceFingerprint,
  planChoiceKeystrokes,
  planFormSubmitKeystrokes,
  tappedOptionStillPresent,
  ARROW_DOWN,
  ARROW_UP,
  ARROW_RIGHT,
  ENTER,
};
