/**
 * SSH session core: exec / upload / download primitives.
 *
 * Wraps the `ssh2` client with the security policy decided in Phase 2:
 *   - DNS-pinned socket (preflightAndConnect) — defeats DNS rebinding
 *   - algorithm allowlist (buildAlgorithmsOption)
 *   - closure-capture hostVerifier — sync, no DB writes inside the callback;
 *     verdict is read after the handshake fails and persistent writes happen
 *     in the outer try/catch
 *   - O_NOFOLLOW on the local file for upload/download (workspace validation
 *     plus an extra symlink defense at open time)
 *   - O_CREAT|O_EXCL partial file for download (no race with concurrent
 *     writers; atomic rename to the target on success)
 *   - JSON-envelope output wrapping (wrapOutput) with byte cap + redaction
 *   - hooks for the two non-fatal host-key outcomes (first_observe / mismatch);
 *     the caller (tools/ssh.ts) wires these to setHostKeyPendingWithToken +
 *     audit. Keeping the writes outside the session module lets us unit-test
 *     this file without a DB.
 *
 * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 3).
 */
import { Client, type ConnectConfig } from 'ssh2';
import type * as net from 'node:net';
import { promises as fs, constants as fsConstants, createReadStream, createWriteStream } from 'node:fs';
import * as path from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
import { buildAlgorithmsOption, isAllowedHostKeyType } from './algorithms.js';
import { preflightAndConnect, SshSsrfError, type PreflightResult } from './ssrf.js';
import { parseHostKeyType } from './connection-repo.js';
import { wrapOutput } from './output.js';
import { sanitizeError, clearBuffer } from './crypto.js';
import { logger } from '../logger.js';

export interface ResolvedConnection {
  id: string;
  ownerId: string | null;
  host: string;
  port: number;
  username: string;
  /** Decrypted PEM. Session zeros this buffer in finally. */
  privateKeyPem: Buffer;
  passphrase?: Buffer;
  /** OpenSSH wire-format host key, base64. null = no key recorded yet (TOFU first contact). */
  hostKeyB64: string | null;
  /** True iff the user has clicked Verify on the recorded key. */
  hostKeyVerified: boolean;
  allowPrivate: boolean;
}

export type SessionErrorCode =
  | 'host_key_not_verified'
  | 'host_key_first_observe'
  | 'host_key_mismatch'
  | 'host_key_alg_not_allowed'
  | 'invalid_host'
  | 'forbidden_address'
  | 'dns_failed'
  | 'connect_failed'
  | 'connect_timeout'
  | 'auth_failed'
  | 'exec_failed'
  | 'exec_timeout'
  | 'transfer_timeout'
  | 'output_too_large'
  | 'local_io_failed'
  | 'remote_io_failed'
  | 'remote_too_large'
  | 'local_target_exists';

export interface HostKeyObservation {
  connectionId: string;
  /** OpenSSH wire-format key, base64. */
  b64: string;
  /** SHA256:base64 fingerprint, OpenSSH style. */
  fingerprint: string;
}

export interface SessionHooks {
  onFirstObserve: (obs: HostKeyObservation) => Promise<{ token: string } | null>;
  onMismatch: (obs: HostKeyObservation) => Promise<{ token: string } | null>;
}

export class SshSessionError extends Error {
  readonly code: SessionErrorCode;
  /** OpenSSH-style fingerprint of the host key observed during the failed handshake, if any. */
  readonly observedFingerprint?: string;
  /** Token issued by setHostKeyPendingWithToken for the verify flow, if any. */
  readonly pendingToken?: string;
  constructor(code: SessionErrorCode, message: string, extra: { fingerprint?: string; token?: string } = {}) {
    super(message);
    this.code = code;
    this.name = 'SshSessionError';
    if (extra.fingerprint) this.observedFingerprint = extra.fingerprint;
    if (extra.token) this.pendingToken = extra.token;
  }
}

export interface ExecArgs {
  connection: ResolvedConnection;
  command: string;
  /** Optional env to set on the remote shell. Server-side AcceptEnv must allow it. */
  env?: Record<string, string>;
  /** Wall-clock cap for the exec phase (after handshake). */
  timeoutMs: number;
  /** Per-stream byte cap before truncation; default 32 KiB. */
  maxOutputBytes?: number;
}

