/**
 * @bytelyst/field-encrypt — Tests
 *
 * ~35 tests covering AES-GCM, key providers, envelope, cache,
 * field encryptor factory, type guards, and migration.
 */

import { describe, it, expect, beforeEach } from 'vitest';
import { randomBytes } from 'node:crypto';
import {
  createFieldEncryptor,
  FieldEncryptor,
  NullFieldEncryptor,
  isEncryptedField,
  encryptField,
  decryptField,
  generateAesKey,
  buildDekId,
  getOrCreateDek,
  rewrapAllDeks,
  DekCache,
  MemoryDekStore,
  CosmosDekStore,
  MemoryKeyProvider,
  EnvKeyProvider,
  migrateDocuments,
} from './index.js';
import type { EncryptedField, FieldEncryptContext } from './types.js';

// ── AES-256-GCM ─────────────────────────────────────

describe('aes-gcm', () => {
  const key = generateAesKey();
  const dekId = 'dek_test_ctx';

  it('encrypt → decrypt roundtrip', () => {
    const plaintext = 'Hello, sensitive data!';
    const encrypted = encryptField(plaintext, key, dekId);
    const decrypted = decryptField(encrypted, key);
    expect(decrypted).toBe(plaintext);
  });

  it('encrypt → decrypt with AAD', () => {
    const plaintext = 'With AAD';
    const aad = 'user_123:transcripts';
    const encrypted = encryptField(plaintext, key, dekId, aad);
    const decrypted = decryptField(encrypted, key, aad);
    expect(decrypted).toBe(plaintext);
  });

  it('rejects decryption with wrong AAD', () => {
    const encrypted = encryptField('secret', key, dekId, 'correct_aad');
    expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow();
  });

  it('rejects decryption with tampered ciphertext', () => {
    const encrypted = encryptField('secret', key, dekId);
    const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' };
    expect(() => decryptField(tampered, key)).toThrow();
  });

  it('rejects decryption with tampered auth tag', () => {
    const encrypted = encryptField('secret', key, dekId);
    const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) };
    expect(() => decryptField(tampered, key)).toThrow();
  });

  it('handles empty string', () => {
    const encrypted = encryptField('', key, dekId);
    const decrypted = decryptField(encrypted, key);
    expect(decrypted).toBe('');
  });

  it('handles unicode content', () => {
    const plaintext = '日本語テスト 🔐 Ñoño';
    const encrypted = encryptField(plaintext, key, dekId);
    const decrypted = decryptField(encrypted, key);
    expect(decrypted).toBe(plaintext);
  });

  it('handles large payload (100 KB)', () => {
    const plaintext = 'x'.repeat(100_000);
    const encrypted = encryptField(plaintext, key, dekId);
    const decrypted = decryptField(encrypted, key);
    expect(decrypted).toBe(plaintext);
  });

  it('rejects wrong key size', () => {
    const shortKey = randomBytes(16);
    expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/);
  });

  it('produces correct EncryptedField shape', () => {
    const encrypted = encryptField('test', key, dekId);
    expect(encrypted.__encrypted).toBe(true);
    expect(encrypted.v).toBe(1);
    expect(encrypted.alg).toBe('aes-256-gcm');
    expect(encrypted.dekId).toBe(dekId);
    expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars
    expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars
    expect(encrypted.ct.length).toBeGreaterThan(0);
  });

  it('generates unique IVs per encryption', () => {
    const e1 = encryptField('same', key, dekId);
    const e2 = encryptField('same', key, dekId);
    expect(e1.iv).not.toBe(e2.iv);
    expect(e1.ct).not.toBe(e2.ct);
  });
});

// ── Type guard ──────────────────────────────────────

describe('isEncryptedField', () => {
  it('returns true for valid EncryptedField', () => {
    const field: EncryptedField = {
      __encrypted: true,
      v: 1,
      alg: 'aes-256-gcm',
      ct: 'abc',
      iv: '012',
      tag: '345',
      dekId: 'dek_1',
    };
    expect(isEncryptedField(field)).toBe(true);
  });

  it('returns false for string', () => {
    expect(isEncryptedField('hello')).toBe(false);
  });

  it('returns false for null', () => {
    expect(isEncryptedField(null)).toBe(false);
  });

  it('returns false for undefined', () => {
    expect(isEncryptedField(undefined)).toBe(false);
  });

  it('returns false for object without __encrypted', () => {
    expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false);
  });

  it('returns false for object with __encrypted: false', () => {
    expect(
      isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' })
    ).toBe(false);
  });
});

// ── Key providers ───────────────────────────────────

