/**
 * GitLab SSH 2FA verifier.
 *
 * Wraps `ssh git@<host> 2fa_verify` in a node-pty session so a UI-supplied
 * OTP can be fed without the user dropping into a terminal. After success
 * GitLab marks the SSH key 2FA-cleared for ~1h, so subsequent `git push`
 * via SSH (and Forge pipelines that push) work without prompting.
 *
 * Host comes from the installed `gitlab` connector's `config.base_url` —
 * we just take the hostname. If the user has a non-default SSH endpoint
 * (different port / user) we'd need a per-connector setting; left as a
 * follow-up.
 */

import * as pty from 'node-pty';
import { getInstalledConnector } from '../connectors/registry';

const SSH_USER = 'git';
const VERIFY_TIMEOUT_MS = 60_000;
const OTP_RE = /^\d{6,8}$/;

export interface VerifyResult {
  ok: boolean;
  verifiedAt?: string;
  host?: string;
  error?: string;
  transcript?: string;
}

/**
 * Resolve the SSH hostname from the gitlab connector config. Returns null
 * if gitlab isn't installed or its base_url is empty.
 */
export function getGitlabSshHost(): string | null {
  const cfg = getInstalledConnector('gitlab')?.config as
    | { base_url?: string }
    | undefined;
  const url = (cfg?.base_url || '').trim();
  if (!url) return null;
  try {
    const u = new URL(url);
    return u.hostname || null;
  } catch {
    // Tolerate base_url without scheme
    const m = url.match(/^([^/]+)/);
    return m?.[1] || null;
  }
}

/**
 * Run `ssh git@<host> 2fa_verify`, feed the OTP, return whether the host
 * said OK. Never throws — failure modes are returned via `{ok:false, error}`.
 */
export async function verifyGitlab2FA(otp: string): Promise<VerifyResult> {
  const cleaned = String(otp || '').replace(/\s+/g, '');
  if (!OTP_RE.test(cleaned)) {
    return { ok: false, error: 'OTP must be 6–8 digits' };
  }
  const host = getGitlabSshHost();
  if (!host) {
    return { ok: false, error: 'GitLab connector not configured — set base_url first' };
  }

  return new Promise<VerifyResult>((resolve) => {
    let buf = '';
    let otpSent = false;
    let resolved = false;
    const finish = (r: VerifyResult) => {
      if (resolved) return;
      resolved = true;
      try { proc.kill(); } catch { /* already dead */ }
      clearTimeout(killTimer);
      clearTimeout(fallbackTimer);
      if (!r.ok) {
        // Log to forge.log — transcript shouldn't be in UI permanently,
        // but ops need it for first-time diagnostics.
        console.warn(
          `[gitlab-2fa] verify failed on ${host}: ${r.error}\n` +
          `--- transcript ---\n${r.transcript || '(empty)'}\n------------------`,
        );
      }
      resolve(r);
    };
    const sendOtp = (why: string) => {
      if (otpSent) return;
      otpSent = true;
      console.log(`[gitlab-2fa] sending OTP (${why})`);
      try { proc.write(cleaned + '\n'); } catch { /* proc died */ }
    };

    const args = [
      '-T',                                  // disable pseudo-TTY allocation we control PTY
      '-o', 'BatchMode=no',
      '-o', 'StrictHostKeyChecking=accept-new',
      '-o', 'ConnectTimeout=30',           // VPN first-packet can be slow
      '-o', 'NumberOfPasswordPrompts=0',     // we use SSH key, not password
      '-o', 'PreferredAuthentications=publickey',
      `${SSH_USER}@${host}`,
      '2fa_verify',
    ];

    const proc = pty.spawn('ssh', args, {
      name: 'xterm-256color',
      cols: 120,
      rows: 30,
      env: process.env as Record<string, string>,
    });

    // Fallback: GitLab versions / forks vary in prompt wording (some
    // print "OTP:", some "2FA token:", some include welcome banners
    // before the prompt). If we haven't sent OTP within 2s of any
    // output, send anyway — gitlab-shell waits silently for input.
    const fallbackTimer = setTimeout(() => sendOtp('1.5s fallback'), 1500);

    const killTimer = setTimeout(() => {
      finish({
        ok: false,
        host,
        error: `Timed out after ${VERIFY_TIMEOUT_MS / 1000}s — last output:\n${tail(buf)}`,
        transcript: buf,
      });
    }, VERIFY_TIMEOUT_MS);

    proc.onData((chunk) => {
      buf += chunk;
      // Strip ANSI escape sequences before matching prompt / markers.
      const visible = stripAnsi(buf);

      // Eager send: as soon as we see "OTP" / "code" / "token" / a ":"
      // line tail, push the digits. The 1.5s fallback above covers
      // versions that print only the prompt with no easy keyword.
      if (!otpSent && /\b(otp|2fa|two-?factor|verification|code|token)\b/i.test(visible)) {
        sendOtp('keyword match');
      } else if (!otpSent && /:\s*$/.test(visible)) {
        sendOtp('colon-prompt');
      }

      if (/\bOK\b/i.test(visible) && /two-?factor|successful|verified/i.test(visible)) {
        finish({
          ok: true,
          verifiedAt: new Date().toISOString(),
          host,
          transcript: visible,
        });
      }
    });

    proc.onExit(({ exitCode }) => {
      if (resolved) return;
      const visible = stripAnsi(buf);
      const lower = visible.toLowerCase();
      let error = `ssh exited (code ${exitCode})`;
      if (lower.includes('invalid otp') || lower.includes('invalid code') || lower.includes('otp validation failed')) {
        error = 'OTP rejected by GitLab — wrong code or expired';
      } else if (lower.includes('permission denied') || lower.includes('publickey')) {
        error = `SSH key not authorized on ${host} — add your key to GitLab → SSH Keys first`;
      } else if (
        lower.includes('connection refused') ||
        lower.includes('connection timed out') ||
        lower.includes('operation timed out') ||           // macOS BSD ssh wording
        lower.includes('network is unreachable') ||
        lower.includes('no route to host')
      ) {
        error = `Can't reach ${host}:22 — are you on the corporate VPN?`;
      } else if (lower.includes('host key verification failed')) {
        error = `Host key changed for ${host} — run 'ssh-keygen -R ${host}' and try again`;
      } else if (lower.includes('command rejected') || lower.includes('disallowed command') || lower.includes('not allowed')) {
        error = `GitLab refused the '2fa_verify' command on ${host} — older GitLab without 2FA-over-SSH? Run it manually first to confirm.`;
      } else if (visible.trim()) {
        // Surface the last non-empty line so the user sees the real
        // reason. Strip any pure-digit lines first — those are the OTP
        // we echoed back through the PTY, not a real error.
        const tailText = tail(visible)
          .split('\n')
          .filter(l => !/^\s*\d{4,8}\s*$/.test(l))
          .join('\n')
          .trim();
        error = tailText
          ? `ssh exited (code ${exitCode}): ${tailText}`
          : `ssh exited (code ${exitCode})`;
      }
      finish({ ok: false, host, error, transcript: visible });
    });
  });
}

