/**
 * Unit tests for src/services/OIDCKeyService.ts
 * Mocks pg Pool and node-vault; uses a real in-memory Redis stub.
 */

import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import { OIDCKeyService } from '../../../src/services/OIDCKeyService';
import { IOIDCKey, IJWKSKey } from '../../../src/types/oidc';

// ─── Mocks ────────────────────────────────────────────────────────────────────

jest.mock('pg', () => {
  const mQuery = jest.fn();
  const mPool = { query: mQuery };
  return { Pool: jest.fn(() => mPool) };
});

jest.mock('node-vault', () => {
  return jest.fn(() => ({
    write: jest.fn().mockResolvedValue({}),
    read: jest.fn().mockResolvedValue({
      data: { data: { privateKeyPem: 'mock-pem' } },
    }),
    delete: jest.fn().mockResolvedValue({}),
  }));
});

// ─── Helpers ─────────────────────────────────────────────────────────────────

function makeRedis(): RedisClientType {
  const store = new Map<string, string>();
  return {
    get: jest.fn(async (key: string) => store.get(key) ?? null),
    set: jest.fn(async (key: string, value: string, _opts?: unknown) => {
      store.set(key, value);
      return 'OK';
    }),
    del: jest.fn(async (key: string) => {
      store.delete(key);
      return 1;
    }),
  } as unknown as RedisClientType;
}

function makeSampleJwk(kid = 'key-test-001'): IJWKSKey {
  return { kid, kty: 'RSA', use: 'sig', alg: 'RS256', n: 'abc', e: 'AQAB' };
}

function makeSampleRow(overrides: Partial<IOIDCKey> = {}): IOIDCKey {
  return {
    id: 'uuid-1',
    kid: 'key-test-001',
    algorithm: 'RS256',
    public_key_jwk: makeSampleJwk(),
    vault_key_path: 'dev:no-vault',
    is_current: true,
    created_at: new Date('2026-01-01T00:00:00Z'),
    expires_at: new Date(Date.now() + 3600 * 1000),
    ...overrides,
  };
}

// ─── Tests ───────────────────────────────────────────────────────────────────

