/**
 * SSH Console tools: SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot.
 *
 * These are the AI-facing wrappers around the in-memory ConsoleSession
 * registry. Each call goes through the same 12-step preflight as SshExec
 * (piece membership, access decision, enabled / abuse / host-key state)
 * via the exported `preflight` helper in ssh.ts. After that:
 *
 *   - SshConsoleEnsure: find-or-open the session keyed by (localTaskId).
 *     If a session already exists for a different connection on this task,
 *     close it with reason 'connection_change' and open a fresh one. Apply
 *     per-connection session cap (evict oldest with 'session_cap_evict').
 *
 *   - SshConsoleSend: deny-list check the input lines, then forward the
 *     bytes to the session (which writes them straight to the PTY — same
 *     path as human keystrokes from the WS). Wait the caller-supplied
 *     waitMs (capped) and return a screen snapshot + new-output byte count
 *     so the LLM can read the post-action terminal state.
 *
 *   - SshConsoleSnapshot: kind=screen returns the rendered terminal view;
 *     kind=scrollback returns the (ANSI-stripped) raw byte history capped
 *     at max_bytes. Does not consume input.
 *
 * Plan: docs/superpowers/plans/2026-05-13-ssh-console.md (Phase 3).
 */
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { ConsoleSession } from '../../ssh/console-session.js';
import { checkConsoleInput } from '../../ssh/console-deny-check.js';
import { clearBuffer } from '../../ssh/crypto.js';
import { logger } from '../../logger.js';
import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js';

// ──────────────────────────────────────────────────────────────────────
// Tool definitions
// ──────────────────────────────────────────────────────────────────────

export const TOOL_DEFS: Record<string, ToolDef> = {
  SshConsoleEnsure: {
    type: 'function',
    function: {
      name: 'SshConsoleEnsure',
      description:
        'SSH console セッションを確保します（無ければ open、有れば再利用）。既存セッションが別 connection_id にある場合はデフォルトでエラー、force_replace=true で強制置換。詳細は ReadToolDoc({ name: "SshConsoleEnsure" })。',
      parameters: {
        type: 'object',
        properties: {
          connection_id: { type: 'string', description: 'SSH 接続の UUID (SshListConnections で取得した id)。label やホスト名ではない。' },
          cols: { type: 'number' },
          rows: { type: 'number' },
          force_replace: {
            type: 'boolean',
            description: '既存セッションが別 connection_id にあるとき、true なら旧セッションを閉じて新規 open。default=false (mismatch ならエラー返却)。',
          },
        },
        required: ['connection_id'],
      },
    },
  },
  SshConsoleSend: {
    type: 'function',
    function: {
      name: 'SshConsoleSend',
      description:
        'console に入力を送る。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id はタスクに active session があれば省略可。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
      parameters: {
        type: 'object',
        properties: {
          connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。明示する場合は active session の id と一致する必要あり (mismatch はエラー)。' },
          input: { type: 'string' },
          wait_ms: { type: 'number' },
        },
        required: ['input'],
      },
    },
  },
  SshConsoleSnapshot: {
    type: 'function',
    function: {
      name: 'SshConsoleSnapshot',
      description:
        'console の現在画面または scrollback を取得します。connection_id はタスクに既に active session があれば省略可 (推奨)。詳細は ReadToolDoc({ name: "SshConsoleSnapshot" })。',
      parameters: {
        type: 'object',
        properties: {
          connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。' },
          kind: { type: 'string', enum: ['screen', 'scrollback'] },
          max_bytes: { type: 'number' },
        },
        required: [],
      },
    },
  },
};

const CONSOLE_TOOL_NAMES = new Set([
  'SshConsoleEnsure',
  'SshConsoleSend',
  'SshConsoleSnapshot',
]);

// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────

function err(output: string): ToolResult {
  return { output, isError: true };
}

function ok(output: string): ToolResult {
  return { output, isError: false };
}

/**
 * Subsystem + console.enabled gate. Console tools return a distinct error
 * vs the SshExec "not initialised" so the LLM can tell the feature is
 * off at the config layer (admin must toggle ssh.console.enabled).
 */