export interface ExecResult {
  /** JSON envelope from wrapOutput — hand directly to LLM. */
  outputJson: string;
  exitCode: number;
  durationMs: number;
  /** SHA256:base64 fingerprint of the host key. */
  hostFingerprint: string;
}

export interface UploadArgs {
  connection: ResolvedConnection;
  localPath: string;
  remotePath: string;
  timeoutMs: number;
  /** Hard size cap (bytes). */
  maxBytes: number;
}

export interface DownloadArgs {
  connection: ResolvedConnection;
  remotePath: string;
  localPath: string;
  timeoutMs: number;
  /** Hard size cap (bytes). */
  maxBytes: number;
}

export interface TransferResult {
  bytes: number;
  durationMs: number;
  hostFingerprint: string;
}

interface VerifierState {
  observedKey: Buffer | null;
  verdict: 'pass' | 'first_observe' | 'mismatch' | 'alg_not_allowed';
}

/** OpenSSH-style sha256 fingerprint: 'SHA256:' + base64(sha256(raw)) without '=' padding. */
function sha256Fingerprint(raw: Buffer): string {
  return 'SHA256:' + createHash('sha256').update(raw).digest('base64').replace(/=+$/, '');
}

function newVerifierState(): VerifierState {
  return { observedKey: null, verdict: 'pass' };
}

/** Translate a SshSsrfError into the session-level error code. */
function mapSsrfError(e: SshSsrfError): SshSessionError {
  switch (e.code) {
    case 'invalid_host':
      return new SshSessionError('invalid_host', e.message);
    case 'forbidden_address':
      return new SshSessionError('forbidden_address', e.message);
    case 'dns_failed':
      return new SshSessionError('dns_failed', e.message);
    case 'connect_timeout':
      return new SshSessionError('connect_timeout', e.message);
    default:
      return new SshSessionError('connect_failed', e.message);
  }
}

/** Open an ssh2 Client with the pre-connected socket; capture host-key verdict in `vstate`. */
async function openClient(
  connection: ResolvedConnection,
  preflight: PreflightResult,
  vstate: VerifierState,
  readyTimeoutMs: number,
): Promise<Client> {
  const client = new Client();

  const config: ConnectConfig = {
    sock: preflight.socket,
    algorithms: buildAlgorithmsOption(),
    privateKey: connection.privateKeyPem,
    passphrase: connection.passphrase,
    readyTimeout: readyTimeoutMs,
    username: connection.username,
    // ssh2 protocol-level debug (no secrets — only DEBUG/INFO message metadata
    // like auth method names, kex algorithms, etc.). Routed through our logger
    // so it only appears when LOG_LEVEL=debug is set.
    debug: (msg: string) =>
      logger.debug(`[ssh:session:debug] conn=${connection.id} ${msg}`),
    // Sync verifier — never do async work or DB writes here.
    hostVerifier: (raw: Buffer): boolean => {
      vstate.observedKey = Buffer.from(raw);
      // Algorithm allowlist on the *server's* host key. Reject without
      // recording anything so an attacker can't poison TOFU with rsa-sha1
      // or ssh-dss.
      const algName = parseHostKeyType(vstate.observedKey.toString('base64'));
      if (!algName || !isAllowedHostKeyType(algName)) {
        vstate.verdict = 'alg_not_allowed';
        return false;
      }
      if (!connection.hostKeyB64) {
        vstate.verdict = 'first_observe';
        return false;
      }
      if (vstate.observedKey.toString('base64') !== connection.hostKeyB64) {
        vstate.verdict = 'mismatch';
        return false;
      }
      vstate.verdict = 'pass';
      return true;
    },
  };

  return new Promise<Client>((resolve, reject) => {
    let settled = false;
    const settle = (err: Error | null) => {
      if (settled) return;
      settled = true;
      // Detach the handshake-only listeners with the exact references
      // (removeAllListeners would also strip the permanent error handler).
      client.removeListener('ready', onReady);
      client.removeListener('close', onClose);
      if (err) {
        // Failed handshake: the caller destroys the socket and never uses
        // this client, so drop the error handler too.
        client.removeListener('error', onError);
        reject(err);
      } else {
        // Success: keep `onError` attached. The client can outlive this
        // call (an interactive console keeps it alive for the whole
        // session), and ssh2 re-emits transport-level failures
        // (ECONNRESET on idle timeout, network drop) as an 'error' event
        // on the Client. A Node 'error' event with no listener is fatal to
        // the entire process — this permanent handler is the safety net
        // that turns a dropped connection into a log line instead of a
        // crash. The live-client owner (ConsoleSession / exec finally)
        // drives the actual teardown.
        resolve(client);
      }
    };
    const onReady = () => settle(null);
    const onClose = () => settle(new Error('connection_closed_during_handshake'));
    const onError = (err: Error) => {
      if (!settled) {
        settle(err);
      } else {
        logger.warn(
          `[ssh:session] client error after handshake conn=${connection.id}: ${sanitizeError(err).message}`,
        );
      }
    };
    client.on('error', onError);
    client.once('ready', onReady);
    client.once('close', onClose);
    try {
      client.connect(config);
    } catch (e) {
      settle(e as Error);
    }
  });
}