describe('MemoryKeyProvider', () => {
  it('wrap → unwrap roundtrip', async () => {
    const provider = new MemoryKeyProvider();
    const dek = generateAesKey();
    const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
    const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
    expect(unwrapped).toEqual(dek);
  });
});

describe('EnvKeyProvider', () => {
  it('wrap → unwrap roundtrip with 64-char hex key', async () => {
    const hexKey = randomBytes(32).toString('hex');
    const provider = new EnvKeyProvider(hexKey);
    const dek = generateAesKey();
    const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
    const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
    expect(unwrapped).toEqual(dek);
  });

  it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => {
    const provider = new EnvKeyProvider('my-dev-secret-key');
    const dek = generateAesKey();
    const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
    const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
    expect(unwrapped).toEqual(dek);
  });

  it('throws on empty key', () => {
    expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/);
  });
});

// ── DEK cache ───────────────────────────────────────

describe('DekCache', () => {
  let cache: DekCache;

  beforeEach(() => {
    cache = new DekCache(1000, 3); // 1s TTL, max 3 entries
  });

  it('get returns null on miss', () => {
    expect(cache.get('nonexistent')).toBeNull();
  });

  it('set + get roundtrip', () => {
    const key = generateAesKey();
    cache.set('dek_1', key);
    expect(cache.get('dek_1')).toEqual(key);
  });

  it('expires entries after TTL', async () => {
    const shortCache = new DekCache(50, 100); // 50ms TTL
    const key = generateAesKey();
    shortCache.set('dek_1', key);
    expect(shortCache.get('dek_1')).toEqual(key);
    await new Promise(r => setTimeout(r, 60));
    expect(shortCache.get('dek_1')).toBeNull();
  });

  it('evicts oldest on max size', () => {
    cache.set('a', generateAesKey());
    cache.set('b', generateAesKey());
    cache.set('c', generateAesKey());
    // At max size (3), adding 'd' should evict 'a'
    cache.set('d', generateAesKey());
    expect(cache.get('a')).toBeNull();
    expect(cache.get('d')).not.toBeNull();
  });

  it('invalidate removes specific entry', () => {
    cache.set('dek_1', generateAesKey());
    cache.invalidate('dek_1');
    expect(cache.get('dek_1')).toBeNull();
  });

  it('tracks hit rate', () => {
    cache.set('dek_1', generateAesKey());
    cache.recordHit();
    cache.recordHit();
    cache.recordMiss();
    expect(cache.hitRate).toBe(67); // 2/3 = 67%
  });
});

// ── Envelope ────────────────────────────────────────

describe('envelope', () => {
  it('buildDekId produces correct format', () => {
    expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts');
  });

  it('getOrCreateDek creates and caches a new DEK', async () => {
    const provider = new MemoryKeyProvider();
    const store = new MemoryDekStore();
    const cache = new DekCache();

    const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache);
    expect(dek).toHaveLength(32);

    // Should be in store
    const stored = await store.get('dek_u1_ctx');
    expect(stored).not.toBeNull();

    // Should be cached — second call should return same key
    const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache);
    expect(dek2).toEqual(dek);
  });

  it('rewrapAllDeks re-wraps with new provider', async () => {
    const oldProvider = new MemoryKeyProvider(undefined, 'old-v1');
    const newProvider = new MemoryKeyProvider(undefined, 'new-v1');
    const store = new MemoryDekStore();
    const cache = new DekCache();

    // Create 3 DEKs with old provider
    await getOrCreateDek('dek_1', oldProvider, store, cache);
    await getOrCreateDek('dek_2', oldProvider, store, cache);
    await getOrCreateDek('dek_3', oldProvider, store, cache);

    // Re-wrap
    const count = await rewrapAllDeks(oldProvider, newProvider, store, cache);
    expect(count).toBe(3);

    // Verify new provider can unwrap
    const stored = await store.get('dek_1');
    expect(stored).not.toBeNull();
    expect(stored!.mekVersion).toBe('new-v1');

    const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion);
    expect(unwrapped).toHaveLength(32);
  });
});

// ── FieldEncryptor (integration) ────────────────────

