/**
 * Regression tests for SSH Console per-space isolation (spec §11).
 *
 * Bug: the console open path (openConsoleSession + the agent SshConsoleEnsure
 * tool + the user-initiated REST route) never threaded the job/task's space
 * into the access resolver. With `spaceId` missing, resolveAccess treated the
 * caller as a no-space (legacy) job and denied EVERY space-scoped connection
 * with `space_mismatch` — i.e. every connection registered inside a workspace
 * became unusable from the console, even though SshExec (which DOES pass
 * ctx.spaceId) worked fine.
 *
 * Strategy: bring up a real in-memory SQLite + real repos + the REAL access
 * resolver (the stubbed resolver in ssh-console.test.ts always allows, so it
 * cannot catch this). `openShellChannel` is stubbed to THROW a sentinel: a
 * connection that CLEARS preflight reaches it and fails with
 * `open_shell_failed`; a connection denied at preflight never gets there and
 * fails with `preflight_denied` + a space_mismatch message. That lets us
 * distinguish "preflight passed" from "space_mismatch denial" without
 * constructing a live ConsoleSession.
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../db/migrate.js';
import { createConnectionRepo, type SshConnectionRepo } from '../../ssh/connection-repo.js';
import { createGrantsRepo } from '../../ssh/grants-repo.js';
import { createAuditRepo } from '../../ssh/audit-repo.js';
import { createAbuseRepo } from '../../ssh/abuse-repo.js';
import { createAccessResolver } from '../../ssh/access.js';
import { SessionRegistry } from '../../ssh/console-registry.js';
import { SSH_DEFAULTS } from '../../ssh/config.js';
import { preflight, setSshSubsystem, type SshSubsystem } from './ssh.js';
import { openConsoleSession } from './ssh-console.js';
import { CONSOLE_SPACE_DENY } from '../../bridge/console-space-resolver.js';
import { executeTool } from './index.js';
import type { ToolContext } from './core.js';

const VALID_KEY = 'a'.repeat(64);
const SHELL_SENTINEL = 'SENTINEL_openShellChannel_reached';

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 makeSubsystem(db: Database.Database): { sub: SshSubsystem; conn: SshConnectionRepo } {
  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 });
  const sub = {
    connectionRepo: conn,
    auditRepo: audit,
    abuseRepo: abuse,
    accessResolver: access,
    // identity decrypt — we never reach a real SSH handshake (openShellChannel throws).
    decryptKeyMaterial: (_o: string | null, blob: Buffer) => Buffer.from(blob),
    decryptPassphrase: (_o: string | null, blob: Buffer | null) => (blob ? Buffer.from(blob) : null),
    getUserAccess: (_userId: string) => ({ isAdmin: false, orgIds: [] }),
    sshExec: async () => ({ outputJson: '{}', exitCode: 0, durationMs: 1, hostFingerprint: 'x' }),
    sshUpload: async () => ({ bytes: 0, durationMs: 1, hostFingerprint: 'x' }),
    sshDownload: async () => ({ bytes: 0, durationMs: 1, hostFingerprint: 'x' }),
    maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => undefined, exit: () => undefined },
    // Enable the console (disabled by default) so the agent-tool wrapper reaches preflight.
    config: { ...SSH_DEFAULTS, console: { ...SSH_DEFAULTS.console, enabled: true } },
    // REAL registry, not a stub: every test here fails preflight or dies at
    // the openShellChannel sentinel, so no session is ever registered and the
    // registry stays empty (no sweep timer is started without an explicit
    // startSweepTimer call). A previous hand-rolled stub silently broke when
    // multi-session (#732) added listForTask/closeSession to the registry API;
    // the real class cannot drift like that.
    sessionRegistry: new SessionRegistry({
      idleTimeoutMs: 60_000,
      maxSessionDurationMs: 600_000,
      maxSessionsPerConnection: 2,
    }),
    // A cleared-preflight session reaches this and fails here (open_shell_failed).
    openShellChannel: async () => {
      throw new Error(SHELL_SENTINEL);
    },
  } as unknown as SshSubsystem;
  return { sub, conn };
}

/** A space-scoped connection with a verified host key (console requires it). */
function createSpaceConn(conn: SshConnectionRepo, db: Database.Database, spaceId: string): string {
  const created = conn.create({
    ownerId: 'alice',
    spaceId,
    label: `srv-${spaceId}`,
    host: 'srv.example.com',
    port: 22,
    username: 'deploy',
    privateKeyEnc: Buffer.from('encrypted-pem'),
    keyFingerprint: 'SHA256:fp',
    remotePathPrefix: '/srv/agent',
  });
  db.prepare(
    `UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
  ).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
  return created.id;
}

describe('SSH Console — per-space isolation (spec §11)', () => {
  let db: Database.Database;
  let sub: SshSubsystem;
  let conn: SshConnectionRepo;
  let connA: string;

  beforeEach(() => {
    db = bootstrapDb();
    ({ sub, conn } = makeSubsystem(db));
    connA = createSpaceConn(conn, db, 'space-A');
  });

  afterEach(() => {
    setSshSubsystem(null);
    db.close();
  });

  describe('openConsoleSession core', () => {
    it('a job in space A can open a console on the space-A connection (space threaded)', async () => {
      const result = await openConsoleSession(
        { sub, preflight },
        {
          taskId: 'task-1',
          connectionId: connA,
          ownerId: 'alice',
          userId: 'alice',
          pieceName: 'ops',
          allowedConnections: ['*'],
          initiator: 'user',
          spaceId: 'space-A',
        },
      );
      // Preflight must PASS: it proceeds to openShellChannel (our sentinel).
      expect(result.error).not.toBe('preflight_denied');
      expect(result.message ?? '').not.toMatch(/space_mismatch/);
      expect(result.error).toBe('open_shell_failed');
    });

    it('a job in space B is still denied the space-A connection (space_mismatch)', async () => {
      const result = await openConsoleSession(
        { sub, preflight },
        {
          taskId: 'task-1',
          connectionId: connA,
          ownerId: 'alice',
          userId: 'alice',
          pieceName: 'ops',
          allowedConnections: ['*'],
          initiator: 'user',
          spaceId: 'space-B',
        },
      );
      expect(result.error).toBe('preflight_denied');
      expect(result.message ?? '').toMatch(/space_mismatch/);
    });

    it('a no-space job is denied a space-scoped connection (space_mismatch)', async () => {
      const result = await openConsoleSession(
        { sub, preflight },
        {
          taskId: 'task-1',
          connectionId: connA,
          ownerId: 'alice',
          userId: 'alice',
          pieceName: 'ops',
          allowedConnections: ['*'],
          initiator: 'user',
          // no spaceId
        },
      );
      expect(result.error).toBe('preflight_denied');
      expect(result.message ?? '').toMatch(/space_mismatch/);
    });
  });

  describe('deny sentinel blocks the legacy space-less path', () => {
    /** A space-LESS (legacy/system) connection owned by alice, host-key verified. */
    function createSpacelessConn(): string {
      const created = conn.create({
        ownerId: 'alice',
        spaceId: null,
        label: 'legacy-srv',
        host: 'legacy.example.com',
        port: 22,
        username: 'deploy',
        privateKeyEnc: Buffer.from('encrypted-pem'),
        keyFingerprint: 'SHA256:fp',
        remotePathPrefix: '/srv/agent',
      });
      db.prepare(
        `UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
      ).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
      return created.id;
    }

    it('genuine no-space (null) reaches a space-less connection via the owner legacy path', async () => {
      const spaceless = createSpacelessConn();
      const result = await openConsoleSession(
        { sub, preflight },
        {
          taskId: 'task-1', connectionId: spaceless, ownerId: 'alice', userId: 'alice',
          pieceName: 'ops', allowedConnections: ['*'], initiator: 'user',
          spaceId: null, // genuine no-space legacy task
        },
      );
      expect(result.error).not.toBe('preflight_denied'); // owner path allows → shell open
      expect(result.error).toBe('open_shell_failed');
    });

    it('DENY sentinel is NOT no-space: it blocks the same space-less connection', async () => {
      const spaceless = createSpacelessConn();
      const result = await openConsoleSession(
        { sub, preflight },
        {
          taskId: 'task-1', connectionId: spaceless, ownerId: 'alice', userId: 'alice',
          pieceName: 'ops', allowedConnections: ['*'], initiator: 'user',
          spaceId: CONSOLE_SPACE_DENY, // unauthorized space-scoped task → hard deny
        },
      );
      expect(result.error).toBe('preflight_denied');
      expect(result.message ?? '').toMatch(/space_mismatch/);
    });
  });

  describe('SshConsoleEnsure agent tool', () => {
    function mkCtx(spaceId: string | undefined): ToolContext {
      return {
        workspacePath: '/tmp',
        editAllowed: false,
        taskId: 'task-1',
        userId: 'alice',
        ownerId: 'alice',
        jobId: 'job-1',
        pieceName: 'ops',
        // Worker resolves the space-scoped list; the connection IS registered here.
        allowedSshConnections: [connA],
        spaceId,
      } as ToolContext;
    }

    it('threads ctx.spaceId so a space-A connection clears preflight', async () => {
      setSshSubsystem(sub);
      const res = await executeTool('SshConsoleEnsure', { connection_id: connA }, mkCtx('space-A'));
      // Preflight passes → we fail at the (stubbed) shell open, NOT at space_mismatch.
      expect(res.isError).toBe(true);
      expect(res.output).not.toMatch(/space_mismatch/);
      expect(res.output).toMatch(/failed to open shell channel/);
    });

    it('a space-B job cannot open the space-A connection', async () => {
      setSshSubsystem(sub);
      const res = await executeTool('SshConsoleEnsure', { connection_id: connA }, mkCtx('space-B'));
      expect(res.isError).toBe(true);
      expect(res.output).toMatch(/space_mismatch/);
    });
  });
});