function stripAnsi(s: string): string {
  // Strip CSI sequences + lone carriage returns that mess up regex.
  return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r(?!\n)/g, '');
}

function tail(s: string, lines = 6): string {
  const arr = s.split('\n').filter(l => l.length > 0);
  return arr.slice(-lines).join('\n');
}

/**
 * GitLab's SSH 2FA grace window. Used by the UI to render "X min left".
 * Conservatively 55 min instead of the documented 60 — gives the user
 * time to re-verify before push starts failing.
 */
export const GITLAB_2FA_VALID_MS = 55 * 60 * 1000;

export function secondsLeft(verifiedAt: string | undefined): number {
  if (!verifiedAt) return 0;
  const t = Date.parse(verifiedAt);
  if (!Number.isFinite(t)) return 0;
  return Math.max(0, Math.floor((t + GITLAB_2FA_VALID_MS - Date.now()) / 1000));
}

// ─── Keystroke fallback (macOS) ────────────────────────────────────────
//
// When direct ssh is blocked by per-process VPN filtering (FortiClient),
// the only viable automation is "type into the user's already-trusted
// Terminal window" via System Events. The typed `ssh ... 2fa_verify`
// then runs as a direct child of the trusted zsh PID — same as if the
// user typed it themselves — so the VPN tunnel applies and the host
// is reachable.
//
// Requires:
//   - macOS
//   - osascript on PATH (always shipped)
//   - Accessibility permission for the parent (node/Forge) — first run
//     prompts; if denied, error message guides the user to System
//     Settings → Privacy → Accessibility
//   - At least one Terminal.app window with the user's trusted zsh
//
// Privacy notes:
//   - Command line starts with a SPACE so users with `setopt HIST_IGNORE_SPACE`
//     keep it out of shell history.
//   - The OTP itself is sent as a separate keystroke event after the
//     ssh process prompts — it goes into ssh, not the shell, so it
//     never lands in history regardless.
//
// Caveats:
//   - Terminal must be reachable to AppleScript (running, at least one
//     window). If none, the script errors with -1719 (no window).
//   - Brings Terminal to front for ~5 seconds — disruptive if the user
//     was typing somewhere else.
//   - We scrape `contents of front window` to detect outcome, looking
//     for a unique marker we emit. Output that contains the marker
//     wrongly (very unlikely) would mis-detect.

import { spawn } from 'node:child_process';

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();
  });
}

/**
 * Drive `ssh git@host 2fa_verify` by typing into the user's frontmost
 * Terminal window. The ssh process becomes a direct child of the
 * trusted zsh, getting FortiClient VPN access automatically.
 */