describe('FieldEncryptor', () => {
  let encryptor: FieldEncryptor;
  const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' };

  beforeEach(() => {
    encryptor = createFieldEncryptor({ keyProvider: 'memory' });
  });

  it('encrypt → decrypt roundtrip', async () => {
    const encrypted = await encryptor.encrypt('Hello World', ctx);
    expect(encrypted.__encrypted).toBe(true);
    const decrypted = await encryptor.decrypt(encrypted, ctx);
    expect(decrypted).toBe('Hello World');
  });

  it('encryptBatch → decryptBatch roundtrip', async () => {
    const plaintexts = ['one', 'two', 'three'];
    const encrypted = await encryptor.encryptBatch(plaintexts, ctx);
    expect(encrypted).toHaveLength(3);
    const decrypted = await encryptor.decryptBatch(encrypted, ctx);
    expect(decrypted).toEqual(plaintexts);
  });

  it('isEncrypted works via encryptor', async () => {
    const encrypted = await encryptor.encrypt('test', ctx);
    expect(encryptor.isEncrypted(encrypted)).toBe(true);
    expect(encryptor.isEncrypted('plaintext')).toBe(false);
  });

  it('different users get different DEKs', async () => {
    const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' };
    const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' };

    const e1 = await encryptor.encrypt('same text', ctx1);
    const e2 = await encryptor.encrypt('same text', ctx2);

    expect(e1.dekId).not.toBe(e2.dekId);
    expect(e1.ct).not.toBe(e2.ct);
  });

  it('JSON-serialized array encryption roundtrip', async () => {
    const transcript = [
      { role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' },
      { role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' },
    ];
    const serialized = JSON.stringify(transcript);
    const encrypted = await encryptor.encrypt(serialized, ctx);
    const decrypted = await encryptor.decrypt(encrypted, ctx);
    expect(JSON.parse(decrypted)).toEqual(transcript);
  });
});

// ── Factory config validation ───────────────────────

describe('createFieldEncryptor config', () => {
  it('throws on unknown provider', () => {
    expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/);
  });

  it('throws on env provider without key', () => {
    expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/);
  });

  it('throws on akv provider without vaultUrl', () => {
    expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow(
      /keyVaultUrl/
    );
  });

  it('throws on akv provider without mekName', () => {
    expect(() =>
      createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' })
    ).toThrow(/mekName/);
  });

  it('env provider works with hex key', async () => {
    const hexKey = randomBytes(32).toString('hex');
    const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey });
    const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' };
    const encrypted = await enc.encrypt('secret', ctx);
    const decrypted = await enc.decrypt(encrypted, ctx);
    expect(decrypted).toBe('secret');
  });
});

// ── Encryption toggle (enabled/disabled) ────────────

describe('enabled: false (NullFieldEncryptor)', () => {
  const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' };

  it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => {
    const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
    expect(enc).toBeInstanceOf(NullFieldEncryptor);
  });

  it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => {
    const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' });
    expect(enc).not.toBeInstanceOf(NullFieldEncryptor);
    expect(enc).toBeInstanceOf(FieldEncryptor);
  });

  it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => {
    const enc = createFieldEncryptor({ keyProvider: 'memory' });
    expect(enc).not.toBeInstanceOf(NullFieldEncryptor);
  });

  it('encrypt returns sentinel with plaintext in ct field', async () => {
    const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
    const result = await enc.encrypt('hello world', ctx);
    expect(result.__encrypted).toBe(true);
    expect(result.ct).toBe('hello world');
    expect(result.iv).toBe('disabled');
    expect(result.tag).toBe('disabled');
    expect(result.dekId).toBe('disabled');
  });

  it('decrypt returns plaintext from ct field', async () => {
    const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
    const encrypted = await enc.encrypt('secret', ctx);
    const decrypted = await enc.decrypt(encrypted, ctx);
    expect(decrypted).toBe('secret');
  });

  it('encryptBatch returns sentinels for all items', async () => {
    const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
    const results = await enc.encryptBatch(['a', 'b', 'c'], ctx);
    expect(results).toHaveLength(3);
    expect(results[0].ct).toBe('a');
    expect(results[1].ct).toBe('b');
    expect(results[2].ct).toBe('c');
  });

  it('decryptBatch returns plaintexts from ct fields', async () => {
    const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
    const encrypted = await enc.encryptBatch(['x', 'y'], ctx);
    const decrypted = await enc.decryptBatch(encrypted, ctx);
    expect(decrypted).toEqual(['x', 'y']);
  });

  it('isEncrypted returns true for disabled sentinel', async () => {
    const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' });
    const result = await enc.encrypt('test', ctx);
    expect(enc.isEncrypted(result)).toBe(true);
  });
});

// ── Migration ───────────────────────────────────────

