/**
 * SSH protocol runtime for connector tools (`protocol: ssh`).
 *
 * Drives the system `ssh` binary through a PTY (node-pty) so it can
 * handle interactive flows the plain `shell` protocol can't: password
 * auth and mid-command confirmations like `(y/N)`. Built for network
 * devices — e.g. FortiNAC `execute restore image scp …` which prompts
 * twice for `y` then streams a multi-minute restore before rebooting.
 *
 * Declarative, expect-style: the manifest's `ssh` block says what to
 * send, what to auto-answer, the success/failure markers, and which
 * regexes to capture from the transcript. Nothing here is FortiNAC-
 * specific.
 *
 * Safety: connectors are user-installed. An ssh-protocol tool can run
 * arbitrary remote commands — review at install time. The password is
 * fed silently (ssh doesn't echo it) so it never lands in the captured
 * transcript; we also never log it.
 */

import type { ConnectorTool, SshSpec } from '../../connectors/types';
import { expandAllTokens } from '../../plugins/templates';
import * as pty from 'node-pty';

export interface SshProtocolArgs {
  tool: ConnectorTool;
  settings: Record<string, any>;
  args: Record<string, any>;
}

export interface SshProtocolResult {
  content: string;
  is_error?: boolean;
}

const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_TIMEOUT_MS = 280_000;
const MAX_OUTPUT_BYTES = 24 * 1024;

function truncate(s: string): string {
  const buf = Buffer.from(s, 'utf-8');
  if (buf.byteLength <= MAX_OUTPUT_BYTES) return s;
  // Keep the tail — the interesting markers (done/reboot) are at the end.
  return `(…truncated, total ${buf.byteLength} bytes)\n` +
    buf.subarray(buf.byteLength - MAX_OUTPUT_BYTES).toString('utf-8');
}

function rx(pattern: string | undefined): RegExp | null {
  if (!pattern) return null;
  try { return new RegExp(pattern, 'i'); } catch { return null; }
}

/**
 * Resolve what to actually type for an auto-answer. If the rule's value is
 * the intent `yes`/`no`, pick the token the prompt itself offers — `(yes/no)`
 * → `yes`/`no`, otherwise `y`/`n`. We always send an EXPLICIT token (never
 * rely on the prompt's default: `(y/N)` defaults to N, so "continue" must
 * send `y` outright). Any other value is sent literally.
 */
function resolveAnswer(send: string, promptChunk: string): string {
  const intent = String(send || '').trim().toLowerCase();
  if (intent !== 'yes' && intent !== 'no') return send; // literal passthrough
  const offersWords = /\byes\s*\/\s*no\b/i.test(promptChunk);
  if (intent === 'yes') return offersWords ? 'yes' : 'y';
  return offersWords ? 'no' : 'n';
}

