/**
 * Envelope encryption for SSH private keys and passphrases.
 *
 * Layers (top → bottom):
 *   Plaintext PEM Buffer
 *     ↑ encrypted under
 *   DEK (per-user OR system, depending on connection owner)
 *     ↑ wrapped under
 *   MCP_ENCRYPTION_KEY env var (shared with MCP — see docs/mcp.md)
 *
 * Why a separate DEK table from existing `user_deks`?
 *   `user_deks` (in browser-sessions) is wrapped under `data/secrets/master.key`,
 *   a file-based key. SSH uses MCP_ENCRYPTION_KEY (env var) for consistency with
 *   MCP. A second key-store would mean key rotation must coordinate both files —
 *   we use a dedicated `ssh_user_deks` table wrapped only under the env var.
 *
 * Buffer hygiene:
 *   All decrypted material is returned as `Buffer`. Callers MUST `Buffer.fill(0)`
 *   when done. Decrypt errors are sanitized to never leak PEM material via
 *   exception messages — use `sanitizeError(e)` when surfacing.
 *
 * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1).
 */
import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import type Database from 'better-sqlite3';
import { loadKeyFromEnv, isKeyConfigured } from '../mcp/crypto.js';

const cjsRequire = createRequire(import.meta.url);

const ALGO = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;

// Layout: [IV (12)] [TAG (16)] [CIPHERTEXT (n)]
// Matches src/mcp/crypto.ts to keep blob formats consistent.
function aesGcmEncrypt(key: Buffer, plaintext: Buffer): Buffer {
  const iv = randomBytes(IV_LEN);
  const cipher = createCipheriv(ALGO, key, iv);
  const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const tag = cipher.getAuthTag();
  return Buffer.concat([iv, tag, ct]);
}

function aesGcmDecrypt(key: Buffer, blob: Buffer): Buffer {
  if (blob.length < IV_LEN + TAG_LEN) throw new Error('ssh-crypto: blob too short');
  const iv = blob.subarray(0, IV_LEN);
  const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
  const ct = blob.subarray(IV_LEN + TAG_LEN);
  const decipher = createDecipheriv(ALGO, key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(ct), decipher.final()]);
}

/** Re-exported for callers that need to gate features on key availability. */
export { isKeyConfigured };

/** Returns the SSH master key (32 bytes) from MCP_ENCRYPTION_KEY env. Caller MUST NOT fill(0). */
function getMasterKey(): Buffer {
  return loadKeyFromEnv();
}

/**
 * Bootstrap the single system_deks row. Safe under concurrent boots.
 * Uses transactional INSERT OR IGNORE; second writer is a no-op.
 */
export function bootstrapSystemDek(db: Database.Database): void {
  const dek = randomBytes(32);
  try {
    const master = getMasterKey();
    const wrapped = aesGcmEncrypt(master, dek);
    db.transaction(() => {
      db.prepare(
        'INSERT OR IGNORE INTO system_deks (id, encrypted_dek, key_version) VALUES (1, ?, 1)',
      ).run(wrapped);
    })();
  } finally {
    dek.fill(0);
  }
}

/**
 * Verify the stored system DEK can be unwrapped with the current MCP_ENCRYPTION_KEY.
 * Fails if the env var has been rotated externally and the operator hasn't run the
 * rewrap job. Throws on mismatch — caller (boot) should disable SSH for the session
 * and log the actionable error.
 */
export function verifySystemDek(db: Database.Database): void {
  const row = db.prepare('SELECT encrypted_dek FROM system_deks WHERE id = 1').get() as
    | { encrypted_dek: Buffer }
    | undefined;
  if (!row) throw new Error('ssh-crypto: system_deks bootstrap row missing');
  const master = getMasterKey();
  const dek = aesGcmDecrypt(master, row.encrypted_dek);
  dek.fill(0);
}

/** Returns the system DEK plaintext (32 bytes). Caller MUST `Buffer.fill(0)` when done. */
export function getSystemDek(db: Database.Database): Buffer {
  const row = db.prepare('SELECT encrypted_dek FROM system_deks WHERE id = 1').get() as
    | { encrypted_dek: Buffer }
    | undefined;
  if (!row) throw new Error('ssh-crypto: system DEK not bootstrapped');
  const master = getMasterKey();
  return aesGcmDecrypt(master, row.encrypted_dek);
}

/**
 * Returns the per-user SSH DEK plaintext (32 bytes), creating it lazily on first call.
 * Caller MUST `Buffer.fill(0)` when done.
 */
export function getOrCreateUserDek(db: Database.Database, userId: string): Buffer {
  const existing = db
    .prepare('SELECT encrypted_dek FROM ssh_user_deks WHERE user_id = ?')
    .get(userId) as { encrypted_dek: Buffer } | undefined;
  const master = getMasterKey();
  if (existing) {
    return aesGcmDecrypt(master, existing.encrypted_dek);
  }
  // Generate + persist atomically. If a concurrent caller raced us, the second
  // INSERT will fail on PRIMARY KEY; we then read back.
  const dek = randomBytes(32);
  try {
    const wrapped = aesGcmEncrypt(master, dek);
    try {
      db.prepare(
        'INSERT INTO ssh_user_deks (user_id, encrypted_dek, key_version) VALUES (?, ?, 1)',
      ).run(userId, wrapped);
      return Buffer.from(dek);
    } catch {
      // Concurrent insert — read the winner.
      const winner = db
        .prepare('SELECT encrypted_dek FROM ssh_user_deks WHERE user_id = ?')
        .get(userId) as { encrypted_dek: Buffer } | undefined;
      if (!winner) throw new Error('ssh-crypto: failed to create user DEK');
      return aesGcmDecrypt(master, winner.encrypted_dek);
    }
  } finally {
    dek.fill(0);
  }
}

