import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockPoolQuery, mockClientQuery, mockClientRelease, mockConnect } = vi.hoisted(() => {
  const mockClientQuery = vi.fn();
  const mockClientRelease = vi.fn();
  return {
    mockPoolQuery: vi.fn(),
    mockClientQuery,
    mockClientRelease,
    mockConnect: vi.fn().mockResolvedValue({
      query: mockClientQuery,
      release: mockClientRelease,
    }),
  };
});

vi.mock('../../src/infra/database/database.manager.js', () => ({
  DatabaseManager: {
    getInstance: () => ({
      getPool: () => ({
        query: mockPoolQuery,
        connect: mockConnect,
      }),
    }),
  },
}));

vi.mock('../../src/infra/security/encryption.manager.js', () => ({
  EncryptionManager: {
    encrypt: (value: string) => `enc:${value}`,
    decrypt: (value: string) => value.replace(/^enc:/, ''),
  },
}));

vi.mock('../../src/utils/logger.js', () => ({
  default: {
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
  },
}));

async function loadSecretService() {
  const { SecretService } = await import('../../src/services/secrets/secret.service.js');
  return SecretService.getInstance();
}

describe('SecretService anon key', () => {
  beforeEach(() => {
    vi.resetModules();
    mockPoolQuery.mockReset();
    mockClientQuery.mockReset();
    mockClientRelease.mockClear();
    mockConnect.mockClear();
    vi.stubEnv('ACCESS_ANON_KEY', '');
  });

  afterEach(() => {
    vi.unstubAllEnvs();
  });

  describe('generateAnonKey', () => {
    it('generates an opaque key with the anon_ prefix', async () => {
      const service = await loadSecretService();
      const key = service.generateAnonKey();

      expect(key).toMatch(/^anon_[0-9a-f]{64}$/);
      expect(service.generateAnonKey()).not.toBe(key);
    });
  });

  describe('verifyAnonKey', () => {
    it('rejects values without the anon_ prefix without touching the database', async () => {
      const service = await loadSecretService();

      await expect(service.verifyAnonKey('ik_something')).resolves.toBe(false);
      await expect(service.verifyAnonKey('')).resolves.toBe(false);
      expect(mockPoolQuery).not.toHaveBeenCalled();
    });

    it('accepts the active anon key', async () => {
      mockPoolQuery.mockResolvedValue({
        rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }],
      });
      const service = await loadSecretService();

      await expect(service.verifyAnonKey('anon_active')).resolves.toBe(true);
      await expect(service.verifyAnonKey('anon_wrong')).resolves.toBe(false);
    });

    it('deduplicates concurrent cold-cache loads into one query (no stampede)', async () => {
      mockPoolQuery.mockImplementation(
        () =>
          new Promise((resolve) =>
            setTimeout(
              () => resolve({ rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }] }),
              20
            )
          )
      );
      const service = await loadSecretService();

      const results = await Promise.all([
        service.verifyAnonKey('anon_active'),
        service.verifyAnonKey('anon_active'),
        service.verifyAnonKey('anon_wrong'),
        service.verifyAnonKey('anon_active'),
      ]);

      expect(results).toEqual([true, true, false, true]);
      expect(mockPoolQuery).toHaveBeenCalledTimes(1);
    });

    it('serves repeated verifications from the in-memory cache', async () => {
      mockPoolQuery.mockResolvedValue({
        rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }],
      });
      const service = await loadSecretService();

      await service.verifyAnonKey('anon_active');
      await service.verifyAnonKey('anon_active');
      await service.verifyAnonKey('anon_active');

      expect(mockPoolQuery).toHaveBeenCalledTimes(1);
    });

    it('accepts a grace-period key until it expires, even when cached', async () => {
      const futureExpiry = new Date(Date.now() + 60 * 60 * 1000);
      const pastExpiry = new Date(Date.now() - 1000);
      mockPoolQuery.mockResolvedValue({
        rows: [
          { value_ciphertext: 'enc:anon_new', expires_at: null },
          { value_ciphertext: 'enc:anon_in_grace', expires_at: futureExpiry },
          { value_ciphertext: 'enc:anon_lapsed', expires_at: pastExpiry },
        ],
      });
      const service = await loadSecretService();

      await expect(service.verifyAnonKey('anon_new')).resolves.toBe(true);
      await expect(service.verifyAnonKey('anon_in_grace')).resolves.toBe(true);
      // Returned by the (mocked) query but already expired: must be skipped
      await expect(service.verifyAnonKey('anon_lapsed')).resolves.toBe(false);
    });

    it('refreshes the cache after invalidation', async () => {
      mockPoolQuery.mockResolvedValue({
        rows: [{ value_ciphertext: 'enc:anon_active', expires_at: null }],
      });
      const service = await loadSecretService();

      await service.verifyAnonKey('anon_active');
      service.invalidateAnonKeyCache();
      await service.verifyAnonKey('anon_active');

      expect(mockPoolQuery).toHaveBeenCalledTimes(2);
    });
  });

  describe('rotateAnonKey', () => {
    it('moves the old key to a grace-period entry and inserts a new active key', async () => {
      mockClientQuery.mockImplementation((sql: string) => {
        if (sql.includes('SELECT id FROM system.secrets')) {
          return Promise.resolve({ rows: [{ id: 'old-key-id' }] });
        }
        return Promise.resolve({ rows: [] });
      });
      const service = await loadSecretService();

      const result = await service.rotateAnonKey(48);

      expect(result.newAnonKey).toMatch(/^anon_[0-9a-f]{64}$/);
      const expectedExpiry = Date.now() + 48 * 60 * 60 * 1000;
      expect(Math.abs(result.oldKeyExpiresAt.getTime() - expectedExpiry)).toBeLessThan(5000);

      const calls = mockClientQuery.mock.calls.map(([sql]) => sql as string);
      expect(calls[0]).toBe('BEGIN');
      expect(calls.at(-1)).toBe('COMMIT');

      const renameCall = mockClientQuery.mock.calls.find(([sql]) =>
        (sql as string).includes('SET key = $1')
      );
      expect(renameCall?.[1]?.[0]).toMatch(/^ANON_KEY_OLD_\d+$/);
      expect(renameCall?.[1]?.[2]).toBe('old-key-id');

      const insertCall = mockClientQuery.mock.calls.find(([sql]) =>
        (sql as string).includes('INSERT INTO system.secrets')
      );
      expect(insertCall?.[0]).toContain("'ANON_KEY'");
      expect(insertCall?.[1]?.[0]).toBe(`enc:${result.newAnonKey}`);
    });

    it('rolls back when no active anon key exists', async () => {
      mockClientQuery.mockImplementation((sql: string) => {
        if (sql.includes('SELECT id FROM system.secrets')) {
          return Promise.resolve({ rows: [] });
        }
        return Promise.resolve({ rows: [] });
      });
      const service = await loadSecretService();

      await expect(service.rotateAnonKey()).rejects.toThrow('Failed to rotate anon key');

      const calls = mockClientQuery.mock.calls.map(([sql]) => sql as string);
      expect(calls).toContain('ROLLBACK');
      expect(calls).not.toContain('COMMIT');
      expect(mockClientRelease).toHaveBeenCalled();
    });

    it('invalidates the verification cache so the new key is picked up immediately', async () => {
      // Prime the cache with the old key
      mockPoolQuery.mockResolvedValue({
        rows: [{ value_ciphertext: 'enc:anon_old', expires_at: null }],
      });
      const service = await loadSecretService();
      await service.verifyAnonKey('anon_old');
      expect(mockPoolQuery).toHaveBeenCalledTimes(1);

      mockClientQuery.mockImplementation((sql: string) => {
        if (sql.includes('SELECT id FROM system.secrets')) {
          return Promise.resolve({ rows: [{ id: 'old-key-id' }] });
        }
        return Promise.resolve({ rows: [] });
      });
      const { newAnonKey } = await service.rotateAnonKey();

      mockPoolQuery.mockResolvedValue({
        rows: [{ value_ciphertext: `enc:${newAnonKey}`, expires_at: null }],
      });
      await expect(service.verifyAnonKey(newAnonKey)).resolves.toBe(true);
      expect(mockPoolQuery).toHaveBeenCalledTimes(2);
    });
  });

  describe('initializeAnonKey', () => {
    it('creates a fresh opaque key when none is stored', async () => {
      const service = await loadSecretService();
      const getSecretByKey = vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
      const createSecret = vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });

      const key = await service.initializeAnonKey();

      expect(key).toMatch(/^anon_[0-9a-f]{64}$/);
      expect(getSecretByKey).toHaveBeenCalledWith('ANON_KEY');
      expect(createSecret).toHaveBeenCalledWith({
        key: 'ANON_KEY',
        value: key,
        isReserved: true,
      });
    });

    it('migrates a legacy JWT-format anon key to an opaque key', async () => {
      const legacyJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYW5vbiJ9.signature';
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue(legacyJwt);
      const updateSecretByKey = vi.spyOn(service, 'updateSecretByKey').mockResolvedValue(true);
      const createSecret = vi.spyOn(service, 'createSecret');

      const key = await service.initializeAnonKey();

      expect(key).toMatch(/^anon_[0-9a-f]{64}$/);
      expect(updateSecretByKey).toHaveBeenCalledWith('ANON_KEY', {
        value: key,
        isReserved: true,
      });
      expect(createSecret).not.toHaveBeenCalled();
    });

    it('keeps an existing opaque key untouched', async () => {
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue('anon_existing');
      const createSecret = vi.spyOn(service, 'createSecret');
      const updateSecretByKey = vi.spyOn(service, 'updateSecretByKey');

      await expect(service.initializeAnonKey()).resolves.toBe('anon_existing');
      expect(createSecret).not.toHaveBeenCalled();
      expect(updateSecretByKey).not.toHaveBeenCalled();
    });

    it('seeds from the ACCESS_ANON_KEY environment variable when no key is stored', async () => {
      vi.stubEnv('ACCESS_ANON_KEY', 'anon_from_environment');
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
      const createSecret = vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });

      await expect(service.initializeAnonKey()).resolves.toBe('anon_from_environment');
      expect(createSecret).toHaveBeenCalledWith({
        key: 'ANON_KEY',
        value: 'anon_from_environment',
        isReserved: true,
      });
    });

    it('normalizes an env-provided key without the anon_ prefix', async () => {
      vi.stubEnv('ACCESS_ANON_KEY', 'bare-environment-key');
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
      vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });

      await expect(service.initializeAnonKey()).resolves.toBe('anon_bare-environment-key');
    });

    it('trims whitespace from the env-provided key before storing', async () => {
      vi.stubEnv('ACCESS_ANON_KEY', '  anon_padded_key  ');
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
      vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });

      await expect(service.initializeAnonKey()).resolves.toBe('anon_padded_key');
    });

    it('generates a key when the env value is only whitespace', async () => {
      vi.stubEnv('ACCESS_ANON_KEY', '   ');
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue(null);
      vi.spyOn(service, 'createSecret').mockResolvedValue({ id: 'new-id' });

      await expect(service.initializeAnonKey()).resolves.toMatch(/^anon_[0-9a-f]{64}$/);
    });

    it('prefers the stored key over the environment variable', async () => {
      vi.stubEnv('ACCESS_ANON_KEY', 'anon_from_environment');
      const service = await loadSecretService();
      vi.spyOn(service, 'getSecretByKey').mockResolvedValue('anon_stored');
      const createSecret = vi.spyOn(service, 'createSecret');

      await expect(service.initializeAnonKey()).resolves.toBe('anon_stored');
      expect(createSecret).not.toHaveBeenCalled();
    });
  });
});