export async function runSsh({ tool, settings, args }: SshProtocolArgs): Promise<SshProtocolResult> {
  const specRaw = tool.ssh;
  if (!specRaw) return { content: 'ssh tool missing `ssh` block', is_error: true };

  const exp = (s: string | undefined) => (s == null ? '' : expandAllTokens(String(s), settings, args));

  const spec: SshSpec = specRaw;

  // Resolve connection params: chat arg > connector setting > literal in
  // the ssh block > built-in default. (IP comes from chat; port/user/
  // password fall back to the connector's saved defaults.)
  const pickConn = (
    argKeys: string[], settingKey: string, specVal: unknown, dflt: string, secret: boolean,
  ): string => {
    for (const k of argKeys) {
      const v = args?.[k];
      if (v != null && String(v) !== '') return secret ? String(v) : String(v).trim();
    }
    const sv = settings?.[settingKey];
    if (sv != null && String(sv) !== '') return secret ? String(sv) : String(sv).trim();
    if (specVal != null && specVal !== '') {
      const r = exp(String(specVal));
      if (r && !r.includes('{')) return secret ? r : r.trim();  // skip unresolved templates
    }
    return dflt;
  };
  const host = pickConn(['host'], 'host', spec.host, '', false);
  const port = pickConn(['port'], 'port', spec.port, '22', false);
  const user = pickConn(['username', 'user'], 'username', spec.user, '', false);
  const password = pickConn(['password'], 'password', spec.password, '', true);
  if (!host) return { content: 'ssh: host is required (pass it from chat, e.g. host=10.15.52.152)', is_error: true };
  if (!user) return { content: 'ssh: user is required (pass username, or set a connector default)', is_error: true };

  const commands = (spec.commands || []).map((c) => exp(c));
  const autoAnswer = (spec.auto_answer || []).map((r) => ({ re: rx(r.match), send: exp(r.send) }));
  const promptRe = rx(spec.prompt_regex) || /[#$>]\s*$/;
  const doneRe = rx(spec.done_when);
  const failRe = rx(spec.fail_when);
  const passwordRe = /password:\s*$/i;
  // timeout_sec is templated so tools can expose it as an arg
  // (e.g. nac.run_command for long-running `diagnose host list all`).
  const rawTimeout = spec.timeout_sec != null ? exp(String(spec.timeout_sec)) : '';
  const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(2_000, Number(rawTimeout || 0) * 1000 || DEFAULT_TIMEOUT_MS));

  const sshArgs = [
    '-tt',                                       // force PTY for interactive prompts
    '-p', port,
    '-o', 'StrictHostKeyChecking=accept-new',    // no yes/no host-key prompt
    '-o', 'UserKnownHostsFile=/dev/null',        // don't pollute known_hosts
    '-o', 'GlobalKnownHostsFile=/dev/null',
    '-o', 'ConnectTimeout=15',
    '-o', 'NumberOfPasswordPrompts=2',
    `${user}@${host}`,
  ];

  return new Promise<SshProtocolResult>((resolve) => {
    let term: pty.IPty;
    try {
      term = pty.spawn('ssh', sshArgs, {
        name: 'xterm-color',
        cols: 200, rows: 50,
        cwd: process.env.HOME || process.cwd(),
        env: process.env as Record<string, string>,
      });
    } catch (e) {
      return resolve({ content: `ssh spawn failed: ${(e as Error).message}`, is_error: true });
    }

    let full = '';
    let cmdIndex = 0;
    let pwSent = 0;
    let settled = false;
    const captured: Record<string, string> = {};

    const finish = (is_error: boolean, note: string) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      try { term.kill(); } catch {}
      // Run captures over the full transcript.
      if (spec.capture) {
        for (const [name, pat] of Object.entries(spec.capture)) {
          const m = full.match(rx(pat) || /$^/);
          if (m && m[1] != null) captured[name] = m[1];
        }
      }
      const payload = {
        ok: !is_error,
        note,
        ...(Object.keys(captured).length ? { captured } : {}),
        output_tail: truncate(full).slice(-4000),
      };
      resolve({ content: JSON.stringify(payload), is_error });
    };

    const timer = setTimeout(() => finish(true, `timed out after ${timeoutMs / 1000}s`), timeoutMs);

    term.onData((chunk: string) => {
      full += chunk;
      // 1) success / failure markers (check on a trailing window so a
      //    marker split across chunks still matches).
      const tail = full.slice(-2000);
      if (doneRe && doneRe.test(tail)) return finish(false, 'done marker matched');
      if (failRe && failRe.test(tail)) return finish(true, 'failure marker matched');

      // 2) password prompt → feed password silently.
      if (password && passwordRe.test(chunk)) {
        if (pwSent >= 2) return finish(true, 'authentication failed (password rejected)');
        pwSent++;
        term.write(`${password}\r`);
        return;
      }

      // 3) interactive confirmations — resolve the correct token (y/yes/
      //    n/no) from THIS prompt's offered options (intent `yes`/`no`).
      for (const rule of autoAnswer) {
        if (rule.re && rule.re.test(chunk)) {
          term.write(`${resolveAnswer(rule.send, chunk)}\r`);
          return;
        }
      }

      // 4) shell prompt → send the next queued command.
      if (promptRe.test(chunk) && cmdIndex < commands.length) {
        const next = commands[cmdIndex++];
        term.write(`${next}\r`);
        return;
      }
    });

    term.onExit(({ exitCode }) => {
      if (settled) return;
      // Connection closed. Success only if explicitly allowed, or a done
      // marker already landed (covered above). Otherwise treat as error.
      if (spec.success_on_close && cmdIndex >= commands.length) {
        return finish(false, `connection closed (exit ${exitCode})`);
      }
      const sawDone = doneRe ? doneRe.test(full) : false;
      finish(!sawDone, sawDone ? 'done before close' : `connection closed unexpectedly (exit ${exitCode})`);
    });
  });
}
