const crypto = require('crypto');
const path = require('path');

// Mock fs BEFORE requiring crypto-utils
jest.mock('fs');
const fs = require('fs');

const TEST_KEY = crypto.randomBytes(32);
const TEST_KEY_HEX = TEST_KEY.toString('hex');

// Load the module once — no jest.resetModules() needed
// We control key state via clearCachedKey() + env vars
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
const cryptoUtils = require('../src/security/crypto-utils');

describe('Crypto Utils', () => {
  beforeEach(() => {
    // Reset key state and env vars before each test
    cryptoUtils.clearCachedKey();
    delete process.env.DASHCADDY_ENCRYPTION_KEY;
    delete process.env.ENCRYPTION_KEY_FILE;
    // Reset fs mock implementations
    fs.existsSync.mockReturnValue(false);
    fs.writeFileSync.mockImplementation(() => {});
    fs.readFileSync.mockReturnValue('');
  });

  // Helper: ensure module has a known key loaded (via env var)
  function ensureKey() {
    process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
    cryptoUtils.clearCachedKey();
    return cryptoUtils.loadOrCreateKey();
  }

  describe('loadOrCreateKey', () => {
    it('loads key from DASHCADDY_ENCRYPTION_KEY env var', () => {
      process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
      const key = cryptoUtils.loadOrCreateKey();
      expect(Buffer.isBuffer(key)).toBe(true);
      expect(key.length).toBe(32);
      expect(key.toString('hex')).toBe(TEST_KEY_HEX);
    });

    it('loads key from file when env var absent', () => {
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockReturnValue(TEST_KEY_HEX);
      const key = cryptoUtils.loadOrCreateKey();
      expect(key.toString('hex')).toBe(TEST_KEY_HEX);
      expect(fs.readFileSync).toHaveBeenCalled();
    });

    it('generates new key when no file and no env var', () => {
      const key = cryptoUtils.loadOrCreateKey();
      expect(Buffer.isBuffer(key)).toBe(true);
      expect(key.length).toBe(32);
    });

    it('saves generated key to file with 0o600 permissions', () => {
      cryptoUtils.loadOrCreateKey();
      expect(fs.writeFileSync).toHaveBeenCalledWith(
        expect.any(String),
        expect.any(String),
        { mode: 0o600 }
      );
    });

    it('returns cached key on subsequent calls', () => {
      process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
      const key1 = cryptoUtils.loadOrCreateKey();
      const key2 = cryptoUtils.loadOrCreateKey();
      expect(key1).toBe(key2); // Same reference
    });

    it('handles invalid key file (too short) by generating new key', () => {
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockReturnValue('abcd'); // Too short
      const key = cryptoUtils.loadOrCreateKey();
      expect(key.length).toBe(32);
      expect(fs.writeFileSync).toHaveBeenCalled();
    });

    it('handles unreadable key file gracefully', () => {
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockImplementation(() => { throw new Error('EACCES'); });
      const key = cryptoUtils.loadOrCreateKey();
      expect(key.length).toBe(32);
    });

    it('handles write failure gracefully', () => {
      fs.writeFileSync.mockImplementation(() => { throw new Error('EROFS'); });
      const key = cryptoUtils.loadOrCreateKey();
      expect(key.length).toBe(32);
    });

    it('clearCachedKey forces reload on next call', () => {
      process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
      const key1 = cryptoUtils.loadOrCreateKey();
      cryptoUtils.clearCachedKey();
      const key2 = cryptoUtils.loadOrCreateKey();
      expect(key1).not.toBe(key2);
      expect(key1.toString('hex')).toBe(key2.toString('hex'));
    });
  });

  describe('encrypt / decrypt', () => {
    beforeEach(() => ensureKey());

    it('roundtrip: encrypt then decrypt returns original string', () => {
      const plaintext = 'hello world';
      const encrypted = cryptoUtils.encrypt(plaintext);
      const decrypted = cryptoUtils.decrypt(encrypted);
      expect(decrypted).toBe(plaintext);
    });

    it('roundtrip: encrypt then decrypt returns original JSON object', () => {
      const obj = { user: 'admin', pass: 'secret123' };
      const encrypted = cryptoUtils.encrypt(obj);
      const decrypted = cryptoUtils.decrypt(encrypted);
      expect(JSON.parse(decrypted)).toEqual(obj);
    });

    it('output format is iv:authTag:ciphertext (3 colon-separated base64 parts)', () => {
      const encrypted = cryptoUtils.encrypt('test');
      const parts = encrypted.split(':');
      expect(parts).toHaveLength(3);
      for (const part of parts) {
        expect(() => Buffer.from(part, 'base64')).not.toThrow();
      }
    });

    it('each encryption produces different ciphertext (random IV)', () => {
      const encrypted1 = cryptoUtils.encrypt('same data');
      const encrypted2 = cryptoUtils.encrypt('same data');
      expect(encrypted1).not.toBe(encrypted2);
    });

    it('decrypt with tampered authTag throws', () => {
      const encrypted = cryptoUtils.encrypt('sensitive');
      const parts = encrypted.split(':');
      const tamperedTag = Buffer.from('aaaaaaaaaaaaaaaa').toString('base64');
      const tampered = `${parts[0]}:${tamperedTag}:${parts[2]}`;
      expect(() => cryptoUtils.decrypt(tampered)).toThrow();
    });

    it('decrypt with tampered ciphertext throws', () => {
      const encrypted = cryptoUtils.encrypt('sensitive');
      const parts = encrypted.split(':');
      const tampered = `${parts[0]}:${parts[1]}:${Buffer.from('garbage').toString('base64')}`;
      expect(() => cryptoUtils.decrypt(tampered)).toThrow();
    });

    it('decrypt with invalid format (2 parts) throws', () => {
      expect(() => cryptoUtils.decrypt('part1:part2')).toThrow('Invalid encrypted data format');
    });

    it('decrypt with invalid format (4 parts) throws', () => {
      expect(() => cryptoUtils.decrypt('a:b:c:d')).toThrow('Invalid encrypted data format');
    });
  });

  describe('isEncrypted', () => {
    beforeEach(() => ensureKey());

    it('returns true for properly formatted encrypted string', () => {
      const encrypted = cryptoUtils.encrypt('test');
      expect(cryptoUtils.isEncrypted(encrypted)).toBe(true);
    });

    it('returns false for plain text', () => {
      expect(cryptoUtils.isEncrypted('just a normal string')).toBe(false);
    });

    it('returns false for non-string input', () => {
      expect(cryptoUtils.isEncrypted(123)).toBe(false);
      expect(cryptoUtils.isEncrypted(null)).toBe(false);
      expect(cryptoUtils.isEncrypted(undefined)).toBe(false);
      expect(cryptoUtils.isEncrypted({ key: 'val' })).toBe(false);
    });

    it('returns false for string with fewer than 3 colon-separated parts', () => {
      expect(cryptoUtils.isEncrypted('only:two')).toBe(false);
    });
  });

  describe('encryptFields / decryptFields', () => {
    beforeEach(() => ensureKey());

    it('encrypts specified fields, leaves others untouched', () => {
      const obj = { username: 'admin', password: 'secret', role: 'admin' };
      const result = cryptoUtils.encryptFields(obj, ['password']);
      expect(result.username).toBe('admin');
      expect(result.role).toBe('admin');
      expect(result.password).not.toBe('secret');
      expect(cryptoUtils.isEncrypted(result.password)).toBe(true);
    });

    it('sets _encrypted: true and _encryptedFields array', () => {
      const result = cryptoUtils.encryptFields({ a: '1' }, ['a']);
      expect(result._encrypted).toBe(true);
      expect(result._encryptedFields).toEqual(['a']);
    });

    it('skips null/undefined field values', () => {
      const obj = { password: null, token: undefined, name: 'test' };
      const result = cryptoUtils.encryptFields(obj, ['password', 'token']);
      expect(result.password).toBeNull();
      expect(result.token).toBeUndefined();
    });

    it('does not double-encrypt already-encrypted fields', () => {
      const obj = { password: 'secret' };
      const first = cryptoUtils.encryptFields(obj, ['password']);
      const encryptedValue = first.password;
      const second = cryptoUtils.encryptFields({ password: encryptedValue }, ['password']);
      expect(second.password).toBe(encryptedValue);
    });

    it('decryptFields restores original values and removes markers', () => {
      const original = { username: 'admin', password: 'secret' };
      const encrypted = cryptoUtils.encryptFields(original, ['password']);
      const decrypted = cryptoUtils.decryptFields(encrypted);
      expect(decrypted.password).toBe('secret');
      expect(decrypted.username).toBe('admin');
      expect(decrypted._encrypted).toBeUndefined();
      expect(decrypted._encryptedFields).toBeUndefined();
    });

    it('decryptFields with no _encrypted flag returns object unchanged', () => {
      const obj = { name: 'test' };
      const result = cryptoUtils.decryptFields(obj);
      expect(result).toEqual(obj);
    });
  });

  describe('readEncryptedFile / writeEncryptedFile', () => {
    beforeEach(() => ensureKey());

    it('writeEncryptedFile encrypts and writes JSON', () => {
      cryptoUtils.writeEncryptedFile('/tmp/creds.json', { password: 'secret' }, ['password']);
      expect(fs.writeFileSync).toHaveBeenCalledWith(
        '/tmp/creds.json',
        expect.any(String),
        'utf8'
      );
      const writtenData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
      expect(writtenData._encrypted).toBe(true);
    });

    it('readEncryptedFile reads and decrypts', () => {
      const encrypted = cryptoUtils.encryptFields({ password: 'secret' }, ['password']);
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockReturnValue(JSON.stringify(encrypted));

      const result = cryptoUtils.readEncryptedFile('/tmp/creds.json', ['password']);
      expect(result.password).toBe('secret');
      expect(result._encrypted).toBeUndefined();
    });

    it('readEncryptedFile returns null when file missing', () => {
      fs.existsSync.mockReturnValue(false);
      const result = cryptoUtils.readEncryptedFile('/tmp/nope.json');
      expect(result).toBeNull();
    });

    it('readEncryptedFile returns null on corrupt JSON', () => {
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockReturnValue('{broken json');
      const result = cryptoUtils.readEncryptedFile('/tmp/bad.json');
      expect(result).toBeNull();
    });

    it('readEncryptedFile returns plaintext data when not encrypted', () => {
      const plainData = { username: 'admin', password: 'plain' };
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockReturnValue(JSON.stringify(plainData));
      const result = cryptoUtils.readEncryptedFile('/tmp/plain.json');
      expect(result.password).toBe('plain');
    });
  });

  describe('deriveKey', () => {
    it('returns 32-byte buffer', async () => {
      const key = await cryptoUtils.deriveKey('password', crypto.randomBytes(32));
      expect(Buffer.isBuffer(key)).toBe(true);
      expect(key.length).toBe(32);
    });

    it('same password + salt yields same key', async () => {
      const salt = crypto.randomBytes(32);
      const key1 = await cryptoUtils.deriveKey('mypass', salt);
      const key2 = await cryptoUtils.deriveKey('mypass', salt);
      expect(key1.equals(key2)).toBe(true);
    });

    it('different salt yields different key', async () => {
      const key1 = await cryptoUtils.deriveKey('mypass', crypto.randomBytes(32));
      const key2 = await cryptoUtils.deriveKey('mypass', crypto.randomBytes(32));
      expect(key1.equals(key2)).toBe(false);
    });
  });

  describe('rotateKey / decryptWithKey', () => {
    beforeEach(() => ensureKey());

    it('rotateKey generates new key and returns oldKey + newKey', () => {
      const { oldKey, newKey } = cryptoUtils.rotateKey();
      expect(Buffer.isBuffer(oldKey)).toBe(true);
      expect(Buffer.isBuffer(newKey)).toBe(true);
      expect(oldKey.length).toBe(32);
      expect(newKey.length).toBe(32);
      expect(oldKey.equals(newKey)).toBe(false);
    });

    it('old data is decryptable with decryptWithKey using oldKey', () => {
      const plaintext = 'my secret';
      const encrypted = cryptoUtils.encrypt(plaintext);
      const { oldKey } = cryptoUtils.rotateKey();
      const decrypted = cryptoUtils.decryptWithKey(encrypted, oldKey);
      expect(decrypted).toBe(plaintext);
    });

    it('new encrypt uses the new key after rotation', () => {
      const { newKey } = cryptoUtils.rotateKey();
      const encrypted = cryptoUtils.encrypt('after rotation');
      const decrypted = cryptoUtils.decryptWithKey(encrypted, newKey);
      expect(decrypted).toBe('after rotation');
    });

    it('rotateKey throws if file write fails', () => {
      fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
      expect(() => cryptoUtils.rotateKey()).toThrow('Failed to save new encryption key');
    });

    it('decryptWithKey with invalid format throws', () => {
      expect(() => cryptoUtils.decryptWithKey('bad:format', TEST_KEY)).toThrow(
        'Invalid encrypted data format'
      );
    });
  });
});
