import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry } from '../mcp/registry.js';
import { listMcpCandidates, transferMcpServer, listSshCandidates, transferSshConnection, listTransferCandidates, executeTransfer } from './space-credential-transfer.js';
import { createConnectionRepo } from '../ssh/connection-repo.js';
import { encryptPrivateKey, decryptPrivateKey, bootstrapSystemDek } from '../ssh/crypto.js';

function freshDb(): Database.Database {
  const db = new Database(':memory:');
  db.pragma('foreign_keys = ON');
  // runMigrations needs these base tables to exist (it ALTERs them).
  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);
  return db;
}

describe('MCP cross-space transfer', () => {
  let db: Database.Database;
  beforeEach(() => {
    process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64);
    db = freshDb();
  });

  it('lists source MCP servers and flags same-name targets as skip', () => {
    const reg = createRegistry(db);
    reg.upsert({ id: 'a1', name: 'NocoDB', url: 'https://noco.example', authKind: 'api_key', staticToken: 'tok', ownerId: null, spaceId: 'A' });
    reg.upsert({ id: 'b1', name: 'NocoDB', url: 'https://noco.example', authKind: 'api_key', staticToken: 'other', ownerId: null, spaceId: 'B' });
    const cands = listMcpCandidates({ db }, 'A', 'B');
    expect(cands).toHaveLength(1);
    expect(cands[0]).toMatchObject({ type: 'mcp', name: 'NocoDB', willSkip: true });
  });

  it('flags same-url (different name) targets as skip', () => {
    const reg = createRegistry(db);
    reg.upsert({ id: 'a1', name: 'X', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' });
    reg.upsert({ id: 'b1', name: 'Y', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'B' });
    const cands = listMcpCandidates({ db }, 'A', 'B');
    expect(cands).toHaveLength(1);
    expect(cands[0]).toMatchObject({ type: 'mcp', name: 'X', willSkip: true });
  });

  it('copies an MCP server into the target space, re-encrypting the secret', () => {
    const reg = createRegistry(db);
    reg.upsert({ id: 'a1', name: 'Linear', url: 'https://linear.example', authKind: 'api_key', staticToken: 'secret-token', ownerId: null, spaceId: 'A' });
    const out = transferMcpServer({ db }, 'a1', 'A', 'B', 'user-x');
    expect(out.copied).toMatchObject({ type: 'mcp', name: 'Linear' });
    const copied = reg.listForSpace('B');
    expect(copied).toHaveLength(1);
    const dec = reg.getDecrypted(copied[0].id)!;
    expect(dec.staticToken).toBe('secret-token');
    expect(dec.spaceId).toBe('B');
    expect(copied[0].id).not.toBe('a1'); // new id
  });

  it('skips copy when same-url already exists in target', () => {
    const reg = createRegistry(db);
    reg.upsert({ id: 'a1', name: 'X', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' });
    reg.upsert({ id: 'b1', name: 'Y', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'B' });
    const out = transferMcpServer({ db }, 'a1', 'A', 'B', 'user-x');
    expect(out.skipped).toMatchObject({ type: 'mcp', name: 'X' });
    expect(reg.listForSpace('B')).toHaveLength(1);
  });
});

describe('SSH cross-space transfer', () => {
  let db: Database.Database;
  beforeEach(() => {
    process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64);
    db = freshDb();
    bootstrapSystemDek(db);
  });

  function seedSsh(spaceId: string, label: string, pem: string): string {
    const repo = createConnectionRepo(db);
    const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), spaceId);
    return repo.create({
      ownerId: null, spaceId, label, host: 'h.example', port: 22, username: 'svc',
      privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: 'SHA256:x',
      remotePathPrefix: '/srv', allowRemoteUnrestricted: false, allowPrivateAddresses: false,
      commandDenyPatterns: null, commandAllowPatterns: null,
    }).id;
  }

  it('lists SSH candidates and flags same-label targets as skip', () => {
    seedSsh('A', 'prod', '-----BEGIN OPENSSH PRIVATE KEY-----\nKEY\n-----END OPENSSH PRIVATE KEY-----');
    seedSsh('B', 'prod', '-----BEGIN OPENSSH PRIVATE KEY-----\nKEY2\n-----END OPENSSH PRIVATE KEY-----');
    const cands = listSshCandidates({ db }, 'A', 'B');
    expect(cands).toHaveLength(1);
    expect(cands[0]).toMatchObject({ type: 'ssh', name: 'prod', willSkip: true });
  });

  it('re-encrypts the private key under the target space DEK (round-trip)', () => {
    const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEYMATERIAL\n-----END OPENSSH PRIVATE KEY-----';
    const srcId = seedSsh('A', 'prod', pem);
    const out = transferSshConnection({ db }, srcId, 'A', 'B', 'user-x');
    expect(out.copied).toMatchObject({ type: 'ssh', name: 'prod' });

    const repo = createConnectionRepo(db);
    const copied = repo.listBySpace('B');
    expect(copied).toHaveLength(1);
    // 移し先(B)のDEKで復号して平文一致
    const dec = decryptPrivateKey(db, null, copied[0].privateKeyEnc, 'B');
    expect(dec.toString()).toBe(pem);
    dec.fill(0);
    // 元(A)のDEKでは復号できない（束縛確認）
    expect(() => decryptPrivateKey(db, null, copied[0].privateKeyEnc, 'A')).toThrow();
  });

  it('skips when target already has same label', () => {
    const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nK\n-----END OPENSSH PRIVATE KEY-----';
    const srcId = seedSsh('A', 'dup', pem);
    seedSsh('B', 'dup', pem);
    const out = transferSshConnection({ db }, srcId, 'A', 'B', 'user-x');
    expect(out.skipped).toMatchObject({ type: 'ssh', name: 'dup' });
    expect(createConnectionRepo(db).listBySpace('B')).toHaveLength(1);
  });
});

