/**
 * End-to-end tests for the SSH tools — runs SshExec / SshUpload / SshDownload
 * against an in-process ssh2 Server (`session-test-server.ts` from Phase 3).
 *
 * The "e2e" label is relative to the SSH tool: it uses the *real* ssh2 client +
 * server libraries through the actual session module (sshExec / sshUpload /
 * sshDownload), wired to the engine's tool dispatcher and the same audit /
 * abuse / connection repos used by HTTP. No external docker / network needed.
 *
 * Gated by SKIP_SSH_E2E=1 so test envs without ssh2 keypair generation
 * support can opt out. Default: runs.
 *
 * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 7).
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { promises as fsp } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { runMigrations } from '../../db/migrate.js';
import { createConnectionRepo, type SshConnectionRepo } from '../../ssh/connection-repo.js';
import { createGrantsRepo } from '../../ssh/grants-repo.js';
import { createAuditRepo, type SshAuditRepo } from '../../ssh/audit-repo.js';
import { createAbuseRepo } from '../../ssh/abuse-repo.js';
import { createAccessResolver } from '../../ssh/access.js';
import { SSH_DEFAULTS } from '../../ssh/config.js';
import { sshExec, sshUpload, sshDownload, openShellChannel } from '../../ssh/session.js';
import {
  startTestServer,
  generateRsaPair,
  type RunningTestServer,
  type ShellHandler,
} from '../../ssh/session-test-server.js';
import { setSshSubsystem, type SshSubsystem } from './ssh.js';
import { executeTool } from './index.js';
import type { ToolContext } from './core.js';
import { SessionRegistry } from '../../ssh/console-registry.js';

const skip = process.env.SKIP_SSH_E2E === '1';
const VALID_KEY = 'a'.repeat(64);

function bootstrapDb(): Database.Database {
  process.env.MCP_ENCRYPTION_KEY = VALID_KEY;
  const db = new Database(':memory:');
  db.pragma('foreign_keys = ON');
  db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, role TEXT);`);
  db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
  db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
  runMigrations(db);
  db.prepare(`INSERT INTO users (id, role) VALUES (?, ?)`).run('alice', 'member');
  return db;
}

function buildSubsystem(
  db: Database.Database,
  clientPem: Buffer,
  decryptPemSwap: (blob: Buffer) => Buffer = (b) => b,
): SshSubsystem {
  const conn = createConnectionRepo(db);
  const grants = createGrantsRepo(db);
  const audit = createAuditRepo(db);
  const abuse = createAbuseRepo(db, { windowMinutes: 10, failureThreshold: 5, lockMinutes: 30 });
  const access = createAccessResolver(grants, { adminBypassesGrants: true });
  void clientPem;
  return {
    connectionRepo: conn,
    auditRepo: audit,
    abuseRepo: abuse,
    accessResolver: access,
    decryptKeyMaterial: (_ownerId, blob) => decryptPemSwap(blob),
    decryptPassphrase: () => null,
    getUserAccess: () => ({ isAdmin: false, orgIds: [] }),
    sshExec,
    sshUpload,
    sshDownload,
    maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => {}, exit: () => {} },
    config: { ...SSH_DEFAULTS, allowPrivateAddresses: true, callTimeoutSeconds: 10 },
    sessionRegistry: {
      register: () => undefined,
      get: () => null,
      listAll: () => [],
      listForConnection: () => [],
      closeForTask: async () => undefined,
      enforceCap: () => [],
      sweep: async () => undefined,
      startSweepTimer: () => undefined,
      stopSweepTimer: () => undefined,
      shutdown: async () => undefined,
    } as unknown as SshSubsystem['sessionRegistry'],
    openShellChannel: async () => {
      throw new Error('openShellChannel not used in this e2e');
    },
  };
}

describe.skipIf(skip)('engine/tools/ssh e2e (in-process ssh2 server)', () => {
  let server: RunningTestServer;
  let clientPem: Buffer;
  let db: Database.Database;
  let connRepo: SshConnectionRepo;
  let auditRepo: SshAuditRepo;
  let connId: string;
  let workspace: string;

  beforeEach(async () => {
    server = await startTestServer();
    clientPem = generateRsaPair().privatePem;
    db = bootstrapDb();
    // We stash the *actual* PEM as the "encrypted" blob and provide an identity
    // decrypt swap that returns it back. The real wiring uses AES-256-GCM via
    // crypto.ts; this shortcut keeps the e2e focused on the tool flow.
    const subsystem = buildSubsystem(db, clientPem, () => clientPem);
    setSshSubsystem(subsystem);
    connRepo = subsystem.connectionRepo;
    auditRepo = subsystem.auditRepo;
    const created = connRepo.create({
      ownerId: 'alice',
      label: 'fixture',
      host: '127.0.0.1',
      port: server.port,
      username: 'testuser',
      privateKeyEnc: clientPem, // identity-decrypt
      keyFingerprint: 'SHA256:test',
      remotePathPrefix: '/srv/agent',
      allowPrivateAddresses: true,
    });
    // Pin the server's host key as verified to mirror a happy-path connection.
    db.prepare(
      `UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
    ).run(server.hostKeyOpenSshB64, server.hostKeyFingerprint, new Date().toISOString(), created.id);
    connId = created.id;
    workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-e2e-'));
  });

  afterEach(async () => {
    setSshSubsystem(null);
    await server.close();
  });

  function ctx(opts: Partial<ToolContext> = {}): ToolContext {
    return {
      workspacePath: workspace,
      editAllowed: true,
      userId: 'alice',
      ownerId: 'alice',
      pieceName: 'ops',
      allowedSshConnections: [connId],
      jobId: 'job-e2e',
      ...opts,
    };
  }

  it('SshExec — runs whoami against the in-process server', async () => {
    const r = await executeTool('SshExec', { connection_id: connId, command: 'whoami' }, ctx());
    expect(r.isError).toBe(false);
    const parsed = JSON.parse(r.output) as { stdout: string; exit_code: number };
    expect(parsed.exit_code).toBe(0);
    // The test fixture defaults to an echo handler: stdout = "echo: <cmd>"
    expect(parsed.stdout).toContain('echo: whoami');
    // Audit row exists
    const rows = auditRepo.listForConnection(connId, 5);
    expect(rows[0].outcome).toBe('success');
    expect(rows[0].action).toBe('ssh.exec');
  });

  it('SshUpload — uploads a workspace file via SFTP', async () => {
    await fsp.writeFile(path.join(workspace, 'hello.txt'), 'hi from e2e');
    const r = await executeTool(
      'SshUpload',
      { connection_id: connId, local_path: 'hello.txt', remote_path: '/srv/agent/hello.txt' },
      ctx(),
    );
    expect(r.isError).toBe(false);
    const body = JSON.parse(r.output) as { ok: boolean; bytes: number; remote: string };
    expect(body.ok).toBe(true);
    expect(body.remote).toBe('/srv/agent/hello.txt');
    expect(server.getFile('/srv/agent/hello.txt')?.toString()).toBe('hi from e2e');
    const rows = auditRepo.listForConnection(connId, 5);
    expect(rows[0].outcome).toBe('success');
    expect(rows[0].action).toBe('ssh.upload');
  });

  it('SshDownload — pulls a remote file into the workspace', async () => {
    server.setFile('/srv/agent/data.txt', Buffer.from('remote-payload'));
    // sshDownload writes via O_CREAT|O_EXCL into the resolved local path; the
    // parent directory must exist beforehand. Caller is responsible for
    // mkdir-p (this matches Write/Upload contracts elsewhere).
    await fsp.mkdir(path.join(workspace, 'output'), { recursive: true });
    const r = await executeTool(
      'SshDownload',
      { connection_id: connId, remote_path: '/srv/agent/data.txt', local_path: 'output/data.txt' },
      ctx(),
    );
    expect(r.isError).toBe(false);
    const body = JSON.parse(r.output) as { ok: boolean; bytes: number; local: string };
    expect(body.ok).toBe(true);
    expect(body.bytes).toBe('remote-payload'.length);
    const localContent = await fsp.readFile(path.join(workspace, 'output', 'data.txt'), 'utf-8');
    expect(localContent).toBe('remote-payload');
    const rows = auditRepo.listForConnection(connId, 5);
    expect(rows[0].outcome).toBe('success');
    expect(rows[0].action).toBe('ssh.download');
  });

  it('SshExec — rejects with host_key_not_verified when the verified timestamp is cleared', async () => {
    // Production-realistic state: an earlier observation set host_key_b64 but
    // the user hasn't clicked "Verify" yet, so host_key_verified_at is NULL.
    // session.connect() short-circuits BEFORE observing the live key —
    // first_observe / mismatch verdicts are captured via the /test endpoint
    // (sshTest), not via SshExec. See ssh-tools.md for the recommended flow.
    db.prepare(`UPDATE ssh_connections SET host_key_verified_at=NULL WHERE id=?`).run(connId);
    const r = await executeTool('SshExec', { connection_id: connId, command: 'whoami' }, ctx());
    expect(r.isError).toBe(true);
    expect(r.output).toMatch(/not user-verified/);
    const rows = auditRepo.listForConnection(connId, 5);
    expect(rows[0].outcome).toBe('failed');
    expect(rows[0].detail).toMatchObject({ error: 'host_key_not_verified' });
  });
});

// ───────────────────────────────────────────────────────────────────────
// SSH Console e2e — Phase 9 / Task 23.
//
// The in-process ssh2 test server is extended with a `shell` handler that
// simulates a tiny line-discipline shell: it echoes typed bytes, tracks a
// pwd state, and responds to a handful of canned commands (uptime / pwd /
// echo / cd). This is enough to verify the full Ensure→Send→Snapshot flow
// through the real ssh2 ClientChannel + headless xterm pipeline. We pair
// it with a real SessionRegistry + the real `openShellChannel` from
// session.ts (the SshExec block above stubs both, since those tools
// don't open shell channels).
//
// Gated by SKIP_SSH_E2E=1, same as the SshExec block.
// ───────────────────────────────────────────────────────────────────────

function makeShellHandler(): ShellHandler {
  return ({ writeOut, onData }) => {
    let line = '';
    let pwd = '/home/testuser';
    const prompt = () => writeOut(`${pwd}$ `);
    writeOut('Last login: e2e fixture\r\n');
    prompt();
    onData((chunk) => {
      const text = chunk.toString('utf8');
      for (const ch of text) {
        if (ch === '\r' || ch === '\n') {
          writeOut('\r\n');
          const cmd = line.trim();
          line = '';
          if (cmd === 'uptime') {
            writeOut(' 12:00:00 up 1 day,  3:14,  1 user,  load average: 0.42, 0.31, 0.28\r\n');
          } else if (cmd === 'pwd') {
            writeOut(`${pwd}\r\n`);
          } else if (cmd.startsWith('cd ')) {
            pwd = cmd.slice(3).trim() || '/';
          } else if (cmd.startsWith('echo ')) {
            writeOut(cmd.slice(5) + '\r\n');
          } else if (cmd === '') {
            // empty — just re-prompt
          } else {
            writeOut(`bash: ${cmd}: command not found\r\n`);
          }
          prompt();
        } else if (ch === '' || ch === '\b') {
          if (line.length > 0) {
            line = line.slice(0, -1);
            writeOut('\b \b');
          }
        } else {
          line += ch;
          writeOut(ch); // local echo so xterm renders the typed chars
        }
      }
    });
    return () => { /* nothing to clean up */ };
  };
}

