/**
 * Generic "type a command into the user's frontmost macOS Terminal,
 * optionally feed an OTP at the prompt, scrape outcome" runner.
 *
 * The original use case is GitLab SSH 2FA refresh, but the mechanism
 * is fully tenant-configurable via the wizard template's
 * `_idp.post_login_terminal` array. Each entry is a TerminalSpec.
 *
 * Why this approach exists: corp VPNs (FortiClient) gate the tunnel
 * by PID. Only the user's manually-launched Terminal session is
 * whitelisted, so commands run by Forge-spawned processes don't
 * reach internal hosts. Typing into the frontmost Terminal via
 * AppleScript+System Events makes the command a direct child of
 * that whitelisted PID.
 */

import { spawn } from 'node:child_process';

export interface TerminalSpec {
  name: string;
  command: string;
  needs_otp?: boolean;
  /** Regexes to detect the OTP prompt in fresh Terminal output. */
  prompt_markers?: string[];
  /** Validity window (informational only, surfaced to UI). */
  valid_minutes?: number;
  /** Regex (case-insensitive). If transcript matches, force verdict=fail
   *  even when exit code is 0. Needed when the command prints failure
   *  text to stdout without setting a non-zero exit code (e.g. GitLab
   *  `ssh ... 2fa_verify` on bad OTP). */
  failure_match?: string;
  /** Regex (case-insensitive). If specified and transcript does NOT match
   *  on exit 0, force verdict=fail. Stronger than failure_match — use
   *  when you know the exact success phrase. */
  success_match?: string;
}

export interface TerminalResult {
  name: string;
  ok: boolean;
  /** Resolved command — surfaced so UI can offer "run this manually" on failure. */
  command: string;
  error?: string;
  transcript?: string;
}

const DEFAULT_PROMPT_MARKERS = ['OTP', 'Token', 'verification', 'code:'];

interface AppleScriptResult { stdout: string; stderr: string; code: number | null }

function runAppleScript(script: string, timeoutMs = 60_000): Promise<AppleScriptResult> {
  return new Promise((resolve) => {
    const p = spawn('osascript', [], { stdio: ['pipe', 'pipe', 'pipe'] });
    let stdout = '', stderr = '';
    p.stdout.on('data', (d) => { stdout += d.toString(); });
    p.stderr.on('data', (d) => { stderr += d.toString(); });
    const timer = setTimeout(() => { try { p.kill(); } catch {} }, timeoutMs);
    p.on('close', (code) => { clearTimeout(timer); resolve({ stdout, stderr, code }); });
    p.stdin.write(script);
    p.stdin.end();
  });
}

/**
 * Type `spec.command` into the frontmost Terminal window, optionally
 * paste `otp` when the prompt appears, and look for OK/FAIL marker.
 * Strings are pasted via clipboard (not keystroke char-by-char) to
 * avoid macOS's well-known character-drop bug on long strings.
 */
