/**
 * Tests for client-side encryption utilities (src/lib/crypto.ts)
 *
 * Uses a self-consistent XOR-based sodium mock so tests run without
 * WebAssembly (libsodium) in the Node/happy-dom environment.
 * The real PBKDF2 key derivation (Web Crypto API) is tested as-is.
 */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
  deriveSecretKey,
  generateDeviceKey,
  encryptEntry,
  decryptEntry,
  encryptSecretKey,
  decryptSecretKey,
  generateSalt,
  getSalt,
  saveSalt,
  saveDeviceKey,
  getDeviceKey,
  clearDeviceKey,
} from '../lib/crypto'

// ---------------------------------------------------------------------------
// Self-consistent sodium mock (XOR cipher + 16-byte auth tag)
// encrypt(msg, key) = tag(16 zeros) || xor(msg, key)
// decrypt(ct, key)  = xor(ct[16:], key)
// Wrong-key behavior is tested by overriding crypto_secretbox_open_easy to throw.
// ---------------------------------------------------------------------------

function xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {
  return data.map((byte, i) => byte ^ key[i % key.length])
}

const createMockSodium = (overrides: Record<string, unknown> = {}) => ({
  randombytes_buf: (size: number) => new Uint8Array(size).fill(42),
  crypto_secretbox_NONCEBYTES: 24,
  crypto_secretbox_easy: (msg: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
    const tag = new Uint8Array(16)
    const encrypted = xorBytes(msg, key)
    const result = new Uint8Array(tag.length + encrypted.length)
    result.set(tag)
    result.set(encrypted, tag.length)
    return result
  },
  crypto_secretbox_open_easy: (ct: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
    if (ct.length < 16) throw new Error('invalid ciphertext length')
    return xorBytes(ct.slice(16), key)
  },
  to_base64: (data: Uint8Array) => Buffer.from(data).toString('base64'),
  from_base64: (str: string) => new Uint8Array(Buffer.from(str, 'base64')),
  from_string: (str: string) => new TextEncoder().encode(str),
  to_string: (data: Uint8Array) => new TextDecoder().decode(data),
  ...overrides,
})