describe('OIDCKeyService', () => {
  let pool: Pool;
  let poolQuery: jest.Mock;
  let redis: RedisClientType;
  let service: OIDCKeyService;

  beforeEach(() => {
    jest.clearAllMocks();
    delete process.env['VAULT_ADDR'];
    delete process.env['VAULT_TOKEN'];
    delete process.env['VAULT_MOUNT'];
    delete process.env['OIDC_KEY_ALGORITHM'];
    delete process.env['OIDC_ID_TOKEN_TTL_SECONDS'];

    pool = new Pool();
    poolQuery = pool.query as jest.Mock;
    redis = makeRedis();
    service = new OIDCKeyService(pool, redis);
  });

  // ── ensureCurrentKey ──────────────────────────────────────────────────────

  describe('ensureCurrentKey()', () => {
    it('generates a key when no current key exists', async () => {
      // COUNT returns 0 → no current key
      poolQuery
        .mockResolvedValueOnce({ rows: [{ count: '0' }] }) // ensureCurrentKey COUNT
        .mockResolvedValueOnce({ rows: [] })               // UPDATE demote old key
        .mockResolvedValueOnce({                           // INSERT new key
          rows: [
            {
              id: 'uuid-1',
              kid: 'key-abc',
              algorithm: 'RS256',
              public_key_jwk: makeSampleJwk('key-abc'),
              vault_key_path: 'dev:no-vault',
              is_current: true,
              created_at: new Date(),
              expires_at: new Date(Date.now() + 3600 * 1000),
            },
          ],
        });

      await service.ensureCurrentKey();

      // Should have called INSERT (i.e. generateSigningKeyPair was invoked)
      expect(poolQuery).toHaveBeenCalledTimes(3);
    });

    it('is idempotent — does not generate when a current key already exists', async () => {
      // COUNT returns 1 → key exists
      poolQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });

      await service.ensureCurrentKey();
      // Only the COUNT query should have been executed
      expect(poolQuery).toHaveBeenCalledTimes(1);
    });

    it('called twice only generates one key', async () => {
      // First call: no key → generates
      poolQuery
        .mockResolvedValueOnce({ rows: [{ count: '0' }] })
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({
          rows: [
            {
              id: 'uuid-1',
              kid: 'key-abc',
              algorithm: 'RS256',
              public_key_jwk: makeSampleJwk('key-abc'),
              vault_key_path: 'dev:no-vault',
              is_current: true,
              created_at: new Date(),
              expires_at: new Date(Date.now() + 3600 * 1000),
            },
          ],
        });

      // Second call: key now exists → no generation
      poolQuery.mockResolvedValueOnce({ rows: [{ count: '1' }] });

      await service.ensureCurrentKey();
      await service.ensureCurrentKey();

      // First call: COUNT + UPDATE + INSERT = 3; second call: COUNT = 1 → total 4
      expect(poolQuery).toHaveBeenCalledTimes(4);
    });
  });

  // ── generateSigningKeyPair ────────────────────────────────────────────────

  describe('generateSigningKeyPair()', () => {
    it('generates an RSA key pair and inserts into the database', async () => {
      const row = {
        id: 'uuid-2',
        kid: 'key-test-002',
        algorithm: 'RS256',
        public_key_jwk: makeSampleJwk('key-test-002'),
        vault_key_path: 'dev:no-vault',
        is_current: true,
        created_at: new Date(),
        expires_at: new Date(Date.now() + 3600 * 1000),
      };

      poolQuery
        .mockResolvedValueOnce({ rows: [] }) // UPDATE demote
        .mockResolvedValueOnce({ rows: [row] }); // INSERT

      const result = await service.generateSigningKeyPair();

      expect(result.algorithm).toBe('RS256');
      expect(result.is_current).toBe(true);
      expect(result.vault_key_path).toBe('dev:no-vault');
      expect(result.public_key_jwk.use).toBe('sig');
    });

    it('throws when OIDC_KEY_ALGORITHM is unsupported', async () => {
      process.env['OIDC_KEY_ALGORITHM'] = 'RS512';
      await expect(service.generateSigningKeyPair()).rejects.toThrow(
        'Unsupported OIDC_KEY_ALGORITHM',
      );
    });

    it('generates an EC P-256 key pair when OIDC_KEY_ALGORITHM=ES256', async () => {
      process.env['OIDC_KEY_ALGORITHM'] = 'ES256';

      const row = {
        id: 'uuid-3',
        kid: 'key-test-003',
        algorithm: 'ES256',
        public_key_jwk: { kid: 'key-test-003', kty: 'EC', use: 'sig', alg: 'ES256', crv: 'P-256', x: 'x', y: 'y' },
        vault_key_path: 'dev:no-vault',
        is_current: true,
        created_at: new Date(),
        expires_at: new Date(Date.now() + 3600 * 1000),
      };

      poolQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [row] });

      const result = await service.generateSigningKeyPair();
      expect(result.algorithm).toBe('ES256');
    });

    it('stores private key as dev:no-vault in dev mode (no Vault env vars)', async () => {
      const row = {
        id: 'uuid-4',
        kid: 'key-test-004',
        algorithm: 'RS256',
        public_key_jwk: makeSampleJwk('key-test-004'),
        vault_key_path: 'dev:no-vault',
        is_current: true,
        created_at: new Date(),
        expires_at: new Date(Date.now() + 3600 * 1000),
      };

      poolQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [row] });

      const result = await service.generateSigningKeyPair();
      expect(result.vault_key_path).toBe('dev:no-vault');
    });

    it('invalidates JWKS Redis cache after generating a new key', async () => {
      const row = {
        id: 'uuid-5',
        kid: 'key-test-005',
        algorithm: 'RS256',
        public_key_jwk: makeSampleJwk('key-test-005'),
        vault_key_path: 'dev:no-vault',
        is_current: true,
        created_at: new Date(),
        expires_at: new Date(Date.now() + 3600 * 1000),
      };

      poolQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [row] });

      await service.generateSigningKeyPair();
      expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
    });
  });

  // ── getCurrentKey ────────────────────────────────────────────────────────

  describe('getCurrentKey()', () => {
    it('returns the current key from the database', async () => {
      const row = makeSampleRow();
      poolQuery.mockResolvedValueOnce({ rows: [row] });

      const key = await service.getCurrentKey();
      expect(key.is_current).toBe(true);
      expect(key.kid).toBe('key-test-001');
    });

    it('throws OIDCKeyNotFoundError when no current key exists', async () => {
      poolQuery.mockResolvedValueOnce({ rows: [] });

      await expect(service.getCurrentKey()).rejects.toMatchObject({
        code: 'OIDC_KEY_NOT_FOUND',
        httpStatus: 500,
      });
    });
  });

  // ── rotateKey ────────────────────────────────────────────────────────────

  describe('rotateKey()', () => {
    it('promotes a new key and the old key remains queryable', async () => {
      const oldRow = makeSampleRow({ kid: 'key-old', is_current: false });
      const newRow = makeSampleRow({ kid: 'key-new', is_current: true });

      // generateSigningKeyPair: UPDATE + INSERT
      poolQuery
        .mockResolvedValueOnce({ rows: [] }) // UPDATE demote
        .mockResolvedValueOnce({ rows: [newRow] }); // INSERT new

      const result = await service.rotateKey();
      expect(result.kid).toBe('key-new');
      expect(result.is_current).toBe(true);

      // Simulate getPublicJWKS returning both (old still in expires_at window)
      poolQuery.mockResolvedValueOnce({
        rows: [oldRow, newRow],
      });

      const jwks = await service.getPublicJWKS();
      expect(jwks.keys).toHaveLength(2);
    });

    it('invalidates JWKS cache on rotation', async () => {
      const newRow = makeSampleRow({ kid: 'key-new', is_current: true });
      poolQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [newRow] });

      await service.rotateKey();
      expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
    });
  });

  // ── getPublicJWKS ────────────────────────────────────────────────────────

  describe('getPublicJWKS()', () => {
    it('returns only non-expired keys', async () => {
      const nonExpiredRow = makeSampleRow();
      poolQuery.mockResolvedValueOnce({ rows: [nonExpiredRow] });

      const jwks = await service.getPublicJWKS();
      expect(jwks.keys).toHaveLength(1);
      expect(jwks.keys[0].kid).toBe('key-test-001');
    });

    it('returns cached JWKS when available', async () => {
      const cachedJwks = JSON.stringify({ keys: [makeSampleJwk('cached-key')] });
      (redis.get as jest.Mock).mockResolvedValueOnce(cachedJwks);

      const jwks = await service.getPublicJWKS();
      expect(jwks.keys[0].kid).toBe('cached-key');
      // Pool should NOT have been queried
      expect(poolQuery).not.toHaveBeenCalled();
    });

    it('caches the JWKS result in Redis', async () => {
      const row = makeSampleRow();
      poolQuery.mockResolvedValueOnce({ rows: [row] });

      await service.getPublicJWKS();
      expect(redis.set).toHaveBeenCalledWith(
        'oidc:jwks',
        expect.any(String),
        expect.objectContaining({ EX: expect.any(Number) }),
      );
    });
  });

  // ── pruneExpiredKeys ─────────────────────────────────────────────────────

  describe('pruneExpiredKeys()', () => {
    it('deletes past-expires_at keys from the database', async () => {
      const expiredRow = makeSampleRow({
        kid: 'key-expired',
        vault_key_path: 'dev:no-vault',
        expires_at: new Date(Date.now() - 1000),
      });

      poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });

      await service.pruneExpiredKeys();
      // DELETE should have been called as the first (and only) DB call
      expect(poolQuery).toHaveBeenCalledTimes(1);
      expect(poolQuery).toHaveBeenCalledWith(
        expect.stringContaining('DELETE FROM oidc_keys'),
      );
    });

    it('invalidates JWKS cache when keys are pruned', async () => {
      const expiredRow = makeSampleRow({
        kid: 'key-expired',
        vault_key_path: 'dev:no-vault',
        expires_at: new Date(Date.now() - 1000),
      });
      poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });

      await service.pruneExpiredKeys();
      expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
    });

    it('does not invalidate cache when no keys are pruned', async () => {
      poolQuery.mockResolvedValueOnce({ rows: [] });
      await service.pruneExpiredKeys();
      expect(redis.del).not.toHaveBeenCalled();
    });
  });

  // ── getPrivateKeyPem ─────────────────────────────────────────────────────

  describe('getPrivateKeyPem()', () => {
    it('returns private key from dev in-memory store for dev:no-vault path', async () => {
      // generateSigningKeyPair runs real crypto; the real kid is dynamic — capture it
      // from the INSERT call args by spying on poolQuery.
      let capturedKid: string | undefined;

      poolQuery
        .mockResolvedValueOnce({ rows: [] }) // UPDATE demote
        .mockImplementationOnce((_sql: string, params: unknown[]) => {
          // INSERT — capture the kid from the query params
          capturedKid = params[0] as string;
          return Promise.resolve({
            rows: [
              {
                id: 'uuid-dev',
                kid: capturedKid,
                algorithm: 'RS256',
                public_key_jwk: makeSampleJwk(capturedKid),
                vault_key_path: 'dev:no-vault',
                is_current: true,
                created_at: new Date(),
                expires_at: new Date(Date.now() + 3600 * 1000),
              },
            ],
          });
        });

      await service.generateSigningKeyPair();

      expect(capturedKid).toBeDefined();
      const pem = await service.getPrivateKeyPem(capturedKid!, 'dev:no-vault');
      expect(pem).toBeDefined();
      expect(typeof pem).toBe('string');
      expect(pem.length).toBeGreaterThan(0);
    });

    it('throws when dev key is not found in memory', async () => {
      await expect(
        service.getPrivateKeyPem('nonexistent-key-xyz-999', 'dev:no-vault'),
      ).rejects.toThrow('not found in memory');
    });

    it('reads private key from Vault when VAULT_ADDR and VAULT_TOKEN are set', async () => {
      process.env['VAULT_ADDR'] = 'http://vault:8200';
      process.env['VAULT_TOKEN'] = 'test-token';

      const pem = await service.getPrivateKeyPem('key-vault-001', 'secret/data/agentidp/oidc/keys/key-vault-001');
      expect(pem).toBe('mock-pem');
    });

    it('throws when VAULT_ADDR and VAULT_TOKEN are missing for a non-dev path', async () => {
      // No VAULT_ADDR/VAULT_TOKEN set (cleared in beforeEach)
      await expect(
        service.getPrivateKeyPem('key-vault-001', 'secret/data/agentidp/oidc/keys/key-vault-001'),
      ).rejects.toThrow('VAULT_ADDR and VAULT_TOKEN are required');
    });
  });

  // ── Vault path — storePrivateKey ─────────────────────────────────────────

  describe('generateSigningKeyPair() — Vault mode', () => {
    it('stores private key in Vault when VAULT_ADDR and VAULT_TOKEN are set', async () => {
      process.env['VAULT_ADDR'] = 'http://vault:8200';
      process.env['VAULT_TOKEN'] = 'test-token';

      const vaultPath = 'secret/data/agentidp/oidc/keys/key-vault-002';
      const row = {
        id: 'uuid-vault',
        kid: 'key-vault-002',
        algorithm: 'RS256',
        public_key_jwk: makeSampleJwk('key-vault-002'),
        vault_key_path: vaultPath,
        is_current: true,
        created_at: new Date(),
        expires_at: new Date(Date.now() + 3600 * 1000),
      };

      poolQuery
        .mockResolvedValueOnce({ rows: [] }) // UPDATE demote
        .mockResolvedValueOnce({ rows: [row] }); // INSERT

      const result = await service.generateSigningKeyPair();
      // vault_key_path returned by DB mock confirms Vault path was used
      expect(result.vault_key_path).toBe(vaultPath);
    });

    it('uses custom VAULT_MOUNT when set', async () => {
      process.env['VAULT_ADDR'] = 'http://vault:8200';
      process.env['VAULT_TOKEN'] = 'test-token';
      process.env['VAULT_MOUNT'] = 'kv';

      const vaultPath = 'kv/data/agentidp/oidc/keys/key-vault-003';
      const row = {
        id: 'uuid-vault-2',
        kid: 'key-vault-003',
        algorithm: 'RS256',
        public_key_jwk: makeSampleJwk('key-vault-003'),
        vault_key_path: vaultPath,
        is_current: true,
        created_at: new Date(),
        expires_at: new Date(Date.now() + 3600 * 1000),
      };

      poolQuery
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [row] });

      const result = await service.generateSigningKeyPair();
      expect(result.vault_key_path).toBe(vaultPath);
    });
  });

  // ── pruneExpiredKeys — Vault path ────────────────────────────────────────

  describe('pruneExpiredKeys() — Vault mode', () => {
    it('deletes Vault key when VAULT_ADDR and VAULT_TOKEN are set', async () => {
      process.env['VAULT_ADDR'] = 'http://vault:8200';
      process.env['VAULT_TOKEN'] = 'test-token';

      const expiredRow = makeSampleRow({
        kid: 'key-vault-expired',
        vault_key_path: 'secret/data/agentidp/oidc/keys/key-vault-expired',
        expires_at: new Date(Date.now() - 1000),
      });

      poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });

      await service.pruneExpiredKeys();
      expect(poolQuery).toHaveBeenCalledTimes(1);
      expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
    });

    it('handles Vault delete errors gracefully (best-effort)', async () => {
      process.env['VAULT_ADDR'] = 'http://vault:8200';
      process.env['VAULT_TOKEN'] = 'test-token';

      // Make vault.delete throw
      const nodeVaultMock = jest.requireMock('node-vault') as jest.Mock;
      nodeVaultMock.mockReturnValueOnce({
        write: jest.fn().mockResolvedValue({}),
        read: jest.fn().mockResolvedValue({ data: { data: { privateKeyPem: 'mock-pem' } } }),
        delete: jest.fn().mockRejectedValue(new Error('Vault unreachable')),
      });

      const expiredRow = makeSampleRow({
        kid: 'key-vault-fail',
        vault_key_path: 'secret/data/agentidp/oidc/keys/key-vault-fail',
        expires_at: new Date(Date.now() - 1000),
      });

      poolQuery.mockResolvedValueOnce({ rows: [expiredRow] });

      // Should not throw — vault delete failure is best-effort
      await expect(service.pruneExpiredKeys()).resolves.not.toThrow();
      expect(redis.del).toHaveBeenCalledWith('oidc:jwks');
    });
  });
});
