/**
 * Tests for workspace-scoped SSH connection resolution (fix/workspace-ssh-connections).
 *
 * Two concerns:
 *  1. Resolution contract: getSshSubsystem().connectionRepo.listBySpace(spaceId) returns
 *     only the IDs registered to that space, never another space's connections.
 *  2. piece-runner threading: workspaceSshConnections option overrides
 *     movement.allowed_ssh_connections in ctx, and falls back to movement when absent.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { execFileSync } from 'child_process';
import { runMigrations } from '../db/migrate.js';
import { createConnectionRepo, type CreateConnectionInput } from '../ssh/connection-repo.js';
import type { PieceDef } from './piece-runner.js';
import type { MovementResult } from './agent-loop.js';

// ── Mock agent-loop so runPiece short-circuits without an LLM ──────────────
vi.mock('./agent-loop.js', () => ({
  executeMovement: vi.fn(),
}));

import { executeMovement } from './agent-loop.js';
import { runPiece } from './piece-runner.js';

const executeMovementMock = vi.mocked(executeMovement);

// ── DB helpers ────────────────────────────────────────────────────────────

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);`);
  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) VALUES (?), (?)`).run('alice', 'bob');
  return db;
}

function baseInput(overrides: Partial<CreateConnectionInput> = {}): CreateConnectionInput {
  return {
    ownerId: 'alice',
    label: 'prod',
    host: 'srv.example.com',
    port: 22,
    username: 'deploy',
    privateKeyEnc: Buffer.from([1, 2, 3]),
    keyFingerprint: 'SHA256:abc',
    remotePathPrefix: '/home/deploy',
    spaceId: null,
    ...overrides,
  };
}

// ── Workspace helpers ──────────────────────────────────────────────────────

function makeGitWorkspace(): string {
  const dir = mkdtempSync(join(tmpdir(), 'test-ws-ssh-'));
  execFileSync('git', ['init', dir]);
  execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
  execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
  return dir;
}

// ── Minimal piece for runPiece invocation ──────────────────────────────────

function makePiece(allowedSsh?: string[]): PieceDef {
  return {
    name: 'test-piece',
    description: 'test',
    max_movements: 5,
    initial_movement: 'execute',
    movements: [
      {
        name: 'execute',
        edit: false,
        persona: 'worker',
        instruction: 'do it',
        allowed_tools: ['Read'],
        allowed_ssh_connections: allowedSsh,
        rules: [],
        default_next: 'COMPLETE',
      },
    ],
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// 1. listBySpace cross-space isolation
// ─────────────────────────────────────────────────────────────────────────────

describe('SshConnectionRepo.listBySpace — cross-space isolation', () => {
  let db: Database.Database;

  beforeEach(() => {
    db = bootstrapDb();
  });

  afterEach(() => {
    db.close();
    delete process.env.MCP_ENCRYPTION_KEY;
  });

  it('returns only connections registered to the queried space', () => {
    const repo = createConnectionRepo(db);

    const a1 = repo.create(baseInput({ spaceId: 'space-A', label: 'a1' }));
    const a2 = repo.create(baseInput({ spaceId: 'space-A', label: 'a2' }));
    const b1 = repo.create(baseInput({ spaceId: 'space-B', label: 'b1' }));
    // space-less connection (should NEVER appear in space-A results)
    repo.create(baseInput({ spaceId: null, label: 'legacy' }));

    const ids = repo.listBySpace('space-A').map((c) => c.id);

    expect(ids).toHaveLength(2);
    expect(ids).toContain(a1.id);
    expect(ids).toContain(a2.id);
    // Cross-space isolation: space-B connection must NOT be visible to space-A
    expect(ids).not.toContain(b1.id);
  });

  it('returns an empty array when no connections are registered to a space', () => {
    const repo = createConnectionRepo(db);
    // Register a connection in a different space only
    repo.create(baseInput({ spaceId: 'space-other', label: 'other' }));

    expect(repo.listBySpace('space-empty')).toEqual([]);
  });

  it('space-less (null space_id) connections are excluded from listBySpace', () => {
    const repo = createConnectionRepo(db);
    // Only a space-less connection exists
    repo.create(baseInput({ spaceId: null, label: 'legacy' }));

    // No connection registered to space-X — listBySpace must return []
    expect(repo.listBySpace('space-X')).toEqual([]);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// 2. piece-runner: workspaceSshConnections option threading
// ─────────────────────────────────────────────────────────────────────────────

describe('piece-runner workspaceSshConnections threading', () => {
  beforeEach(() => {
    executeMovementMock.mockReset();
    // By default, executeMovement resolves as if the movement completed.
    executeMovementMock.mockResolvedValue({
      status: 'aborted',
      abortReason: 'agent_self_abort',
      output: '',
    } as MovementResult);
  });

  it('ctx.allowedSshConnections reflects workspaceSshConnections when provided', async () => {
    const ws = makeGitWorkspace();
    const wsConns = ['conn-aaa-111', 'conn-bbb-222'];
    const piece = makePiece(undefined); // no movement declaration

    try {
      await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {
        workspaceSshConnections: wsConns,
      });

      expect(executeMovementMock).toHaveBeenCalled();
      const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] };
      // Workspace-scoped list governs
      expect(ctx.allowedSshConnections).toEqual(wsConns);
    } finally {
      rmSync(ws, { recursive: true });
    }
  });

  it('workspaceSshConnections overrides movement.allowed_ssh_connections', async () => {
    const ws = makeGitWorkspace();
    const movementConns = ['conn-from-movement-decl'];
    const wsConns = ['conn-from-workspace'];
    const piece = makePiece(movementConns); // movement has its own declaration

    try {
      await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {
        workspaceSshConnections: wsConns,
      });

      expect(executeMovementMock).toHaveBeenCalled();
      const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] };
      // Workspace wins; movement declaration ignored
      expect(ctx.allowedSshConnections).toEqual(wsConns);
      expect(ctx.allowedSshConnections).not.toContain('conn-from-movement-decl');
    } finally {
      rmSync(ws, { recursive: true });
    }
  });

  // Security: a movement declaring the legacy wildcard ['*'] must be fully
  // suppressed when the workspace supplies a scoped connection list. Proves the
  // "NEVER a global wildcard under the workspace model" guarantee is testable.
  it('workspace list suppresses a movement-declared ["*"] wildcard', async () => {
    const ws = makeGitWorkspace();
    const wsConns = ['conn-from-workspace'];
    const piece = makePiece(['*']); // movement declares the all-connections wildcard

    try {
      await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {
        workspaceSshConnections: wsConns,
      });

      const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] };
      expect(ctx.allowedSshConnections).toEqual(wsConns);   // scoped list, NOT ['*']
      expect(ctx.allowedSshConnections).not.toContain('*'); // wildcard fully suppressed
    } finally {
      rmSync(ws, { recursive: true });
    }
  });

  it('empty workspaceSshConnections ([]) is respected (ssh enabled, no registered connections)', async () => {
    const ws = makeGitWorkspace();
    const piece = makePiece(['conn-from-movement-decl']); // movement declaration present

    try {
      await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {
        workspaceSshConnections: [], // ssh enabled but 0 connections in space
      });

      expect(executeMovementMock).toHaveBeenCalled();
      const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] };
      // Empty list propagates — preflight will cleanly reject any connection attempt
      expect(ctx.allowedSshConnections).toEqual([]);
    } finally {
      rmSync(ws, { recursive: true });
    }
  });

  it('does NOT fall back to a movement declaration when workspaceSshConnections is absent (PR B: empty)', async () => {
    const ws = makeGitWorkspace();
    // Even if a legacy movement declaration is present, it is ignored — pieces no
    // longer gate SSH connections.
    const piece = makePiece(['conn-from-movement-decl']);

    try {
      // No workspaceSshConnections option — unit-test / legacy caller path
      await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {});

      expect(executeMovementMock).toHaveBeenCalled();
      const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] };
      // Empty (never the movement declaration) → ssh.ts preflight cleanly rejects.
      expect(ctx.allowedSshConnections).toEqual([]);
    } finally {
      rmSync(ws, { recursive: true });
    }
  });

  it('ctx.allowedSshConnections is [] when neither workspaceSshConnections nor a movement declaration is present', async () => {
    const ws = makeGitWorkspace();
    const piece = makePiece(undefined); // no movement declaration

    try {
      // No workspaceSshConnections — ssh not enabled in policy
      await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {});

      expect(executeMovementMock).toHaveBeenCalled();
      const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] };
      // Empty array → ssh.ts preflight produces the "no connections available" error.
      expect(ctx.allowedSshConnections).toEqual([]);
    } finally {
      rmSync(ws, { recursive: true });
    }
  });
});