export async function runTerminalKeystroke(
  spec: TerminalSpec,
  otp?: string,
): Promise<TerminalResult> {
  if (process.platform !== 'darwin') {
    return { name: spec.name, command: spec.command, ok: false, error: 'Keystroke mode is macOS-only.' };
  }
  if (spec.needs_otp) {
    const cleaned = String(otp || '').replace(/\s+/g, '');
    if (!/^\d{6,8}$/.test(cleaned)) {
      return { name: spec.name, command: spec.command, ok: false, error: 'OTP must be 6–8 digits' };
    }
    otp = cleaned;
  }
  const tag = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
  const markOk = `FORGE_TERM_OK_${tag}`;
  const markFail = `FORGE_TERM_FAIL_${tag}`;
  // Leading space → HIST_IGNORE_SPACE keeps it out of shell history.
  // Two atomic markers avoid the partial-render race we hit earlier.
  const cmd = ` ${spec.command} && echo "${markOk}" || echo "${markFail}"`;

  const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  const markers = (spec.prompt_markers && spec.prompt_markers.length
    ? spec.prompt_markers
    : DEFAULT_PROMPT_MARKERS
  ).map(esc);
  const promptCheck = markers.map((m) => `fresh contains "${m}"`).join(' or ');

  const lines: string[] = [
    'set savedClip to ""',
    'try',
    '  set savedClip to (the clipboard as text)',
    'end try',
    'tell application "Terminal" to activate',
    'delay 0.4',
    'tell application "Terminal"',
    '  set baseline to (contents of front window as text)',
    'end tell',
    'set baseLen to count of baseline',
    `set the clipboard to "${esc(cmd)}"`,
    'delay 0.1',
    'tell application "System Events"',
    '  keystroke "v" using {command down}',
    '  delay 0.15',
    '  key code 36',
    'end tell',
  ];

  if (spec.needs_otp) {
    lines.push(
      'set promptDeadline to (current date) + 20',
      'set sawPrompt to false',
      'repeat',
      '  if (current date) > promptDeadline then exit repeat',
      '  delay 0.4',
      '  try',
      '    tell application "Terminal"',
      '      set txt to contents of front window as text',
      '    end tell',
      '    set fresh to txt',
      '    if (count of txt) > baseLen then',
      '      try',
      '        set fresh to text (baseLen + 1) thru -1 of txt',
      '      end try',
      '    end if',
      `    if ${promptCheck} then`,
      '      set sawPrompt to true',
      '      exit repeat',
      '    end if',
      '  end try',
      'end repeat',
      'if not sawPrompt then',
      '  set the clipboard to savedClip',
      '  return "noprompt|no prompt detected after 20s"',
      'end if',
      'delay 0.3',
      `set the clipboard to "${esc(otp!)}"`,
      'delay 0.1',
      'tell application "System Events"',
      '  keystroke "v" using {command down}',
      '  delay 0.15',
      '  key code 36',
      'end tell',
    );
  }

  lines.push(
    'set t0 to current date',
    'set verdict to "timeout"',
    'set tail to ""',
    'repeat',
    '  if ((current date) - t0) > 30 then exit repeat',
    '  delay 0.4',
    '  try',
    '    tell application "Terminal"',
    '      set txt to contents of front window as text',
    '    end tell',
    `    if txt contains "${markOk}" then`,
    '      set verdict to "ok"',
    // Capture tail on OK branch too — caller may regex-check it to
    // override exit-code-based verdict (e.g. GitLab 2fa_verify exits
    // 0 but prints failure text on bad OTP).
    '      if (length of txt) > 400 then',
    '        set tail to text -400 thru -1 of txt',
    '      else',
    '        set tail to txt',
    '      end if',
    '      exit repeat',
    `    else if txt contains "${markFail}" then`,
    '      set verdict to "fail"',
    '      if (length of txt) > 400 then',
    '        set tail to text -400 thru -1 of txt',
    '      else',
    '        set tail to txt',
    '      end if',
    '      exit repeat',
    '    end if',
    '  on error errMsg',
    '    set the clipboard to savedClip',
    '    return "error:" & errMsg',
    '  end try',
    'end repeat',
    'set the clipboard to savedClip',
    'return verdict & "|" & tail',
  );

  const r = await runAppleScript(lines.join('\n'), 60_000);
  const out = (r.stdout || '').trim();
  if (r.code !== 0) {
    const err = (r.stderr || '').trim();
    if (/not authorized.*assistive|accessibility|tcc/i.test(err)) {
      return {
        name: spec.name,
        command: spec.command,
        ok: false,
        error:
          'Accessibility permission required. Open System Settings → ' +
          'Privacy & Security → Accessibility, add node/Forge and enable it.',
      };
    }
    if (/-1719|no window|application isn.t running/i.test(err)) {
      return {
        name: spec.name,
        command: spec.command,
        ok: false,
        error: 'No Terminal window open. Open one and retry.',
      };
    }
    return { name: spec.name, command: spec.command, ok: false, error: `osascript failed (code ${r.code}): ${err || out}` };
  }
  const [verdict, ...rest] = out.split('|');
  const tail = rest.join('|');
  if (verdict === 'ok') {
    // Transcript-based override: some commands report failure via stdout
    // text instead of non-zero exit (e.g. GitLab 2fa_verify).
    if (spec.failure_match) {
      try {
        if (new RegExp(spec.failure_match, 'i').test(tail)) {
          return { name: spec.name, command: spec.command, ok: false, error: 'transcript matched failure pattern', transcript: tail };
        }
      } catch { /* invalid regex — skip */ }
    }
    if (spec.success_match) {
      try {
        if (!new RegExp(spec.success_match, 'i').test(tail)) {
          return { name: spec.name, command: spec.command, ok: false, error: 'transcript did not match success pattern', transcript: tail };
        }
      } catch { /* invalid regex — skip */ }
    }
    return { name: spec.name, command: spec.command, ok: true, transcript: tail };
  }
  if (verdict === 'fail') return { name: spec.name, command: spec.command, ok: false, error: 'command exited non-zero', transcript: tail };
  if (verdict === 'noprompt') return { name: spec.name, command: spec.command, ok: false, error: tail || 'No prompt appeared' };
  if (verdict.startsWith('error:')) return { name: spec.name, command: spec.command, ok: false, error: verdict.slice(6) };
  return { name: spec.name, command: spec.command, ok: false, error: 'timed out reading Terminal' };
}