/**
 * Map a handshake/connect error and the closure verifier verdict into a
 * SshSessionError, and call the right repo hook on the way.
 * This runs AFTER ssh2 rejects the connection — i.e. outside the synchronous
 * verifier callback, where async DB writes are safe.
 */
async function handleConnectFailure(
  connection: ResolvedConnection,
  vstate: VerifierState,
  rawErr: Error,
  hooks: SessionHooks,
): Promise<SshSessionError> {
  if (vstate.verdict === 'alg_not_allowed' && vstate.observedKey) {
    return new SshSessionError(
      'host_key_alg_not_allowed',
      'Server host key uses an algorithm not in the allowlist',
      { fingerprint: sha256Fingerprint(vstate.observedKey) },
    );
  }
  if (vstate.verdict === 'first_observe' && vstate.observedKey) {
    const fp = sha256Fingerprint(vstate.observedKey);
    const b64 = vstate.observedKey.toString('base64');
    const r = await hooks.onFirstObserve({ connectionId: connection.id, b64, fingerprint: fp });
    return new SshSessionError(
      'host_key_first_observe',
      'Host key observed for the first time; user must verify before connecting',
      { fingerprint: fp, token: r?.token },
    );
  }
  if (vstate.verdict === 'mismatch' && vstate.observedKey) {
    const fp = sha256Fingerprint(vstate.observedKey);
    const b64 = vstate.observedKey.toString('base64');
    const r = await hooks.onMismatch({ connectionId: connection.id, b64, fingerprint: fp });
    return new SshSessionError(
      'host_key_mismatch',
      'Host key fingerprint does not match the recorded key',
      { fingerprint: fp, token: r?.token },
    );
  }
  // Non-host-key failure. Map common ssh2 phrasings.
  const msg = sanitizeError(rawErr).message;
  if (/authentication/i.test(msg) && /fail/i.test(msg)) {
    return new SshSessionError('auth_failed', msg);
  }
  if (/timed?\s?out|readytimeout/i.test(msg)) {
    return new SshSessionError('connect_timeout', msg);
  }
  return new SshSessionError('connect_failed', msg);
}

/** Common preflight + open client. Returns either a connected Client or throws SshSessionError. */
async function connect(connection: ResolvedConnection, hooks: SessionHooks, timeoutMs: number) {
  if (!connection.hostKeyVerified) {
    throw new SshSessionError(
      'host_key_not_verified',
      'Connection has no verified host key; complete the TOFU flow first',
    );
  }
  let preflight: PreflightResult;
  try {
    preflight = await preflightAndConnect({
      host: connection.host,
      port: connection.port,
      allowPrivate: connection.allowPrivate,
      timeoutMs,
    });
  } catch (e) {
    if (e instanceof SshSsrfError) throw mapSsrfError(e);
    throw new SshSessionError('connect_failed', (e as Error).message);
  }

  const vstate = newVerifierState();
  try {
    const client = await openClient(connection, preflight, vstate, timeoutMs);
    return { client, fingerprint: sha256Fingerprint(vstate.observedKey!) };
  } catch (e) {
    // openClient rejected — close the pre-connected socket so it doesn't leak.
    try { preflight.socket.destroy(); } catch { /* ignore */ }
    throw await handleConnectFailure(connection, vstate, e as Error, hooks);
  }
}

