/**
 * EncryptionService — AES-256-CBC column-level encryption for SentryAgent.ai AgentIdP.
 *
 * Encrypts and decrypts sensitive PostgreSQL column values using AES-256-CBC.
 * The encryption key is stored in HashiCorp Vault and fetched once on first use,
 * then cached in process memory. If decryption fails (e.g. key rotation), the
 * cached key is cleared and re-fetched on the next call.
 *
 * Encrypted format: base64(iv):base64(ciphertext)
 * Key format:       32-byte hex string stored in Vault
 */

import forge from 'node-forge';
import { VaultClient } from '../vault/VaultClient.js';

/** Vault path env var for the encryption key (default path used when var is not set). */
const DEFAULT_VAULT_PATH = 'secret/data/agentidp/encryption-key';

/** Regex that matches the encrypted column format: base64(iv):base64(ciphertext). */
const ENCRYPTED_PATTERN = /^[A-Za-z0-9+/]+=*:[A-Za-z0-9+/]+=*$/;

/**
 * Service providing AES-256-CBC column-level encryption backed by a Vault-managed key.
 * All sensitive database columns (credential hashes, vault paths) pass through this
 * service before being written to or read from PostgreSQL.
 */
export class EncryptionService {
  /** In-memory cache of the 32-byte encryption key (hex-encoded). */
  private cachedKey: string | null = null;

  /**
   * @param vaultClient - VaultClient used to fetch the AES-256-CBC encryption key.
   */
  constructor(private readonly vaultClient: VaultClient) {}

  /**
   * Returns the encryption key, fetching it from Vault if not yet cached.
   * The key is stored at the path specified by `ENCRYPTION_KEY_VAULT_PATH` (default:
   * `secret/data/agentidp/encryption-key`). The Vault record must contain a field
   * named `encryptionKey` whose value is a 64-character hex string (32 bytes).
   *
   * @returns The raw 32-byte encryption key as a hex string.
   * @throws Error if the key cannot be fetched or is not a 64-character hex string.
   */
  private async getKey(): Promise<string> {
    if (this.cachedKey !== null) {
      return this.cachedKey;
    }

    const vaultPath =
      process.env['ENCRYPTION_KEY_VAULT_PATH'] ?? DEFAULT_VAULT_PATH;

    const data = await this.vaultClient.readArbitrarySecret(vaultPath);
    const key = data['encryptionKey'];

    if (typeof key !== 'string' || key.length !== 64) {
      throw new Error(
        `Invalid encryption key at Vault path '${vaultPath}': expected a 64-character hex string.`,
      );
    }

    this.cachedKey = key;
    return key;
  }

  /**
   * Clears the in-memory key cache, forcing a re-fetch from Vault on the next call.
   * Called automatically when decryption fails (e.g. after key rotation).
   */
  private clearKeyCache(): void {
    this.cachedKey = null;
  }

  /**
   * Encrypts a plaintext string using AES-256-CBC.
   * A fresh 16-byte IV is generated per call, ensuring different ciphertexts
   * for identical inputs (semantic security).
   *
   * @param plaintext - The string to encrypt.
   * @returns A base64-encoded string in the format `iv_base64:ciphertext_base64`.
   * @throws Error if the Vault key cannot be fetched.
   */
  async encryptColumn(plaintext: string): Promise<string> {
    const hexKey = await this.getKey();
    const keyBytes = forge.util.hexToBytes(hexKey);

    const iv = forge.random.getBytesSync(16);
    const cipher = forge.cipher.createCipher('AES-CBC', keyBytes);
    cipher.start({ iv });
    cipher.update(forge.util.createBuffer(plaintext, 'utf8'));
    cipher.finish();

    const ivBase64 = forge.util.encode64(iv);
    const ciphertextBase64 = forge.util.encode64(cipher.output.getBytes());

    return `${ivBase64}:${ciphertextBase64}`;
  }

  /**
   * Decrypts a ciphertext string that was produced by `encryptColumn`.
   * If decryption fails (wrong key, corrupted data), the key cache is cleared
   * so the next call re-fetches from Vault, then the error is re-thrown.
   *
   * @param ciphertext - A `iv_base64:ciphertext_base64` encoded string.
   * @returns The original plaintext string.
   * @throws Error if the ciphertext format is invalid or decryption fails.
   */
  async decryptColumn(ciphertext: string): Promise<string> {
    const colonIndex = ciphertext.indexOf(':');
    if (colonIndex === -1) {
      throw new Error('Invalid encrypted column format: missing ":" separator.');
    }

    const ivBase64 = ciphertext.slice(0, colonIndex);
    const ciphertextBase64 = ciphertext.slice(colonIndex + 1);

    let hexKey: string;
    try {
      hexKey = await this.getKey();
    } catch (err) {
      throw err;
    }

    try {
      const keyBytes = forge.util.hexToBytes(hexKey);
      const iv = forge.util.decode64(ivBase64);
      const encryptedBytes = forge.util.decode64(ciphertextBase64);

      const decipher = forge.cipher.createDecipher('AES-CBC', keyBytes);
      decipher.start({ iv });
      decipher.update(forge.util.createBuffer(encryptedBytes));
      const ok = decipher.finish();

      if (!ok) {
        this.clearKeyCache();
        throw new Error('AES-256-CBC decryption failed — possible key mismatch or corrupted data.');
      }

      return decipher.output.toString();
    } catch (err) {
      this.clearKeyCache();
      throw err;
    }
  }

  /**
   * Returns `true` if the given value appears to be an encrypted column value
   * (i.e. matches the `base64:base64` pattern produced by `encryptColumn`).
   * Used for backward-compatibility: existing plaintext rows can be detected and
   * skipped during the read-decrypt cycle until they are re-written in encrypted form.
   *
   * @param value - The column value to test.
   * @returns `true` if the value looks encrypted; `false` if it is plaintext.
   */
  isEncrypted(value: string): boolean {
    return ENCRYPTED_PATTERN.test(value);
  }
}

// ---------------------------------------------------------------------------
// Singleton — re-using VaultClient requires a live instance at module load time.
// The singleton is created lazily to allow test overrides.
// ---------------------------------------------------------------------------

let _instance: EncryptionService | null = null;

/**
 * Returns the singleton EncryptionService instance.
 * On first call, creates the instance using the VaultClient singleton.
 *
 * @param vaultClient - A VaultClient instance (required on first call).
 * @returns The singleton EncryptionService.
 */
export function getEncryptionService(vaultClient: VaultClient): EncryptionService {
  if (_instance === null) {
    _instance = new EncryptionService(vaultClient);
  }
  return _instance;
}

/**
 * Resets the singleton (for testing only).
 *
 * @internal
 */
export function _resetEncryptionServiceSingleton(): void {
  _instance = null;
}