export async function verifyGitlab2FAViaKeystroke(otp: string): Promise<VerifyResult> {
  if (process.platform !== 'darwin') {
    return { ok: false, error: 'Keystroke mode is macOS-only.' };
  }
  const cleaned = String(otp || '').replace(/\s+/g, '');
  if (!OTP_RE.test(cleaned)) {
    return { ok: false, error: 'OTP must be 6–8 digits' };
  }
  const host = getGitlabSshHost();
  if (!host) {
    return { ok: false, error: 'GitLab connector not configured — set base_url first' };
  }

  const tag = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
  const markOk = `FORGE_2FA_OK_${tag}`;
  const markFail = `FORGE_2FA_FAIL_${tag}`;
  // Leading space → HIST_IGNORE_SPACE keeps it out of history.
  // Two distinct atomic markers so partial-render races can't mis-
  // identify outcome (Terminal might render the marker line in
  // pieces; checking for one short atomic token sidesteps that).
  const cmd = ` ssh git@${host} 2fa_verify && echo "${markOk}" || echo "${markFail}"`;

  // AppleScript escape: ", \ → \", \\.
  const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  // Use clipboard + cmd+v instead of keystroke-char-by-char.
  // keystroke drops characters on slow/busy targets (user saw
  // 'sshgitdops-git106...' — spaces and '@' eaten). Clipboard paste
  // lands the entire string atomically.
  // We save the user's previous clipboard and restore at the end.
  const script = [
    'set savedClip to ""',
    'try',
    '  set savedClip to (the clipboard as text)',
    'end try',
    'tell application "Terminal" to activate',
    'delay 0.4',
    // Snapshot buffer BEFORE we paste so the prompt check only looks
    // at fresh output. Scrollback from prior runs contains old "OTP"
    // strings that would otherwise match immediately and we'd paste
    // the OTP before ssh has even printed its prompt.
    'tell application "Terminal"',
    '  set baseline to (contents of front window as text)',
    'end tell',
    'set baseLen to count of baseline',
    // Paste the ssh command
    `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',
    // Wait for the OTP prompt before pasting the code.
    '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',
    // Only look at content added since we started — strip the baseline
    // prefix. Defensive guard: if Terminal scrolls and the baseline no
    // longer matches a prefix (rare), fall back to last-1000 chars.
    '    set fresh to txt',
    '    if (count of txt) > baseLen then',
    '      try',
    '        set fresh to text (baseLen + 1) thru -1 of txt',
    '      end try',
    '    else if (count of txt) > 1000 then',
    '      set fresh to text -1000 thru -1 of txt',
    '    end if',
    `    if fresh contains "OTP" or fresh contains "Token" or fresh contains "verification" or fresh contains "code:" 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 OTP prompt detected after 20s — VPN slow or ssh failed before prompting"',
    'end if',
    'delay 0.3',
    // Paste the OTP
    `set the clipboard to "${esc(cleaned)}"`,
    'delay 0.1',
    'tell application "System Events"',
    '  keystroke "v" using {command down}',
    '  delay 0.15',
    '  key code 36',
    'end tell',
    '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"',
    '      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',
    // Restore user's clipboard.
    'set the clipboard to savedClip',
    'return verdict & "|" & tail',
  ].join('\n');

  const r = await runAppleScript(script, 45_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 {
        ok: false,
        host,
        error:
          'Forge needs Accessibility permission to type into Terminal. ' +
          'Open System Settings → Privacy & Security → Accessibility, ' +
          'add the process running Forge (node) and turn it on, then retry.',
      };
    }
    if (/-1719|no window|application isn.t running/i.test(err)) {
      return {
        ok: false,
        host,
        error: 'Terminal.app has no open window. Open a Terminal window (Cmd+N) and retry.',
      };
    }
    return { ok: false, host, error: `osascript failed (code ${r.code}): ${err || out}` };
  }
  // out format: "ok|" or "fail|<tail>" or "timeout|" or "error:<msg>"
  const [verdict, ...rest] = out.split('|');
  const tail = rest.join('|');
  if (verdict === 'ok') {
    return {
      ok: true,
      verifiedAt: new Date().toISOString(),
      host,
      transcript: tail,
    };
  }
  if (verdict === 'fail') {
    return { ok: false, host, error: 'GitLab rejected the OTP', transcript: tail };
  }
  if (verdict.startsWith('error:')) {
    return { ok: false, host, error: verdict.slice('error:'.length) };
  }
  if (verdict === 'noprompt') {
    return {
      ok: false,
      host,
      error: tail || 'No OTP prompt appeared in your Terminal — ssh may have failed to connect',
    };
  }
  return {
    ok: false,
    host,
    error: 'Timed out reading Terminal output — did the command actually run?',
    transcript: tail,
  };
}