describe.skipIf(skip)('engine/tools/ssh-console e2e (in-process ssh2 server)', () => {
  let server: RunningTestServer;
  let clientPem: Buffer;
  let db: Database.Database;
  let connRepo: SshConnectionRepo;
  let connId: string;
  let workspace: string;
  let sessionRegistry: SessionRegistry;
  let subsystem: SshSubsystem;

  beforeEach(async () => {
    server = await startTestServer({ shell: makeShellHandler() });
    clientPem = generateRsaPair().privatePem;
    db = bootstrapDb();
    // Seed a local_tasks row so ctx.taskId="1" resolves to a real task in
    // the future (we don't depend on it here, but matching the prod shape
    // keeps the test honest if a downstream lookup is added).
    db.prepare(`INSERT INTO local_tasks DEFAULT VALUES`).run();

    sessionRegistry = new SessionRegistry({
      idleTimeoutMs: 60_000,
      maxSessionDurationMs: 600_000,
      maxSessionsPerConnection: 3,
    });
    const built = buildSubsystem(db, clientPem, () => clientPem);
    subsystem = {
      ...built,
      // Override the no-op session bits with real production pieces so we
      // exercise the full Console path.
      sessionRegistry: sessionRegistry as unknown as SshSubsystem['sessionRegistry'],
      openShellChannel,
      config: {
        ...built.config,
        console: {
          enabled: true,
          idleTimeoutSeconds: 60,
          maxSessionDurationSeconds: 600,
          scrollbackBytes: 16_384,
          maxSessionsPerConnection: 3,
          maxInputBytesPerSend: 4096,
          autoInjectScreenLines: 24,
          defaultCols: 80,
          defaultRows: 24,
        },
      },
    };
    setSshSubsystem(subsystem);
    connRepo = subsystem.connectionRepo;
    const created = connRepo.create({
      ownerId: 'alice',
      label: 'fixture',
      host: '127.0.0.1',
      port: server.port,
      username: 'testuser',
      privateKeyEnc: clientPem,
      keyFingerprint: 'SHA256:test',
      remotePathPrefix: '/srv/agent',
      allowPrivateAddresses: true,
    });
    db.prepare(
      `UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
    ).run(server.hostKeyOpenSshB64, server.hostKeyFingerprint, new Date().toISOString(), created.id);
    connId = created.id;
    workspace = await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-console-e2e-'));
  });

  afterEach(async () => {
    await sessionRegistry.shutdown();
    setSshSubsystem(null);
    // Use forceClose because the ssh2 Client behind each ConsoleSession is
    // long-lived — ConsoleSession.close() ends the channel but does NOT
    // close the underlying Client (the ssh2 Client owns the TCP socket and
    // is not exposed back through SessionRegistry). server.close() would
    // wait forever on those sockets; forceClose destroys live clients
    // first.
    await server.forceClose();
  });

  function ctx(taskId: string): ToolContext {
    return {
      workspacePath: workspace,
      editAllowed: true,
      userId: 'alice',
      ownerId: 'alice',
      pieceName: 'ops-console',
      allowedSshConnections: [connId],
      jobId: `job-console-${taskId}`,
      taskId,
    };
  }

  // Helper: poll briefly until the shell has flushed expected text. The
  // ssh2 data event is async; without this we'd race the prompt write.
  async function waitForScreen(taskId: string, needle: string, timeoutMs = 2000): Promise<string> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      const session = sessionRegistry.get(taskId);
      if (session) {
        const text = session.snapshotScreen().text;
        if (text.includes(needle)) return text;
      }
      await new Promise((res) => setTimeout(res, 50));
    }
    const session = sessionRegistry.get(taskId);
    return session ? session.snapshotScreen().text : '';
  }

  it('Ensure opens a session and the welcome banner is reflected in the screen', async () => {
    const r = await executeTool('SshConsoleEnsure', { connection_id: connId }, ctx('1'));
    expect(r?.isError).toBe(false);
    const data = JSON.parse(r!.output);
    expect(data.opened).toBe(true);
    // Banner from makeShellHandler() — wait for it to land on the screen.
    const text = await waitForScreen('1', 'Last login');
    expect(text).toContain('Last login');
  });

  it('Send(uptime\\n) → screen_after contains load average', async () => {
    const ensure = await executeTool('SshConsoleEnsure', { connection_id: connId }, ctx('2'));
    expect(ensure?.isError).toBe(false);
    // Wait for the initial prompt before sending.
    await waitForScreen('2', '$ ');

    const send = await executeTool(
      'SshConsoleSend',
      { connection_id: connId, input: 'uptime\n', wait_ms: 800 },
      ctx('2'),
    );
    expect(send?.isError).toBe(false);
    const data = JSON.parse(send!.output);
    expect(data.bytes_sent).toBeGreaterThan(0);
    expect(data.screen_after).toMatch(/load average/i);
  });

  it('shell state persists across sends in same task (cd /tmp ; pwd)', async () => {
    await executeTool('SshConsoleEnsure', { connection_id: connId }, ctx('3'));
    await waitForScreen('3', '$ ');

    await executeTool(
      'SshConsoleSend',
      { connection_id: connId, input: 'cd /tmp\n', wait_ms: 400 },
      ctx('3'),
    );
    const r = await executeTool(
      'SshConsoleSend',
      { connection_id: connId, input: 'pwd\n', wait_ms: 600 },
      ctx('3'),
    );
    expect(r?.isError).toBe(false);
    const data = JSON.parse(r!.output);
    expect(data.screen_after).toContain('/tmp');
  });

  it('Snapshot(kind=scrollback) returns recent output text', async () => {
    await executeTool('SshConsoleEnsure', { connection_id: connId }, ctx('4'));
    await waitForScreen('4', '$ ');
    await executeTool(
      'SshConsoleSend',
      { connection_id: connId, input: 'echo hello-scrollback\n', wait_ms: 500 },
      ctx('4'),
    );
    // Drain the prompt write that follows the echo.
    await waitForScreen('4', 'hello-scrollback');

    const r = await executeTool(
      'SshConsoleSnapshot',
      { connection_id: connId, kind: 'scrollback', max_bytes: 4096 },
      ctx('4'),
    );
    expect(r?.isError).toBe(false);
    const data = JSON.parse(r!.output);
    expect(data.kind).toBe('scrollback');
    expect(data.text).toContain('hello-scrollback');
  });

  it('Ensure is idempotent — second call reuses the existing session', async () => {
    const first = await executeTool('SshConsoleEnsure', { connection_id: connId }, ctx('5'));
    const firstData = JSON.parse(first!.output);
    expect(firstData.opened).toBe(true);

    const second = await executeTool('SshConsoleEnsure', { connection_id: connId }, ctx('5'));
    const secondData = JSON.parse(second!.output);
    expect(secondData.opened).toBe(false);
    // Same registry slot still holds the live session.
    expect(sessionRegistry.get('5')).not.toBeNull();
  });
});