function checkConsoleGate(sub: SshSubsystem | null): ToolResult | null {
  if (!sub) {
    return err('SSH subsystem is not initialised (ssh.enabled=false or MCP_ENCRYPTION_KEY missing).');
  }
  if (sub.maintenance.isActive()) {
    return err('SSH subsystem is in maintenance — retry in a moment.');
  }
  if (!sub.config.console.enabled) {
    return err('SSH Console is disabled (ssh.console.enabled=false in config.yaml).');
  }
  return null;
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleEnsure
// ──────────────────────────────────────────────────────────────────────

interface EnsureResult {
  /** True if a fresh session was opened on this call. */
  opened: boolean;
  session: ConsoleSession;
}

/**
 * Internal find-or-open helper. Returns a live ConsoleSession bound to
 * (ctx.taskId, connectionId). Used by SshConsoleEnsure directly and by
 * SshConsoleSend / SshConsoleSnapshot when no session is attached yet.
 */
async function ensureSessionInternal(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<EnsureResult | ToolResult> {
  const connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
  if (!connectionId) {
    return err('SshConsoleEnsure: connection_id is required.');
  }
  const localTaskId = ctx.taskId ?? '';
  if (!localTaskId) {
    return err('SshConsoleEnsure: this tool requires a local task context (ctx.taskId).');
  }

  // If a session already exists for this task, branch on whether it's the
  // same connection. Same → reuse. Different → reject by default (so a
  // single LLM connection_id slip can't kill the user's live shell), opt
  // into the swap with force_replace=true.
  const existing = sub.sessionRegistry.get(localTaskId);
  if (existing) {
    if (existing.connectionId === connectionId) {
      return { opened: false, session: existing };
    }
    const forceReplace = input.force_replace === true;
    if (!forceReplace) {
      const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
      const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
      return err(
        `SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
          `(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
          `Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
          `or pass force_replace=true to close it and open a new session on ${connectionId}.`,
      );
    }
    await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
  }

  // Full 12-step preflight (same path as SshExec).
  const pre = preflight({
    toolName: 'SshExec',
    connectionId,
    ctx,
    sub,
    auditAction: 'ssh.console.open',
  });
  if (!pre.ok) return pre.error;
  const { connection, actingUserId, pieceName } = pre;

  // Console requires a verified host key — there is no LLM-actionable
  // recovery from first_observe / mismatch on a long-lived shell.
  if (connection.hostKeyVerifiedAt === null) {
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.open',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: { reason: 'host_key_not_verified' },
      },
      'denied',
    );
    return err(
      `SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`,
    );
  }

  const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.floor(input.cols) : sub.config.console.defaultCols;
  const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.floor(input.rows) : sub.config.console.defaultRows;

  // Decrypt key material — same flow as SshExec; we clear on failure but
  // keep alive past this call because the ssh2 Client needs the PEM through
  // the entire session. ConsoleSession.close() does NOT clear these
  // buffers (it can't see them) — we accept that the PEM stays in memory
  // for the lifetime of the session, which already holds the decrypted
  // channel and host connection state.
  let pemBuf: Buffer | null = null;
  let passBuf: Buffer | null = null;
  try {
    pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
    passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
  } catch (e) {
    if (pemBuf) clearBuffer(pemBuf);
    if (passBuf) clearBuffer(passBuf);
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.open',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: { reason: 'decrypt_failed', msg: (e as Error).message },
      },
      'failed',
    );
    return err('SshConsoleEnsure: failed to decrypt stored key material.');
  }

  // Open the channel. On failure clear the PEM and bail.
  let channel: import('ssh2').ClientChannel;
  let client: import('ssh2').Client;
  let hostFingerprint: string;
  try {
    const shellResult = await sub.openShellChannel({
      connection: {
        id: connection.id,
        ownerId: connection.ownerId,
        host: connection.host,
        port: connection.port,
        username: connection.username,
        privateKeyPem: pemBuf,
        passphrase: passBuf ?? undefined,
        hostKeyB64: connection.hostKeyB64,
        hostKeyVerified: true,
        allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses,
      },
      cols,
      rows,
      timeoutMs: sub.config.callTimeoutSeconds * 1000,
    });
    channel = shellResult.channel;
    client = shellResult.client;
    hostFingerprint = shellResult.hostFingerprint;
  } catch (e) {
    clearBuffer(pemBuf);
    clearBuffer(passBuf);
    sub.abuseRepo.checkAndRecordFailure({
      connectionId,
      ownerId: connection.ownerId,
      userId: actingUserId,
      host: connection.host,
      username: connection.username,
    });
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.open',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: { reason: 'open_shell_failed', msg: (e as Error).message },
      },
      'failed',
    );
    return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`);
  }

  // Build the session and register it. From here on the channel + client
  // + PEM belong to the session; we don't clear them on the happy path.
  // The session ends the client (and thus releases the PEM-bound
  // connection) when it closes.
  const session = new ConsoleSession({
    localTaskId,
    connectionId,
    ownerId: connection.ownerId,
    startedByUserId: actingUserId,
    cols,
    rows,
    scrollbackCap: sub.config.console.scrollbackBytes,
    channel,
    client,
    auditRepo: sub.auditRepo,
  });
  sub.sessionRegistry.register(session);
  sub.abuseRepo.recordSuccess(connectionId);
  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.open',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: ctx.jobId ?? undefined,
      detail: { cols, rows, host_fingerprint: hostFingerprint },
    },
    'success',
  );

  // Enforce the per-connection session cap (evict oldest).
  const evict = sub.sessionRegistry.enforceCap(connectionId);
  for (const e of evict) {
    sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) =>
      logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
    );
  }

  return { opened: true, session };
}

