import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import { createMockLogger } from '../../__test-utils__/mockFactories.js';

// Mock fs/promises, crypto, os, and userDataDir before importing
const mockFs = {
  readFile: jest.fn(),
  writeFile: jest.fn()
};
const mockCrypto = {
  randomBytes: jest.fn(() => Buffer.alloc(32, 'a')),
  pbkdf2Sync: jest.fn(() => Buffer.alloc(32, 'k')),
  createCipheriv: jest.fn(),
  createDecipheriv: jest.fn()
};
const mockOs = {
  hostname: jest.fn(() => 'test-host'),
  homedir: jest.fn(() => '/home/test'),
  userInfo: jest.fn(() => ({ username: 'testuser' }))
};

jest.unstable_mockModule('fs', () => ({
  promises: mockFs
}));

jest.unstable_mockModule('crypto', () => ({
  default: mockCrypto,
  ...mockCrypto
}));

jest.unstable_mockModule('os', () => ({
  default: mockOs,
  ...mockOs
}));

jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
  getUserDataPaths: jest.fn(() => ({ settings: '/fake/settings', attachments: '/fake/attachments' })),
  ensureUserDataDirs: jest.fn(async () => {})
}));

const { default: ApiKeyManager } = await import('../apiKeyManager.js');

describe('ApiKeyManager', () => {
  let manager;
  let logger;

  beforeEach(() => {
    jest.clearAllMocks();
    logger = createMockLogger();
    manager = new ApiKeyManager(logger);
  });

  test('constructor initializes with empty keys', () => {
    expect(manager.keys.loxiaApiKey).toBeNull();
    expect(manager.keys.vendorKeys).toEqual({});
    expect(manager.initialized).toBe(false);
  });

  test('initialize sets up persistence and loads keys', async () => {
    // Mock salt file read failure (new salt generation)
    mockFs.readFile.mockRejectedValueOnce(new Error('ENOENT'));
    // Mock writeFile for salt
    mockFs.writeFile.mockResolvedValue(undefined);
    // Mock readFile for loadFromDisk - ENOENT (no existing keys)
    const enoent = new Error('No file');
    enoent.code = 'ENOENT';
    mockFs.readFile.mockRejectedValueOnce(enoent);

    await manager.initialize();
    expect(manager.initialized).toBe(true);
    expect(manager.persistenceFile).toContain('api-keys.enc');
  });

  test('initialize only runs once', async () => {
    manager.initialized = true;
    await manager.initialize();
    // Should not call ensureUserDataDirs if already initialized
    expect(logger.info).not.toHaveBeenCalled();
  });

  test('initialize handles errors gracefully', async () => {
    const { ensureUserDataDirs } = await import('../../utilities/userDataDir.js');
    ensureUserDataDirs.mockRejectedValueOnce(new Error('disk full'));

    await manager.initialize();
    expect(logger.warn).toHaveBeenCalled();
  });

  // _getMachineIdentifier moved to `utilities/secretsCipher.js` as part
  // of the DRY refactor sharing the cipher with `services/authCacheStore.js`.
  // The equivalent coverage now lives in
  // `src/utilities/__tests__/secretsCipher.test.js`.

  test('_encrypt throws when no encryption key', () => {
    expect(() => manager._encrypt('test')).toThrow('Encryption key not initialized');
  });

  test('_decrypt throws when no encryption key', () => {
    expect(() => manager._decrypt('test')).toThrow('Encryption key not initialized');
  });

  test('setSessionKeys stores loxia key and vendor keys', async () => {
    manager.persistenceFile = null; // skip persist
    await manager.setSessionKeys('sess-1', {
      loxiaApiKey: 'loxia-key-123',
      vendorKeys: { anthropic: 'anth-key' }
    });
    expect(manager.keys.loxiaApiKey).toBe('loxia-key-123');
    expect(manager.keys.vendorKeys.anthropic).toBe('anth-key');
  });

  test('setSessionKeys merges vendor keys', async () => {
    manager.persistenceFile = null;
    manager.keys.vendorKeys = { openai: 'oai-key' };
    await manager.setSessionKeys('sess-1', {
      vendorKeys: { anthropic: 'anth-key' }
    });
    expect(manager.keys.vendorKeys.openai).toBe('oai-key');
    expect(manager.keys.vendorKeys.anthropic).toBe('anth-key');
  });

  // Regression locks: the UI used to write display-only placeholder
  // strings into the editable settings field; auto-save then echoed
  // them here, overwriting real keys with strings like
  // `lx_••••••••••••••••(server-managed)` and breaking every agent
  // request with 401. loadFromDisk must scrub any placeholder that
  // already snuck onto disk before the fix, so a poisoned persist
  // file from an older build self-heals on next boot.
  describe('loadFromDisk placeholder scrub (poisoned persist file)', () => {
    /**
     * Helper: prep a manager so loadFromDisk takes the happy path
     * and returns deterministic decrypted data without going through
     * the real crypto. Each call hands back the manager + a stub.
     */
    function primeForLoad(manager, decryptedJson) {
      manager.persistenceFile = '/fake/api-keys.enc';
      manager.encryptionKey = Buffer.alloc(32, 'k');
      mockFs.readFile.mockResolvedValueOnce('encrypted-bytes-ignored');
      mockFs.writeFile.mockResolvedValue(undefined);
      manager._decrypt = jest.fn(() => decryptedJson);
    }

    test('scrubs bullet-string loxia key + re-persists', async () => {
      primeForLoad(manager, JSON.stringify({
        version: 2,
        keys: {
          loxiaApiKey: 'lx_••••••••••••••••(server-managed)',
          vendorKeys: { anthropic: 'sk-ant-real-key' },
        },
      }));
      // Stub persist so we can observe it without going through crypto.
      manager.persist = jest.fn().mockResolvedValue(undefined);

      await manager.loadFromDisk();

      expect(manager.keys.loxiaApiKey).toBeNull();
      // Real vendor keys preserved.
      expect(manager.keys.vendorKeys.anthropic).toBe('sk-ant-real-key');
      // Best-effort re-persist fired.
      expect(manager.persist).toHaveBeenCalledTimes(1);
      // Operator-visible warning logged so the scrub is observable.
      expect(logger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Scrubbing placeholder loxiaApiKey')
      );
    });

    test('scrubs platform-managed sentinel and any placeholder vendor keys', async () => {
      primeForLoad(manager, JSON.stringify({
        version: 2,
        keys: {
          loxiaApiKey: 'platform-managed',
          vendorKeys: {
            anthropic: '••••••••(server-managed)',
            openai:    'sk-real-openai-key',
            azure:     'foo[server-managed]bar',
          },
        },
      }));
      manager.persist = jest.fn().mockResolvedValue(undefined);

      await manager.loadFromDisk();

      expect(manager.keys.loxiaApiKey).toBeNull();
      // Placeholder vendors removed entirely (NOT just blanked) so the
      // shape stays clean for downstream serialization.
      expect(manager.keys.vendorKeys).not.toHaveProperty('anthropic');
      expect(manager.keys.vendorKeys).not.toHaveProperty('azure');
      // Real vendor preserved.
      expect(manager.keys.vendorKeys.openai).toBe('sk-real-openai-key');
      expect(manager.persist).toHaveBeenCalledTimes(1);
    });

    test('clean persist file: no scrub, no re-persist', async () => {
      primeForLoad(manager, JSON.stringify({
        version: 2,
        keys: {
          loxiaApiKey: 'lx_real_loxia_key_abc123',
          vendorKeys: { anthropic: 'sk-ant-real', openai: 'sk-real-openai' },
        },
      }));
      manager.persist = jest.fn().mockResolvedValue(undefined);

      await manager.loadFromDisk();

      expect(manager.keys.loxiaApiKey).toBe('lx_real_loxia_key_abc123');
      expect(manager.keys.vendorKeys).toEqual({ anthropic: 'sk-ant-real', openai: 'sk-real-openai' });
      // Nothing to scrub → no extra write.
      expect(manager.persist).not.toHaveBeenCalled();
    });

    test('legacy globalKeys format: scrub still applies after migration', async () => {
      primeForLoad(manager, JSON.stringify({
        // No `version` / `keys` — old shape with `globalKeys` at root.
        globalKeys: {
          loxiaApiKey: 'lx_••••(server-managed)',
          vendorKeys:  { anthropic: 'sk-ant-still-good' },
        },
      }));
      manager.persist = jest.fn().mockResolvedValue(undefined);

      await manager.loadFromDisk();

      expect(manager.keys.loxiaApiKey).toBeNull();
      expect(manager.keys.vendorKeys.anthropic).toBe('sk-ant-still-good');
      expect(manager.persist).toHaveBeenCalled();
    });

    test('persist failure during scrub is non-fatal (in-memory state still clean)', async () => {
      primeForLoad(manager, JSON.stringify({
        version: 2,
        keys: { loxiaApiKey: 'platform-managed', vendorKeys: {} },
      }));
      manager.persist = jest.fn().mockRejectedValue(new Error('disk full'));

      // Should NOT throw — re-persist failure is tolerated.
      await expect(manager.loadFromDisk()).resolves.toBeUndefined();
      // In-memory state is clean even though disk wasn't updated.
      expect(manager.keys.loxiaApiKey).toBeNull();
    });
  });

  describe('placeholder rejection (server-managed key bug guard)', () => {
    test('refuses bullet-string placeholder for loxia key, keeps existing', async () => {
      manager.persistenceFile = null;
      manager.keys.loxiaApiKey = 'real-key-existing';
      await manager.setSessionKeys('sess-1', {
        loxiaApiKey: 'lx_••••••••••••••••(server-managed)'
      });
      expect(manager.keys.loxiaApiKey).toBe('real-key-existing');
    });

    test('refuses [server-managed] tag', async () => {
      manager.persistenceFile = null;
      manager.keys.loxiaApiKey = 'real-key';
      await manager.setSessionKeys('sess-1', {
        loxiaApiKey: 'lx_abc[server-managed]'
      });
      expect(manager.keys.loxiaApiKey).toBe('real-key');
    });

    test('refuses platform-managed sentinel', async () => {
      manager.persistenceFile = null;
      manager.keys.loxiaApiKey = 'real-key';
      await manager.setSessionKeys('sess-1', { loxiaApiKey: 'platform-managed' });
      expect(manager.keys.loxiaApiKey).toBe('real-key');
    });

    test('refuses placeholder vendor keys', async () => {
      manager.persistenceFile = null;
      manager.keys.vendorKeys = { anthropic: 'real-anth-key' };
      await manager.setSessionKeys('sess-1', {
        vendorKeys: { anthropic: '••••••••(server-managed)' }
      });
      expect(manager.keys.vendorKeys.anthropic).toBe('real-anth-key');
    });

    test('still accepts real keys (regression guard for the guard)', async () => {
      manager.persistenceFile = null;
      manager.keys.loxiaApiKey = 'old';
      await manager.setSessionKeys('sess-1', { loxiaApiKey: 'lx_brand_new_real_key' });
      expect(manager.keys.loxiaApiKey).toBe('lx_brand_new_real_key');
    });
  });

  test('getSessionKeys returns keys regardless of sessionId', () => {
    manager.keys.loxiaApiKey = 'test-key';
    const keys = manager.getSessionKeys('any-session');
    expect(keys.loxiaApiKey).toBe('test-key');
  });

  test('getKeysForRequest returns loxia key for platform-provided', () => {
    manager.keys.loxiaApiKey = 'loxia-123';
    const result = manager.getKeysForRequest('sess', { platformProvided: true, vendor: 'anthropic' });
    expect(result.loxiaApiKey).toBe('loxia-123');
    expect(result.vendorApiKey).toBeNull();
  });

  test('getKeysForRequest returns vendor key for direct access', () => {
    manager.keys.loxiaApiKey = 'loxia-123';
    manager.keys.vendorKeys = { anthropic: 'anth-key' };
    const result = manager.getKeysForRequest('sess', { platformProvided: false, vendor: 'anthropic' });
    expect(result.loxiaApiKey).toBe('loxia-123');
    expect(result.vendorApiKey).toBe('anth-key');
  });

  test('getKeysForRequest returns null vendor key when no vendor specified', () => {
    const result = manager.getKeysForRequest('sess', {});
    expect(result.vendorApiKey).toBeNull();
  });

  test('removeSessionKeys clears all keys and returns true if had keys', async () => {
    manager.persistenceFile = null;
    manager.keys.loxiaApiKey = 'some-key';
    const hadKeys = await manager.removeSessionKeys('sess');
    expect(hadKeys).toBe(true);
    expect(manager.keys.loxiaApiKey).toBeNull();
    expect(manager.keys.vendorKeys).toEqual({});
  });

  test('removeSessionKeys returns false if no keys existed', async () => {
    const hadKeys = await manager.removeSessionKeys('sess');
    expect(hadKeys).toBe(false);
  });

  test('setGlobalKeys delegates to setSessionKeys', async () => {
    manager.persistenceFile = null;
    await manager.setGlobalKeys({ loxiaApiKey: 'global-key' });
    expect(manager.keys.loxiaApiKey).toBe('global-key');
  });

  test('getActiveSessions returns empty array', () => {
    expect(manager.getActiveSessions()).toEqual([]);
  });

  test('cleanupExpiredSessions returns 0', () => {
    expect(manager.cleanupExpiredSessions()).toBe(0);
  });

  test('persist does nothing when not initialized', async () => {
    manager.persistenceFile = null;
    await manager.persist();
    expect(mockFs.writeFile).not.toHaveBeenCalled();
  });

  test('loadFromDisk does nothing when not initialized', async () => {
    manager.persistenceFile = null;
    await manager.loadFromDisk();
    // No errors expected
  });

  test('loadFromDisk handles ENOENT gracefully', async () => {
    manager.persistenceFile = '/fake/file';
    manager.encryptionKey = Buffer.alloc(32);
    const err = new Error('not found');
    err.code = 'ENOENT';
    mockFs.readFile.mockRejectedValueOnce(err);
    await manager.loadFromDisk();
    expect(logger.debug).toHaveBeenCalled();
  });

  test('loadFromDisk handles decrypt errors gracefully', async () => {
    manager.persistenceFile = '/fake/file';
    manager.encryptionKey = Buffer.alloc(32);
    mockFs.readFile.mockRejectedValueOnce(new Error('bad data'));
    await manager.loadFromDisk();
    expect(logger.warn).toHaveBeenCalled();
  });
});