describe('orchestration', () => {
  beforeEach(() => { process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64); });

  it('executes a mixed transfer and returns copied/skipped summary', () => {
    const db = freshDb();
    bootstrapSystemDek(db);
    const reg = createRegistry(db);
    reg.upsert({ id: 'm1', name: 'Linear', url: 'https://l.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' });
    const repo = createConnectionRepo(db);
    const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEY\n-----END OPENSSH PRIVATE KEY-----';
    const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), 'A');
    const s1 = repo.create({
      ownerId: null, spaceId: 'A', label: 'box', host: 'h', port: 22, username: 'u',
      privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: null,
      remotePathPrefix: '/x', allowRemoteUnrestricted: false, allowPrivateAddresses: false,
      commandDenyPatterns: null, commandAllowPatterns: null,
    }).id;

    const result = executeTransfer({ db }, { sourceSpaceId: 'A', targetSpaceId: 'B', actorId: 'u1', mcpServerIds: ['m1'], sshConnectionIds: [s1] });
    expect(result.copied).toHaveLength(2);
    expect(result.skipped).toHaveLength(0);
    expect(reg.listForSpace('B')).toHaveLength(1);
    expect(repo.listBySpace('B')).toHaveLength(1);
  });

  it('listTransferCandidates returns combined mcp+ssh', () => {
    const db = freshDb();
    bootstrapSystemDek(db);
    const reg = createRegistry(db);
    reg.upsert({ id: 'm1', name: 'Linear', url: 'https://l.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' });
    const repo = createConnectionRepo(db);
    const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEY\n-----END OPENSSH PRIVATE KEY-----';
    const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), 'A');
    repo.create({
      ownerId: null, spaceId: 'A', label: 'box', host: 'h', port: 22, username: 'u',
      privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: null,
      remotePathPrefix: '/x', allowRemoteUnrestricted: false, allowPrivateAddresses: false,
      commandDenyPatterns: null, commandAllowPatterns: null,
    });

    const cands = listTransferCandidates({ db }, 'A', 'B');
    expect(cands.mcp).toHaveLength(1);
    expect(cands.mcp[0]).toMatchObject({ type: 'mcp', name: 'Linear', willSkip: false });
    expect(cands.ssh).toHaveLength(1);
    expect(cands.ssh[0]).toMatchObject({ type: 'ssh', name: 'box', willSkip: false });
  });

  it('IDOR: foreign space IDs are skipped, nothing copied into target (Fix 1)', () => {
    // Space C owns both resources. Transfer request claims sourceSpaceId=A but uses C's IDs.
    const db = freshDb();
    bootstrapSystemDek(db);
    const reg = createRegistry(db);
    reg.upsert({ id: 'c-mcp-1', name: 'CServer', url: 'https://c.example', authKind: 'api_key', staticToken: 'c-secret', ownerId: null, spaceId: 'C' });
    const repo = createConnectionRepo(db);
    const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nCKEY\n-----END OPENSSH PRIVATE KEY-----';
    const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), 'C');
    const cSshId = repo.create({
      ownerId: null, spaceId: 'C', label: 'c-box', host: 'c.h', port: 22, username: 'cu',
      privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: null,
      remotePathPrefix: '/c', allowRemoteUnrestricted: false, allowPrivateAddresses: false,
      commandDenyPatterns: null, commandAllowPatterns: null,
    }).id;

    // Claim source=A but pass C's resource IDs → both should be skipped, space B untouched
    const result = executeTransfer({ db }, {
      sourceSpaceId: 'A',
      targetSpaceId: 'B',
      actorId: 'attacker',
      mcpServerIds: ['c-mcp-1'],
      sshConnectionIds: [cSshId],
    });

    expect(result.copied).toHaveLength(0);
    expect(result.skipped).toHaveLength(2);
    expect(result.skipped[0].reason).toBe('指定元ワークスペースに属さない');
    expect(result.skipped[1].reason).toBe('指定元ワークスペースに属さない');

    // Space C's rows must be unchanged
    expect(reg.listForSpace('C')).toHaveLength(1);
    expect(repo.listBySpace('C')).toHaveLength(1);
    // Space B must remain empty
    expect(reg.listForSpace('B')).toHaveLength(0);
    expect(repo.listBySpace('B')).toHaveLength(0);
  });

  it('missing IDs are skipped without throwing (Fix 2)', () => {
    const db = freshDb();
    bootstrapSystemDek(db);
    const result = executeTransfer({ db }, {
      sourceSpaceId: 'A',
      targetSpaceId: 'B',
      actorId: 'u1',
      mcpServerIds: ['does-not-exist-mcp'],
      sshConnectionIds: ['does-not-exist-ssh'],
    });
    expect(result.copied).toHaveLength(0);
    expect(result.skipped).toHaveLength(2);
    expect(result.skipped[0].reason).toBe('見つかりません');
    expect(result.skipped[1].reason).toBe('見つかりません');
  });

  it('does NOT copy user_mcp_tokens rows', () => {
    const db = freshDb();
    const reg = createRegistry(db);
    reg.upsert({ id: 'm1', name: 'Linear', url: 'https://l.example', authKind: 'oauth', oauthClientId: 'cid', oauthClientSecret: 'sec', ownerId: null, spaceId: 'A' });
    // ユーザートークンを 1 件入れておく（実スキーマ: created_at 列なし、connected_at/updated_at は DEFAULT）
    db.prepare("INSERT INTO user_mcp_tokens (user_id, server_id, access_token_enc) VALUES ('u1','m1', x'00')").run();
    executeTransfer({ db }, { sourceSpaceId: 'A', targetSpaceId: 'B', actorId: 'u1', mcpServerIds: ['m1'], sshConnectionIds: [] });
    const n = db.prepare('SELECT COUNT(*) AS n FROM user_mcp_tokens').get() as { n: number };
    expect(n.n).toBe(1); // 増えていない
  });
});