async function ensureTool(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const r = await ensureSessionInternal(input, ctx, sub);
  if ('isError' in r) return r;
  const { opened, session } = r;
  return ok(
    JSON.stringify(
      {
        ok: true,
        opened,
        reused: !opened,
        connection_id: session.connectionId,
        cols: session.cols,
        rows: session.rows,
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleSend
// ──────────────────────────────────────────────────────────────────────

/** Cap waitMs to a sane range so the LLM can't burn an entire timeout
 * window on one Send call. Min 0 (no wait), max 5s. */
const MAX_WAIT_MS = 5_000;

/**
 * Decode common escape sequences in AI input strings. Some LLMs serialize
 * tool args such that "\n" arrives as a real LF (0x0a), others surface it
 * literally as the 2-byte "\\n". Real bytes are preserved (the regex only
 * matches the literal 2-char forms), so this is safe to apply uniformly.
 *
 * Recognized: \n \r \t \0 \\ \" \xHH (2-hex). Unknown sequences such as
 * \q pass through unchanged.
 */
export function unescapeAiInput(text: string): string {
  return text.replace(/\\(x([0-9a-fA-F]{2})|.)/g, (_match, full: string, hex?: string) => {
    if (hex) return String.fromCharCode(parseInt(hex, 16));
    switch (full) {
      case 'n': return '\n';
      case 'r': return '\r';
      case 't': return '\t';
      case '0': return '\0';
      case '\\': return '\\';
      case '"': return '"';
      case "'": return "'";
      default: return '\\' + full;
    }
  });
}

async function sendInput(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const rawText = typeof input.input === 'string' ? input.input : '';
  if (rawText.length === 0) {
    return err('SshConsoleSend: input is required (non-empty string).');
  }
  const localTaskId = ctx.taskId ?? '';
  if (!localTaskId) {
    return err('SshConsoleSend: this tool requires a local task context (ctx.taskId).');
  }

  // Resolve connection_id: explicit arg or active session's id.
  // - omitted + active session → use active session's connection_id (recommended)
  // - omitted + no active session → instruct the agent to discover via SshListConnections
  // - explicit + matches active → fine
  // - explicit + mismatches active → reject with surface of the actual id (don't auto-swap)
  let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
  const existingSession = sub.sessionRegistry.get(localTaskId);
  if (!connectionId) {
    if (!existingSession) {
      return err(
        'SshConsoleSend: connection_id is required when this task has no active session. ' +
          'Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.',
      );
    }
    connectionId = existingSession.connectionId;
  } else if (existingSession && existingSession.connectionId !== connectionId) {
    return err(
      `SshConsoleSend: this task has an active session on connection ${existingSession.connectionId}, not ${connectionId}. ` +
        `Either omit connection_id (uses the active one), pass connection_id="${existingSession.connectionId}", ` +
        `or call SshConsoleEnsure({connection_id: "${connectionId}", force_replace: true}) to swap intentionally.`,
    );
  }

  // Many LLMs serialize tool args correctly so "\n" in input means an
  // actual LF byte. But some surface escape sequences *literally*
  // (the tool args carry the 2-byte sequence "\\n" instead of "\n"),
  // and shells don't treat literal "\n" as Enter. Unescape common
  // sequences (\n \r \t \0 \\ \" \xNN) so AI input behaves as the
  // caller intended regardless of which encoding path the LLM took.
  // Real LF/CR bytes pass through unchanged (the regex only matches
  // the 2-byte "\\X" forms).
  const text = unescapeAiInput(rawText);

  // Auto-append \n when input clearly looks like a shell command missing its
  // newline (printable text, length >= 2, no existing line terminator, no
  // control byte that would indicate TUI / completion / SIGINT). The agent
  // frequently forgets to add \n and the shell then buffers the bytes as
  // partial readline without executing. Auto-append makes this the safe
  // default; agents that need raw partial input (sudo password, vim normal
  // mode keys, etc.) must include a control byte or shorter sequence.
  const hasLineTerminator = /[\r\n]/.test(text);
  const hasControlChar = /[\x00-\x08\x0b-\x1f\x7f]/.test(text);
  const autoNewlineAppended = text.length >= 2 && !hasLineTerminator && !hasControlChar;
  const sendText = autoNewlineAppended ? text + '\n' : text;

  // Length cap (UTF-8 byte length, not character count — matches the
  // raw bytes we'll write to the PTY).
  const bytesToSend = Buffer.byteLength(sendText, 'utf8');
  const maxBytes = sub.config.console.maxInputBytesPerSend;
  if (bytesToSend > maxBytes) {
    return err(
      `SshConsoleSend: input is ${bytesToSend}B but max_input_bytes_per_send=${maxBytes}.`,
    );
  }

  // Find-or-open the session. We already resolved/validated connectionId
  // above (either matches the existing session or there is none), so
  // ensureSessionInternal will always reuse the live session or open a
  // brand-new one without ever hitting its mismatch-reject path.
  const ensured = await ensureSessionInternal({ connection_id: connectionId }, ctx, sub);
  if ('isError' in ensured) return ensured;
  const session = ensured.session;

  // Re-run preflight to get the canonical connection for deny-list eval +
  // audit logging. (ensureSessionInternal already did the same call but
  // discarded the result; we want the latest snapshot in case admin
  // policy changed between Ensure and Send.)
  const pre = preflight({
    toolName: 'SshExec',
    connectionId,
    ctx,
    sub,
    auditAction: 'ssh.console.send',
  });
  if (!pre.ok) return pre.error;
  const { connection, actingUserId, pieceName } = pre;

  // Deny check (built-in + per-connection patterns). Line-wise so the
  // LLM gets actionable feedback ("line 2 matched rm-rf").
  const denyResult = checkConsoleInput(
    sendText,
    connection.commandDenyPatterns ? connection.commandDenyPatterns.split('\n') : null,
    connection.commandAllowPatterns ? connection.commandAllowPatterns.split('\n') : null,
  );
  if (!denyResult.ok) {
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.input_rejected',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: {
          reason: denyResult.reason,
          line_index: denyResult.lineIndex,
          matched: denyResult.matched,
        },
      },
      'denied',
    );
    return err(
      `SshConsoleSend: input rejected by ${denyResult.reason} (line ${denyResult.lineIndex + 1}, pattern ${denyResult.matched ?? 'n/a'}).`,
    );
  }

  // Record bytes_before so we can compute new_output_bytes after wait.
  const outputBytesBefore = session.totalOutputBytes;

  // Write to the session as 'ai' source. ConsoleSession.write forwards
  // bytes straight to the PTY so the shell echoes them back the same
  // way it does for human keystrokes — partial input is allowed and
  // shows up on screen immediately. LF→CR normalization happens inside
  // ConsoleSession.write so the PTY treats \n as Enter.
  session.write(Buffer.from(sendText, 'utf8'), 'ai');

  // Wait so the LLM sees post-action terminal state. Cap at MAX_WAIT_MS;
  // 0 is allowed (return immediately, useful for control bytes like ^C).
  const waitMsRaw = typeof input.wait_ms === 'number' && Number.isFinite(input.wait_ms) ? input.wait_ms : 200;
  const waitMs = Math.max(0, Math.min(MAX_WAIT_MS, Math.floor(waitMsRaw)));
  if (waitMs > 0) {
    await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
  }

  const screen = session.snapshotScreen();
  const newOutputBytes = session.totalOutputBytes - outputBytesBefore;

  // (auto-newline was applied earlier — agent self-correction is no longer needed)

  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.send',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: ctx.jobId ?? undefined,
      detail: {
        bytes_sent: bytesToSend,
        wait_ms: waitMs,
        new_output_bytes: newOutputBytes,
        ...(autoNewlineAppended ? { auto_newline_appended: true } : {}),
      },
    },
    'success',
  );

  return ok(
    JSON.stringify(
      {
        ok: true,
        bytes_sent: bytesToSend,
        wait_ms: waitMs,
        new_output_bytes: newOutputBytes,
        screen_after: screen.text,
        cursor: screen.cursor,
        cols: screen.cols,
        rows: screen.rows,
        ...(autoNewlineAppended ? { auto_newline_appended: true } : {}),
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleSnapshot
// ──────────────────────────────────────────────────────────────────────

/** Hard cap on scrollback bytes returned in one call — prevents the LLM
 * from blowing its context window by asking for the full ringbuffer. */
const MAX_SCROLLBACK_RETURN_BYTES = 64 * 1024;

async function snapshot(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const localTaskId = ctx.taskId ?? '';
  if (!localTaskId) {
    return err('SshConsoleSnapshot: this tool requires a local task context (ctx.taskId).');
  }

  // Snapshot does NOT auto-Ensure — if there is no live session, the LLM
  // should call SshConsoleEnsure / Send first.
  const session = sub.sessionRegistry.get(localTaskId);
  if (!session) {
    return err(
      'SshConsoleSnapshot: no live session for this task. Call SshConsoleEnsure({connection_id}) first.',
    );
  }

  // Resolve connection_id: explicit arg or the active session's id.
  // Mismatch (explicit id ≠ active session id) → reject with surface of
  // the actual id so the LLM can self-correct.
  let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
  if (!connectionId) {
    connectionId = session.connectionId;
  } else if (session.connectionId !== connectionId) {
    return err(
      `SshConsoleSnapshot: this task has an active session on connection ${session.connectionId}, not ${connectionId}. ` +
        `Either omit connection_id (uses the active one) or pass connection_id="${session.connectionId}".`,
    );
  }

  // Preflight (for audit + access check; we still want to see read attempts
  // in the audit log).
  const pre = preflight({
    toolName: 'SshExec',
    connectionId,
    ctx,
    sub,
    auditAction: 'ssh.console.snapshot',
  });
  if (!pre.ok) return pre.error;
  const { connection, actingUserId, pieceName } = pre;

  const kind = input.kind === 'scrollback' ? 'scrollback' : 'screen';

  if (kind === 'screen') {
    const screen = session.snapshotScreen();
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.snapshot',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: { kind: 'screen', cols: screen.cols, rows: screen.rows },
      },
      'success',
    );
    return ok(
      JSON.stringify(
        {
          ok: true,
          kind: 'screen',
          text: screen.text,
          cursor: screen.cursor,
          cols: screen.cols,
          rows: screen.rows,
        },
        null,
        2,
      ),
    );
  }

  // kind === 'scrollback'
  const maxBytesRaw = typeof input.max_bytes === 'number' && Number.isFinite(input.max_bytes) ? input.max_bytes : MAX_SCROLLBACK_RETURN_BYTES;
  const maxBytes = Math.max(1, Math.min(MAX_SCROLLBACK_RETURN_BYTES, Math.floor(maxBytesRaw)));
  const sb = session.snapshotScrollback({ maxBytes });
  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.snapshot',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: ctx.jobId ?? undefined,
      detail: {
        kind: 'scrollback',
        max_bytes: maxBytes,
        byte_count: sb.byteCount,
        truncated: sb.truncated,
      },
    },
    'success',
  );
  return ok(
    JSON.stringify(
      {
        ok: true,
        kind: 'scrollback',
        text: sb.text,
        byte_count: sb.byteCount,
        truncated: sb.truncated,
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// Dispatcher
// ──────────────────────────────────────────────────────────────────────

export async function executeTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  if (!CONSOLE_TOOL_NAMES.has(name)) return null;

  const sub = getSshSubsystem();
  const gate = checkConsoleGate(sub);
  if (gate) return gate;
  // checkConsoleGate guarantees sub is non-null when it returns null.
  const subsystem = sub as SshSubsystem;

  if (name === 'SshConsoleEnsure') return ensureTool(input, ctx, subsystem);
  if (name === 'SshConsoleSend') return sendInput(input, ctx, subsystem);
  if (name === 'SshConsoleSnapshot') return snapshot(input, ctx, subsystem);
  return null;
}