/** Execute a command on the connected client; returns wrapped JSON output. */
/**
 * Test the connection without running a command — used by Phase 5
 * `POST /api/ssh/connections/:id/test`.
 *
 * Differs from sshExec in two ways:
 *  - Skips the `hostKeyVerified` precondition (callers test exactly when the
 *    key is unknown).
 *  - Treats first_observe / mismatch / alg_not_allowed as RESULTS, not errors.
 *    Real network/auth/timeout failures still throw SshSessionError.
 *
 * Auth still runs when the host key matches a previously-recorded one — that
 * gives the test endpoint useful "key + cred + host" coverage. For a new
 * connection (hostKeyB64 = null) the verifier rejects before auth, so the
 * remote logs no auth attempt.
 */
export interface TestArgs {
  connection: ResolvedConnection;
  timeoutMs: number;
}

export type TestVerdict = 'pass' | 'first_observe' | 'mismatch' | 'alg_not_allowed';

export interface TestResult {
  verdict: TestVerdict;
  fingerprint: string;
  hostKeyB64: string;
  hostKeyType: string;
}

export async function sshTest(args: TestArgs): Promise<TestResult> {
  // Bypass hostKeyVerified precondition; the verifier captures the actual
  // observation in vstate regardless.
  const conn: ResolvedConnection = { ...args.connection, hostKeyVerified: true };
  let preflight: PreflightResult;
  try {
    preflight = await preflightAndConnect({
      host: conn.host,
      port: conn.port,
      allowPrivate: conn.allowPrivate,
      timeoutMs: args.timeoutMs,
    });
  } catch (e) {
    if (e instanceof SshSsrfError) throw mapSsrfError(e);
    throw new SshSessionError('connect_failed', (e as Error).message);
  }

  const vstate = newVerifierState();
  let client: Client | null = null;
  try {
    client = await openClient(conn, preflight, vstate, args.timeoutMs);
    const observed = vstate.observedKey;
    if (!observed) throw new SshSessionError('connect_failed', 'no host key observed');
    const b64 = observed.toString('base64');
    return {
      verdict: 'pass',
      fingerprint: sha256Fingerprint(observed),
      hostKeyB64: b64,
      hostKeyType: parseHostKeyType(b64) ?? 'unknown',
    };
  } catch (e) {
    try { preflight.socket.destroy(); } catch { /* ignore */ }
    if (vstate.observedKey && vstate.verdict !== 'pass') {
      const b64 = vstate.observedKey.toString('base64');
      return {
        verdict: vstate.verdict,
        fingerprint: sha256Fingerprint(vstate.observedKey),
        hostKeyB64: b64,
        hostKeyType: parseHostKeyType(b64) ?? 'unknown',
      };
    }
    // Genuine failure with no host-key observation.
    const msg = sanitizeError(e as Error).message;
    if (/authentication/i.test(msg) && /fail/i.test(msg)) {
      throw new SshSessionError('auth_failed', msg);
    }
    if (/timed?\s?out|readytimeout/i.test(msg)) {
      throw new SshSessionError('connect_timeout', msg);
    }
    throw new SshSessionError('connect_failed', msg);
  } finally {
    if (client) {
      try { client.end(); } catch { /* ignore */ }
    }
  }
}

export async function sshExec(args: ExecArgs, hooks: SessionHooks): Promise<ExecResult> {
  const started = Date.now();
  const { client, fingerprint } = await connect(args.connection, hooks, args.timeoutMs);
  try {
    return await runExec(client, args, fingerprint, started);
  } finally {
    try { client.end(); } catch { /* ignore */ }
    clearBuffer(args.connection.privateKeyPem);
    clearBuffer(args.connection.passphrase);
  }
}