describe('migrateDocuments', () => {
  it('encrypts plaintext fields and skips already-encrypted', async () => {
    const encryptor = createFieldEncryptor({ keyProvider: 'memory' });
    const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' };

    const alreadyEncrypted = await encryptor.encrypt('old', ctx);
    const docs = [
      { id: '1', body: 'plaintext note' },
      { id: '2', body: alreadyEncrypted },
      { id: '3', body: 'another note' },
      { id: '4', body: null },
    ];

    const written: Array<{ id: string; body: EncryptedField }> = [];

    const result = await migrateDocuments({
      fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize),
      getId: doc => doc.id,
      getField: doc => doc.body,
      encryptValue: plaintext => encryptor.encrypt(plaintext, ctx),
      writeBack: async (doc, encrypted) => {
        written.push({ id: doc.id, body: encrypted });
      },
      batchSize: 10,
    });

    expect(result.scanned).toBe(4);
    expect(result.encrypted).toBe(2); // id 1 + id 3
    expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null)
    expect(result.errors).toBe(0);
    expect(written).toHaveLength(2);
  });

  it('dry run does not write', async () => {
    const encryptor = createFieldEncryptor({ keyProvider: 'memory' });
    const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' };

    const docs = [{ id: '1', body: 'plaintext' }];
    let writeCount = 0;

    const result = await migrateDocuments({
      fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize),
      getId: doc => doc.id,
      getField: doc => doc.body,
      encryptValue: plaintext => encryptor.encrypt(plaintext, ctx),
      writeBack: async () => {
        writeCount++;
      },
      dryRun: true,
    });

    expect(result.encrypted).toBe(1);
    expect(writeCount).toBe(0);
  });
});

// ── CosmosDekStore ──────────────────────────────────

describe('CosmosDekStore', () => {
  function createMockContainer() {
    const docs = new Map<string, Record<string, unknown>>();

    const container = {
      item: (id: string, _pk: string) => ({
        read: async <T>() => {
          const resource = docs.get(id) as T | undefined;
          if (!resource) {
            const err = new Error('Not found') as Error & { code: number };
            err.code = 404;
            throw err;
          }
          return { resource };
        },
        delete: async () => {
          if (!docs.has(id)) {
            const err = new Error('Not found') as Error & { code: number };
            err.code = 404;
            throw err;
          }
          docs.delete(id);
        },
      }),
      items: {
        upsert: async (doc: Record<string, unknown>) => {
          docs.set(doc.id as string, doc);
        },
        query: (_sql: string) => ({
          fetchAll: async () => ({
            resources: [...docs.values()].map(d => ({ dekId: d.dekId })),
          }),
        }),
      },
    };
    return container as unknown as import('@azure/cosmos').Container;
  }

  it('put and get a DEK', async () => {
    const store = new CosmosDekStore(createMockContainer());
    const dek = {
      dekId: 'dek_test',
      wrappedKey: 'aabbcc',
      mekVersion: 'v1',
      createdAt: '2026-01-01T00:00:00Z',
    };
    await store.put(dek);
    const got = await store.get('dek_test');
    expect(got).toEqual(dek);
  });

  it('get returns null for missing DEK', async () => {
    const store = new CosmosDekStore(createMockContainer());
    const got = await store.get('nonexistent');
    expect(got).toBeNull();
  });

  it('listIds returns all stored DEK IDs', async () => {
    const store = new CosmosDekStore(createMockContainer());
    await store.put({
      dekId: 'dek_a',
      wrappedKey: '11',
      mekVersion: 'v1',
      createdAt: '2026-01-01T00:00:00Z',
    });
    await store.put({
      dekId: 'dek_b',
      wrappedKey: '22',
      mekVersion: 'v1',
      createdAt: '2026-01-01T00:00:00Z',
    });
    const ids = await store.listIds();
    expect(ids).toContain('dek_a');
    expect(ids).toContain('dek_b');
    expect(ids.length).toBe(2);
  });

  it('delete removes a DEK', async () => {
    const store = new CosmosDekStore(createMockContainer());
    await store.put({
      dekId: 'dek_del',
      wrappedKey: '33',
      mekVersion: 'v1',
      createdAt: '2026-01-01T00:00:00Z',
    });
    await store.delete('dek_del');
    expect(await store.get('dek_del')).toBeNull();
  });

  it('delete is idempotent (no error on missing)', async () => {
    const store = new CosmosDekStore(createMockContainer());
    await expect(store.delete('nonexistent')).resolves.toBeUndefined();
  });

  it('put overwrites existing DEK (upsert)', async () => {
    const store = new CosmosDekStore(createMockContainer());
    await store.put({
      dekId: 'dek_up',
      wrappedKey: 'old',
      mekVersion: 'v1',
      createdAt: '2026-01-01T00:00:00Z',
    });
    await store.put({
      dekId: 'dek_up',
      wrappedKey: 'new',
      mekVersion: 'v2',
      createdAt: '2026-01-01T00:00:00Z',
    });
    const got = await store.get('dek_up');
    expect(got?.wrappedKey).toBe('new');
    expect(got?.mekVersion).toBe('v2');
  });
});