/**
 * Encrypt a private-key PEM under the appropriate DEK.
 * - ownerId !== null: per-user DEK (creates lazily)
 * - ownerId === null: system DEK (must be bootstrapped first)
 */
export function encryptPrivateKey(
  db: Database.Database,
  ownerId: string | null,
  pem: Buffer,
): { blob: Buffer; keyVersion: number } {
  const dek = ownerId === null ? getSystemDek(db) : getOrCreateUserDek(db, ownerId);
  try {
    return { blob: aesGcmEncrypt(dek, pem), keyVersion: 1 };
  } finally {
    dek.fill(0);
  }
}

/**
 * Decrypt a private-key blob. Returns Buffer — caller MUST `clearBuffer` when done.
 * Errors are NOT sanitized at this layer; callers that surface to UI must wrap
 * in `sanitizeError()`.
 */
export function decryptPrivateKey(
  db: Database.Database,
  ownerId: string | null,
  blob: Buffer,
): Buffer {
  const dek = ownerId === null ? getSystemDek(db) : getOrCreateUserDek(db, ownerId);
  try {
    return aesGcmDecrypt(dek, blob);
  } finally {
    dek.fill(0);
  }
}

/**
 * Compute an OpenSSH-style SHA-256 fingerprint of a PEM key's public portion.
 * Returns `SHA256:<base64-without-padding>` (the operator-readable form).
 *
 * `passphrase` is required for encrypted OpenSSH-format keys; for plain
 * PKCS#1 / OpenSSH-unencrypted PEMs it may be null.
 *
 * ssh2 is a CJS-only package; Node's ESM-CJS interop cannot resolve named
 * exports of complex CJS structures (`Named export 'utils' not found`),
 * so we use createRequire to load it the CJS way.
 */
export function computeKeyFingerprint(pem: Buffer, passphrase: Buffer | null = null): string {
  const ssh2 = cjsRequire('ssh2') as {
    utils: { parseKey: (k: Buffer, p?: Buffer | string) => unknown };
  };
  const parsed = ssh2.utils.parseKey(pem, passphrase ?? undefined) as
    | { getPublicSSH(): Buffer; type: string }
    | Error;
  if (parsed instanceof Error) throw parsed;
  const publicSsh = parsed.getPublicSSH();
  const hash = createHash('sha256').update(publicSsh).digest('base64');
  return `SHA256:${hash.replace(/=+$/, '')}`;
}

/**
 * Format the public key portion of a PEM private key as an OpenSSH 1-line
 * authorized_keys entry: `<algo> <base64-pubkey>`. The caller adds an
 * optional comment if it wants one.
 *
 * `passphrase` is required for encrypted OpenSSH keys.
 */
export function formatPublicKey(pem: Buffer, passphrase: Buffer | null = null): string {
  const ssh2 = cjsRequire('ssh2') as {
    utils: { parseKey: (k: Buffer, p?: Buffer | string) => unknown };
  };
  const parsed = ssh2.utils.parseKey(pem, passphrase ?? undefined) as
    | { getPublicSSH(): Buffer; type: string }
    | Error;
  if (parsed instanceof Error) throw parsed;
  return `${parsed.type} ${parsed.getPublicSSH().toString('base64')}`;
}

export type GeneratedKeyType = 'ed25519' | 'rsa-4096';

/**
 * Generate a fresh SSH keypair using ssh2's native generator. Returns the
 * OpenSSH-format private key (no passphrase) plus the public key in the
 * `<algo> <base64>` shape suitable for `authorized_keys`.
 *
 * The returned private key is in OpenSSH PEM with no passphrase — the
 * caller is expected to envelope-encrypt it before persisting.
 */
export function generateKeypair(keyType: GeneratedKeyType): {
  privateKeyPem: Buffer;
  publicKey: string;
} {
  const ssh2 = cjsRequire('ssh2') as {
    utils: {
      generateKeyPairSync: (
        keyType: string,
        opts?: { bits?: number },
      ) => { public: string; private: string };
    };
  };
  const algName = keyType === 'rsa-4096' ? 'rsa' : 'ed25519';
  const opts = keyType === 'rsa-4096' ? { bits: 4096 } : undefined;
  const { public: pub, private: priv } = ssh2.utils.generateKeyPairSync(algName, opts);
  // ssh2 returns the public key as a full 1-line "ssh-* AAAA... user@host"
  // string. Strip any trailing comment so we control the comment ourselves.
  const trimmed = pub.trim().split(/\s+/).slice(0, 2).join(' ');
  return { privateKeyPem: Buffer.from(priv, 'utf-8'), publicKey: trimmed };
}

/**
 * Strip PEM blocks from an error message. Use before re-throwing errors that
 * may have been generated by an underlying crypto/parser library that included
 * the offending key bytes in its message.
 */
export function sanitizeError(e: unknown): Error {
  if (e instanceof Error) {
    const cleaned = e.message.replace(
      /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g,
      '[REDACTED PEM]',
    );
    return new Error(cleaned);
  }
  return new Error('ssh-crypto: unknown error');
}

/** Zero out a Buffer. No-op for empty / null. */
export function clearBuffer(buf: Buffer | undefined | null): void {
  if (buf && buf.length > 0) buf.fill(0);
}