function runExec(
  client: Client,
  args: ExecArgs,
  hostFingerprint: string,
  started: number,
): Promise<ExecResult> {
  return new Promise<ExecResult>((resolve, reject) => {
    const cap = args.maxOutputBytes ?? 32 * 1024;
    // We hold up to cap*2 bytes per stream in memory and stop buffering past
    // that. The raw byte totals (sout/serr) keep growing so the JSON envelope
    // can report the true pre-cap size.
    const chunks = {
      stdout: [] as Buffer[],
      stderr: [] as Buffer[],
      outBuf: 0,
      errBuf: 0,
      sout: 0,
      serr: 0,
    };
    let exitCode = -1;
    let settled = false;
    const settle = (err: Error | null, result?: ExecResult) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      if (err) reject(err);
      else if (result) resolve(result);
    };
    const timer = setTimeout(() => {
      try { client.end(); } catch { /* ignore */ }
      settle(new SshSessionError('exec_timeout', `Exec exceeded ${args.timeoutMs} ms`));
    }, args.timeoutMs);

    const opts = args.env ? { env: args.env as NodeJS.ProcessEnv } : undefined;
    const cb = (err: Error | undefined, stream: import('ssh2').ClientChannel | undefined) => {
      if (err || !stream) {
        return settle(new SshSessionError('exec_failed', sanitizeError(err ?? new Error('no stream')).message));
      }
      const ceil = cap * 2;
      stream.on('data', (d: Buffer) => {
        chunks.sout += d.length;
        if (chunks.outBuf < ceil) {
          chunks.stdout.push(d);
          chunks.outBuf += d.length;
        }
      });
      stream.stderr.on('data', (d: Buffer) => {
        chunks.serr += d.length;
        if (chunks.errBuf < ceil) {
          chunks.stderr.push(d);
          chunks.errBuf += d.length;
        }
      });
      stream.on('exit', (code: number | null) => {
        exitCode = typeof code === 'number' ? code : -1;
      });
      stream.on('close', () => {
        const outputJson = wrapOutput({
          stdout: Buffer.concat(chunks.stdout, chunks.outBuf),
          stderr: Buffer.concat(chunks.stderr, chunks.errBuf),
          exitCode,
          durationMs: Date.now() - started,
          capBytes: cap,
          stdoutBytesRaw: chunks.sout,
          stderrBytesRaw: chunks.serr,
        });
        settle(null, {
          outputJson,
          exitCode,
          durationMs: Date.now() - started,
          hostFingerprint,
        });
      });
      stream.on('error', (e: Error) =>
        settle(new SshSessionError('exec_failed', sanitizeError(e).message)),
      );
    };
    // ssh2 has both 2- and 3-arg overloads of exec.
    if (opts) client.exec(args.command, opts, cb);
    else client.exec(args.command, cb);
  });
}

/** Wrap a node-style sftp method into a Promise. */
function promisify<T>(fn: (cb: (err: Error | undefined, value: T) => void) => void): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    fn((err, v) => (err ? reject(err) : resolve(v)));
  });
}

async function withSftp<T>(
  client: Client,
  body: (sftp: import('ssh2').SFTPWrapper) => Promise<T>,
): Promise<T> {
  const sftp = await promisify<import('ssh2').SFTPWrapper>((cb) =>
    client.sftp((err, s) => cb(err ?? undefined, s as import('ssh2').SFTPWrapper)),
  );
  try {
    return await body(sftp);
  } finally {
    try { sftp.end(); } catch { /* ignore */ }
  }
}