vi.mock('../utils/sodium', () => ({
  getSodium: vi.fn(),
}))

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('crypto utilities', () => {
  beforeEach(async () => {
    const { getSodium } = await import('../utils/sodium')
    vi.mocked(getSodium).mockResolvedValue(createMockSodium() as never)
    localStorage.clear()
  })

  // ── deriveSecretKey ──────────────────────────────────────────────────────

  describe('deriveSecretKey', () => {
    it('returns a 32-byte Uint8Array', async () => {
      const key = await deriveSecretKey('test-uid-123', 'test-salt')
      expect(key).toBeInstanceOf(Uint8Array)
      expect(key.length).toBe(32)
    })

    it('is deterministic — same inputs always produce the same key', async () => {
      const key1 = await deriveSecretKey('uid-abc', 'salt-xyz')
      const key2 = await deriveSecretKey('uid-abc', 'salt-xyz')
      expect(key1).toEqual(key2)
    })

    it('different UIDs produce different keys', async () => {
      const key1 = await deriveSecretKey('uid-1', 'same-salt')
      const key2 = await deriveSecretKey('uid-2', 'same-salt')
      expect(key1).not.toEqual(key2)
    })

    it('different salts produce different keys', async () => {
      const key1 = await deriveSecretKey('same-uid', 'salt-a')
      const key2 = await deriveSecretKey('same-uid', 'salt-b')
      expect(key1).not.toEqual(key2)
    })

    it('handles empty UID string', async () => {
      const key = await deriveSecretKey('', 'some-salt')
      expect(key).toBeInstanceOf(Uint8Array)
      expect(key.length).toBe(32)
    })
  })

  // ── generateDeviceKey ────────────────────────────────────────────────────

  describe('generateDeviceKey', () => {
    it('returns a 32-byte Uint8Array', async () => {
      const key = await generateDeviceKey()
      expect(key).toBeInstanceOf(Uint8Array)
      expect(key.length).toBe(32)
    })

    it('generates unique keys each time (random)', async () => {
      const key1 = await generateDeviceKey()
      const key2 = await generateDeviceKey()
      // Two random 256-bit arrays should be different
      expect(key1).not.toEqual(key2)
    })
  })

  // ── encryptEntry / decryptEntry ──────────────────────────────────────────

  describe('encryptEntry / decryptEntry', () => {
    const secretKey = new Uint8Array(32).fill(1)

    it('roundtrip: decrypting an encrypted entry returns original content', async () => {
      const content = 'Today I am grateful for my family.'
      const { ciphertext, nonce } = await encryptEntry(content, secretKey)
      const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
      expect(decrypted).toBe(content)
    })

    it('returns base64-encoded strings for ciphertext and nonce', async () => {
      const { ciphertext, nonce } = await encryptEntry('test content', secretKey)
      expect(() => Buffer.from(ciphertext, 'base64')).not.toThrow()
      expect(() => Buffer.from(nonce, 'base64')).not.toThrow()
      // Valid base64 only contains these characters
      expect(ciphertext).toMatch(/^[A-Za-z0-9+/=]+$/)
    })

    it('handles empty string content', async () => {
      const { ciphertext, nonce } = await encryptEntry('', secretKey)
      const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
      expect(decrypted).toBe('')
    })

    it('handles unicode and emoji content', async () => {
      const content = 'Grateful for 🌟 life! नमस्ते 日本語'
      const { ciphertext, nonce } = await encryptEntry(content, secretKey)
      const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
      expect(decrypted).toBe(content)
    })

    it('handles very long content (10,000 chars)', async () => {
      const content = 'a'.repeat(10000)
      const { ciphertext, nonce } = await encryptEntry(content, secretKey)
      const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
      expect(decrypted).toBe(content)
    })

    it('different plaintext produces different ciphertext', async () => {
      const { ciphertext: ct1 } = await encryptEntry('hello world', secretKey)
      const { ciphertext: ct2 } = await encryptEntry('goodbye world', secretKey)
      expect(ct1).not.toBe(ct2)
    })

    it('decryptEntry throws "Failed to decrypt entry" on bad ciphertext', async () => {
      const { getSodium } = await import('../utils/sodium')
      vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
        crypto_secretbox_open_easy: () => { throw new Error('invalid mac') },
      }) as never)
      await expect(decryptEntry('notvalidbase64!!', 'nonce', secretKey))
        .rejects.toThrow('Failed to decrypt entry')
    })

    it('decryptEntry throws when called with wrong key', async () => {
      // Simulate libsodium authentication failure with wrong key
      const { getSodium } = await import('../utils/sodium')
      vi.mocked(getSodium)
        .mockResolvedValueOnce(createMockSodium() as never)  // for encrypt
        .mockResolvedValueOnce(createMockSodium({            // for decrypt (wrong key throws)
          crypto_secretbox_open_easy: () => { throw new Error('incorrect key') },
        }) as never)

      const { ciphertext, nonce } = await encryptEntry('secret', secretKey)
      const wrongKey = new Uint8Array(32).fill(99)
      await expect(decryptEntry(ciphertext, nonce, wrongKey))
        .rejects.toThrow('Failed to decrypt entry')
    })
  })

  // ── encryptSecretKey / decryptSecretKey ──────────────────────────────────

  describe('encryptSecretKey / decryptSecretKey', () => {
    it('roundtrip: encrypts and decrypts master key back to original', async () => {
      const masterKey = new Uint8Array(32).fill(99)
      const deviceKey = new Uint8Array(32).fill(55)
      const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
      const decrypted = await decryptSecretKey(ciphertext, nonce, deviceKey)
      expect(decrypted).toEqual(masterKey)
    })

    it('returns base64 strings', async () => {
      const masterKey = new Uint8Array(32).fill(1)
      const deviceKey = new Uint8Array(32).fill(2)
      const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
      expect(typeof ciphertext).toBe('string')
      expect(typeof nonce).toBe('string')
    })

    it('decryptSecretKey throws "Failed to decrypt secret key" on wrong device key', async () => {
      const { getSodium } = await import('../utils/sodium')
      vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
        crypto_secretbox_open_easy: () => { throw new Error('decryption failed') },
      }) as never)
      await expect(decryptSecretKey('fakeciphertext', 'fakenonce', new Uint8Array(32)))
        .rejects.toThrow('Failed to decrypt secret key')
    })
  })

  // ── salt functions ───────────────────────────────────────────────────────

  describe('generateSalt / saveSalt / getSalt', () => {
    it('generateSalt returns the constant salt string', () => {
      expect(generateSalt()).toBe('grateful-journal-v1')
    })

    it('generateSalt is idempotent', () => {
      expect(generateSalt()).toBe(generateSalt())
    })

    it('saveSalt and getSalt roundtrip', () => {
      saveSalt('my-custom-salt')
      expect(getSalt()).toBe('my-custom-salt')
    })

    it('getSalt returns null when nothing stored', () => {
      localStorage.clear()
      expect(getSalt()).toBeNull()
    })

    it('overwriting salt replaces old value', () => {
      saveSalt('first')
      saveSalt('second')
      expect(getSalt()).toBe('second')
    })
  })

  // ── device key localStorage ──────────────────────────────────────────────

  describe('saveDeviceKey / getDeviceKey / clearDeviceKey', () => {
    it('saves and retrieves device key correctly', async () => {
      const key = new Uint8Array(32).fill(7)
      await saveDeviceKey(key)
      const retrieved = await getDeviceKey()
      expect(retrieved).toEqual(key)
    })

    it('returns null when no device key is stored', async () => {
      localStorage.clear()
      const key = await getDeviceKey()
      expect(key).toBeNull()
    })

    it('clearDeviceKey removes the stored key', async () => {
      const key = new Uint8Array(32).fill(7)
      await saveDeviceKey(key)
      clearDeviceKey()
      const retrieved = await getDeviceKey()
      expect(retrieved).toBeNull()
    })

    it('overwriting device key stores the new key', async () => {
      const key1 = new Uint8Array(32).fill(1)
      const key2 = new Uint8Array(32).fill(2)
      await saveDeviceKey(key1)
      await saveDeviceKey(key2)
      const retrieved = await getDeviceKey()
      expect(retrieved).toEqual(key2)
    })
  })
})
