/**
 * Tests for the shared cipher primitives used by both
 * `services/apiKeyManager.js` (api-keys.enc) and
 * `services/authCacheStore.js` (auth-cache.enc).
 *
 * Covers:
 *   - key derivation: persistence across calls, identity vs different salts
 *   - round-trip: encrypt then decrypt returns the original
 *   - constants stability: KEY_DERIVATION_DOMAIN is part of the on-disk
 *     contract and must not change without a migration
 */

import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';

import {
  encrypt,
  decrypt,
  getOrCreateEncryptionKey,
  KEY_DERIVATION_DOMAIN,
  SALT_FILENAME,
  SALT_LENGTH,
  KEY_LENGTH,
} from '../secretsCipher.js';

const tmpDir = path.join(os.tmpdir(), 'secretsCipher-test-' + crypto.randomBytes(6).toString('hex'));

beforeEach(async () => { await fs.mkdir(tmpDir, { recursive: true }); });
afterEach(async () => { try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ok */ } });

describe('secretsCipher', () => {
  test('KEY_DERIVATION_DOMAIN is the documented value — changing this invalidates all stored credentials', () => {
    // Tripwire: this constant is part of the on-disk contract.
    // If you intentionally need to rotate it, also write a migration.
    expect(KEY_DERIVATION_DOMAIN).toBe('loxia-api-key-encryption-v1');
  });

  test('getOrCreateEncryptionKey creates a salt file on first call', async () => {
    await getOrCreateEncryptionKey(tmpDir, null);
    const saltFile = path.join(tmpDir, SALT_FILENAME);
    const buf = await fs.readFile(saltFile);
    expect(buf.length).toBe(SALT_LENGTH);
  });

  test('getOrCreateEncryptionKey reuses the existing salt on subsequent calls', async () => {
    const key1 = await getOrCreateEncryptionKey(tmpDir, null);
    const key2 = await getOrCreateEncryptionKey(tmpDir, null);
    expect(key1.equals(key2)).toBe(true);
    expect(key1.length).toBe(KEY_LENGTH);
  });

  test('different salt directories yield different keys', async () => {
    const dirA = path.join(tmpDir, 'a');
    const dirB = path.join(tmpDir, 'b');
    await fs.mkdir(dirA); await fs.mkdir(dirB);
    const keyA = await getOrCreateEncryptionKey(dirA, null);
    const keyB = await getOrCreateEncryptionKey(dirB, null);
    expect(keyA.equals(keyB)).toBe(false);
  });

  test('encrypt then decrypt round-trips the plaintext', async () => {
    const key = await getOrCreateEncryptionKey(tmpDir, null);
    const plaintext = JSON.stringify({ jwt: 'eyJ.test.jwt', user: { id: 'u-1' } });
    const ct = encrypt(plaintext, key);
    expect(typeof ct).toBe('string');
    expect(ct).not.toContain('eyJ');     // not plaintext
    expect(decrypt(ct, key)).toBe(plaintext);
  });

  test('encrypt produces different ciphertext each call (random IV)', async () => {
    const key = await getOrCreateEncryptionKey(tmpDir, null);
    const ct1 = encrypt('same', key);
    const ct2 = encrypt('same', key);
    expect(ct1).not.toBe(ct2);
  });

  test('decrypt throws with a clear message when no key supplied', () => {
    expect(() => decrypt('anything', null)).toThrow('Encryption key not initialized');
  });

  test('encrypt throws with a clear message when no key supplied', () => {
    expect(() => encrypt('anything', null)).toThrow('Encryption key not initialized');
  });

  test('decrypt fails on tampered ciphertext (GCM authTag check)', async () => {
    const key = await getOrCreateEncryptionKey(tmpDir, null);
    const ct = encrypt('payload', key);
    // Flip a byte in the middle of the buffer
    const buf = Buffer.from(ct, 'base64');
    buf[Math.floor(buf.length / 2)] ^= 0xff;
    const tampered = buf.toString('base64');
    expect(() => decrypt(tampered, key)).toThrow();
  });
});