export async function sshUpload(args: UploadArgs, hooks: SessionHooks): Promise<TransferResult> {
  const started = Date.now();
  // Open local first so we catch symlink / size / type errors before connecting.
  let localFd: import('node:fs/promises').FileHandle;
  let localSize: number;
  try {
    // O_NOFOLLOW: refuse to open if the leaf is a symlink. Path-policy
    // already rejected symlinks; this is defense in depth at open time.
    localFd = await fs.open(args.localPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
  } catch (e) {
    throw new SshSessionError('local_io_failed', `local open failed: ${(e as Error).message}`);
  }
  try {
    const st = await localFd.stat();
    if (!st.isFile()) {
      throw new SshSessionError('local_io_failed', 'local path is not a regular file');
    }
    localSize = st.size;
    if (localSize > args.maxBytes) {
      throw new SshSessionError('output_too_large', `local file ${localSize} > cap ${args.maxBytes}`);
    }
  } catch (e) {
    await localFd.close().catch(() => undefined);
    if (e instanceof SshSessionError) throw e;
    throw new SshSessionError('local_io_failed', `local stat failed: ${(e as Error).message}`);
  }

  const { client, fingerprint } = await connect(args.connection, hooks, args.timeoutMs);
  try {
    return await withSftp(client, async (sftp) => {
      return await new Promise<TransferResult>((resolve, reject) => {
        let settled = false;
        let bytes = 0;
        const settle = (err: Error | null, result?: TransferResult) => {
          if (settled) return;
          settled = true;
          clearTimeout(timer);
          if (err) reject(err);
          else if (result) resolve(result);
        };
        const timer = setTimeout(() => {
          try { client.end(); } catch { /* ignore */ }
          settle(new SshSessionError('transfer_timeout', `Upload exceeded ${args.timeoutMs} ms`));
        }, args.timeoutMs);

        // Path-validated above; use createReadStream off the open fd.
        const localStream = createReadStream('', { fd: localFd.fd, autoClose: false });
        const remoteStream = sftp.createWriteStream(args.remotePath);
        localStream.on('data', (chunk: Buffer | string) => {
          bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;
        });
        localStream.on('error', (e: Error) =>
          settle(new SshSessionError('local_io_failed', sanitizeError(e).message)),
        );
        remoteStream.on('error', (e: Error) =>
          settle(new SshSessionError('remote_io_failed', sanitizeError(e).message)),
        );
        remoteStream.on('close', () => {
          settle(null, { bytes, durationMs: Date.now() - started, hostFingerprint: fingerprint });
        });
        localStream.pipe(remoteStream);
      });
    });
  } finally {
    await localFd.close().catch(() => undefined);
    try { client.end(); } catch { /* ignore */ }
    clearBuffer(args.connection.privateKeyPem);
    clearBuffer(args.connection.passphrase);
  }
}

export async function sshDownload(args: DownloadArgs, hooks: SessionHooks): Promise<TransferResult> {
  const started = Date.now();
  // Refuse to overwrite an existing file; download into a partial sibling that
  // we rename on success. The caller has already validated localPath is inside
  // the workspace and contains no symlinks in its ancestry.
  if (path.isAbsolute(args.localPath) === false) {
    throw new SshSessionError('local_io_failed', 'localPath must be absolute');
  }
  let leafExists = false;
  try {
    await fs.lstat(args.localPath);
    leafExists = true;
  } catch (e) {
    if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
      throw new SshSessionError('local_io_failed', `lstat failed: ${(e as Error).message}`);
    }
  }
  if (leafExists) {
    throw new SshSessionError('local_target_exists', 'local target already exists; refusing to overwrite');
  }
  const partialSuffix = `.partial-${randomBytes(8).toString('hex')}`;
  const partialPath = args.localPath + partialSuffix;

  let partialFd: import('node:fs/promises').FileHandle;
  try {
    partialFd = await fs.open(
      partialPath,
      fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
      0o600,
    );
  } catch (e) {
    throw new SshSessionError('local_io_failed', `open partial failed: ${(e as Error).message}`);
  }

  const { client, fingerprint } = await connect(args.connection, hooks, args.timeoutMs);
  let bytes = 0;
  let failed: SshSessionError | null = null;
  try {
    await withSftp(client, async (sftp) => {
      const remoteStats = await promisify<import('ssh2').Stats>((cb) =>
        sftp.stat(args.remotePath, (err, stats) => cb(err ?? undefined, stats as import('ssh2').Stats)),
      );
      if (!remoteStats || typeof remoteStats.size !== 'number') {
        throw new SshSessionError('remote_io_failed', 'remote stat returned no size');
      }
      if (remoteStats.size > args.maxBytes) {
        throw new SshSessionError(
          'remote_too_large',
          `remote ${remoteStats.size} > cap ${args.maxBytes}`,
        );
      }
      await new Promise<void>((resolve, reject) => {
        let settled = false;
        const settle = (err: Error | null) => {
          if (settled) return;
          settled = true;
          clearTimeout(timer);
          if (err) reject(err);
          else resolve();
        };
        const timer = setTimeout(() => {
          try { client.end(); } catch { /* ignore */ }
          settle(new SshSessionError('transfer_timeout', `Download exceeded ${args.timeoutMs} ms`));
        }, args.timeoutMs);

        const rs = sftp.createReadStream(args.remotePath);
        const ws = createWriteStreamFromFd(partialFd.fd);
        rs.on('data', (chunk: Buffer | string) => {
          bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;
          if (bytes > args.maxBytes) {
            settle(new SshSessionError('remote_too_large', `download exceeded cap ${args.maxBytes}`));
            try { rs.destroy(); } catch { /* ignore */ }
          }
        });
        rs.on('error', (e: Error) =>
          settle(new SshSessionError('remote_io_failed', sanitizeError(e).message)),
        );
        ws.on('error', (e: Error) =>
          settle(new SshSessionError('local_io_failed', sanitizeError(e).message)),
        );
        ws.on('finish', () => settle(null));
        rs.pipe(ws);
      });
    });
  } catch (e) {
    failed = e instanceof SshSessionError ? e : new SshSessionError('remote_io_failed', (e as Error).message);
  } finally {
    await partialFd.close().catch(() => undefined);
    try { client.end(); } catch { /* ignore */ }
    clearBuffer(args.connection.privateKeyPem);
    clearBuffer(args.connection.passphrase);
  }

  if (failed) {
    await fs.unlink(partialPath).catch(() => undefined);
    throw failed;
  }
  try {
    await fs.rename(partialPath, args.localPath);
  } catch (e) {
    await fs.unlink(partialPath).catch(() => undefined);
    throw new SshSessionError('local_io_failed', `rename partial failed: ${(e as Error).message}`);
  }
  return { bytes, durationMs: Date.now() - started, hostFingerprint: fingerprint };
}

