/**
 * Tests for src/encryption/vault.ts (ADR-096 Phase 1).
 *
 * These pin the wire format + cipher contract so future iterations can't
 * silently change either without a coordinated migration. The format is
 * meant to be stable across versions — every encrypted file shipped before
 * a format change has to keep decrypting under the new code.
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { randomBytes } from 'node:crypto';

import {
  MAGIC,
  decodeKey,
  decryptBuffer,
  encryptBuffer,
  getKey,
  isEncryptedBlob,
  isEncryptionEnabled,
} from '../src/encryption/vault.js';

// Per-test env reset — these tests mutate process.env and must not leak.
const SAVED_ENV: Record<string, string | undefined> = {};
function saveEnv(...names: string[]) {
  for (const n of names) SAVED_ENV[n] = process.env[n];
}
function restoreEnv() {
  for (const [n, v] of Object.entries(SAVED_ENV)) {
    if (v === undefined) delete process.env[n];
    else process.env[n] = v;
  }
}

describe('vault (ADR-096 Phase 1)', () => {
  beforeEach(() => {
    saveEnv('CLAUDE_FLOW_ENCRYPT_AT_REST', 'CLAUDE_FLOW_ENCRYPTION_KEY');
  });
  afterEach(() => {
    restoreEnv();
  });

  describe('isEncryptionEnabled', () => {
    it('is false when the env var is unset', () => {
      delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST;
      expect(isEncryptionEnabled()).toBe(false);
    });

    it.each(['1', 'true', 'TRUE', 'yes', 'on', '  on  '])(
      'is true for "%s"',
      (v) => {
        process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = v;
        expect(isEncryptionEnabled()).toBe(true);
      },
    );

    it.each(['0', 'false', 'no', 'off', 'maybe', ''])(
      'is false for "%s"',
      (v) => {
        process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = v;
        expect(isEncryptionEnabled()).toBe(false);
      },
    );
  });

  describe('decodeKey', () => {
    it('decodes 64-char hex', () => {
      const hex = 'a'.repeat(64);
      expect(decodeKey(hex).length).toBe(32);
    });

    it('decodes 44-char base64 (padded)', () => {
      const b64 = randomBytes(32).toString('base64');
      expect(b64).toMatch(/=$/);
      expect(decodeKey(b64).length).toBe(32);
    });

    it('decodes 43-char base64 (unpadded)', () => {
      const b64 = randomBytes(32).toString('base64').replace(/=+$/, '');
      expect(decodeKey(b64).length).toBe(32);
    });

    it('rejects too-short hex', () => {
      expect(() => decodeKey('a'.repeat(63))).toThrow(/Invalid/);
    });

    it('rejects too-long hex', () => {
      expect(() => decodeKey('a'.repeat(65))).toThrow(/Invalid/);
    });

    it('rejects non-hex / non-base64 garbage', () => {
      expect(() => decodeKey('not a key at all!!!')).toThrow(/Invalid/);
    });

    it('rejects base64 of the wrong length (16 bytes)', () => {
      const b64 = randomBytes(16).toString('base64');
      expect(() => decodeKey(b64)).toThrow(/Invalid/);
    });

    it('trims surrounding whitespace before validating', () => {
      const hex = 'b'.repeat(64);
      expect(decodeKey(`  ${hex}\n`).length).toBe(32);
    });
  });

  describe('getKey', () => {
    it('throws with a clear message when the env var is unset', () => {
      delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
      expect(() => getKey()).toThrow(/CLAUDE_FLOW_ENCRYPTION_KEY/);
    });

    it('returns a 32-byte buffer when the env var holds valid hex', () => {
      process.env.CLAUDE_FLOW_ENCRYPTION_KEY = 'c'.repeat(64);
      expect(getKey().length).toBe(32);
    });
  });

  describe('encrypt / decrypt round-trip', () => {
    const key = randomBytes(32);

    it('round-trips an empty buffer', () => {
      const blob = encryptBuffer(Buffer.alloc(0), key);
      expect(decryptBuffer(blob, key).length).toBe(0);
    });

    it('round-trips small text', () => {
      const plain = Buffer.from('hello world', 'utf-8');
      expect(decryptBuffer(encryptBuffer(plain, key), key).equals(plain)).toBe(
        true,
      );
    });

    it('round-trips JSON the way session-tools writes it', () => {
      const obj = { sessionId: 'sess-1', name: 'audit', stats: { tasks: 5 } };
      const plain = Buffer.from(JSON.stringify(obj, null, 2), 'utf-8');
      const round = JSON.parse(
        decryptBuffer(encryptBuffer(plain, key), key).toString('utf-8'),
      );
      expect(round).toEqual(obj);
    });

    it('round-trips a large random buffer (megabyte-scale)', () => {
      const plain = randomBytes(1_048_576); // 1MB
      expect(decryptBuffer(encryptBuffer(plain, key), key).equals(plain)).toBe(
        true,
      );
    });

    it('produces different ciphertext for the same plaintext (random IV)', () => {
      const plain = Buffer.from('repeated', 'utf-8');
      const a = encryptBuffer(plain, key);
      const b = encryptBuffer(plain, key);
      expect(a.equals(b)).toBe(false);
    });
  });

  describe('wire format', () => {
    it('begins with the RFE1 magic', () => {
      const blob = encryptBuffer(Buffer.from('x', 'utf-8'), randomBytes(32));
      expect(blob.subarray(0, 4)).toEqual(MAGIC);
    });

    it('has at least magic+iv+tag bytes (32) for any plaintext', () => {
      const blob = encryptBuffer(Buffer.alloc(0), randomBytes(32));
      // magic(4) + iv(12) + tag(16) = 32 minimum
      expect(blob.length).toBeGreaterThanOrEqual(32);
    });
  });

  describe('isEncryptedBlob', () => {
    it('detects a freshly-encrypted blob', () => {
      const blob = encryptBuffer(Buffer.from('hi', 'utf-8'), randomBytes(32));
      expect(isEncryptedBlob(blob)).toBe(true);
    });

    it('returns false for plaintext JSON', () => {
      expect(isEncryptedBlob(Buffer.from('{"a":1}', 'utf-8'))).toBe(false);
    });

    it('returns false for the SQLite header', () => {
      // Real .swarm/memory.db files start with "SQLite format 3\0"
      expect(isEncryptedBlob(Buffer.from('SQLite format 3\0', 'utf-8'))).toBe(
        false,
      );
    });

    it('returns false for a blob shorter than the magic+iv+tag minimum', () => {
      expect(isEncryptedBlob(MAGIC)).toBe(false); // magic alone, but length < 32
    });

    it('returns false for a non-Buffer input', () => {
      expect(isEncryptedBlob('not a buffer' as unknown as Buffer)).toBe(false);
    });
  });

  describe('tamper detection', () => {
    const key = randomBytes(32);

    it('rejects a blob with a flipped ciphertext byte', () => {
      const plain = Buffer.from('sensitive data', 'utf-8');
      const blob = Buffer.from(encryptBuffer(plain, key));
      // Flip a byte inside the ciphertext region (after magic+iv, before tag)
      blob[20] ^= 0xff;
      expect(() => decryptBuffer(blob, key)).toThrow();
    });

    it('rejects a blob with a flipped auth-tag byte', () => {
      const blob = Buffer.from(
        encryptBuffer(Buffer.from('payload', 'utf-8'), key),
      );
      blob[blob.length - 1] ^= 0xff;
      expect(() => decryptBuffer(blob, key)).toThrow();
    });

    it('rejects a blob with a flipped IV byte', () => {
      const blob = Buffer.from(
        encryptBuffer(Buffer.from('payload', 'utf-8'), key),
      );
      blob[5] ^= 0xff; // inside the IV region (offset 4..15)
      expect(() => decryptBuffer(blob, key)).toThrow();
    });

    it('rejects a blob with a corrupted magic', () => {
      const blob = Buffer.from(
        encryptBuffer(Buffer.from('payload', 'utf-8'), key),
      );
      blob[0] = 0x00; // break the magic
      expect(() => decryptBuffer(blob, key)).toThrow(/bad magic/);
    });

    it('rejects a blob shorter than the minimum length', () => {
      expect(() => decryptBuffer(Buffer.alloc(10), key)).toThrow(/too short/);
    });

    it('rejects a wrong-key decrypt (GCM auth fails)', () => {
      const blob = encryptBuffer(Buffer.from('payload', 'utf-8'), key);
      const wrongKey = randomBytes(32);
      expect(() => decryptBuffer(blob, wrongKey)).toThrow();
    });
  });

  describe('input validation', () => {
    const key = randomBytes(32);

    it('encryptBuffer rejects a non-Buffer plaintext', () => {
      expect(() =>
        encryptBuffer('string' as unknown as Buffer, key),
      ).toThrow(/Buffer/);
    });

    it('encryptBuffer rejects a wrong-size key', () => {
      expect(() =>
        encryptBuffer(Buffer.from('x'), randomBytes(16)),
      ).toThrow(/32-byte/);
    });

    it('decryptBuffer rejects a non-Buffer blob', () => {
      expect(() =>
        decryptBuffer('string' as unknown as Buffer, key),
      ).toThrow(/Buffer/);
    });

    it('decryptBuffer rejects a wrong-size key', () => {
      const blob = encryptBuffer(Buffer.from('x'), key);
      expect(() => decryptBuffer(blob, randomBytes(16))).toThrow(/32-byte/);
    });
  });
});