/** createWriteStream against an existing fd. autoClose: false so the caller closes. */
function createWriteStreamFromFd(fd: number) {
  return createWriteStream('', { fd, autoClose: false });
}

// ──────────────────────────────────────────────────────────────────────
// Phase 3: SSH Console — interactive shell channel
//
// Opens a connection (reusing the same preflight / host-key / algorithm
// allowlist path as sshExec) and then requests a PTY-backed shell. The
// caller (engine/tools/ssh-console.ts) wraps the returned ClientChannel
// in a ConsoleSession and is responsible for closing it (channel.end /
// client.end on close path). We do NOT zero connection.privateKeyPem
// here — the long-lived console session keeps the Client alive past
// this call, and the Channel must remain usable. The caller clears the
// PEM buffer once the session closes.
// ──────────────────────────────────────────────────────────────────────

export interface OpenShellArgs {
  connection: ResolvedConnection;
  cols: number;
  rows: number;
  /** Wall-clock cap for the connect+shell handshake (ms). */
  timeoutMs: number;
}

export interface OpenShellResult {
  channel: import('ssh2').ClientChannel;
  client: import('ssh2').Client;
  hostFingerprint: string;
}

export async function openShellChannel(args: OpenShellArgs): Promise<OpenShellResult> {
  // No-op hooks: for interactive shells we keep the existing semantics
  // (host_key_not_verified is rejected before we get here in the tool
  // layer; first_observe / mismatch on a previously-verified key would
  // throw the standard SshSessionError, which the caller surfaces).
  const noopHooks: SessionHooks = {
    onFirstObserve: async () => null,
    onMismatch: async () => null,
  };
  const { client, fingerprint } = await connect(args.connection, noopHooks, args.timeoutMs);
  try {
    const channel = await new Promise<import('ssh2').ClientChannel>((resolve, reject) => {
      client.shell(
        { cols: args.cols, rows: args.rows, term: 'xterm-256color' },
        (err: Error | undefined, ch: import('ssh2').ClientChannel | undefined) => {
          if (err || !ch) {
            return reject(
              new SshSessionError(
                'exec_failed',
                sanitizeError(err ?? new Error('shell() returned no channel')).message,
              ),
            );
          }
          resolve(ch);
        },
      );
    });
    return { channel, client, hostFingerprint: fingerprint };
  } catch (e) {
    // shell() failed — close the client so the socket doesn't leak.
    try { client.end(); } catch { /* ignore */ }
    throw e;
  }
}
