/**
 * Unit tests for src/repositories/TokenRepository.ts
 * Uses mocked pg.Pool and Redis client — no real infrastructure.
 */

import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import { TokenRepository } from '../../../src/repositories/TokenRepository';

jest.mock('pg', () => ({
  Pool: jest.fn().mockImplementation(() => ({
    query: jest.fn(),
    connect: jest.fn(),
  })),
}));

// ─── helpers ─────────────────────────────────────────────────────────────────

function buildMockRedis(): jest.Mocked<Pick<RedisClientType, 'get' | 'set' | 'incr' | 'expire'>> {
  return {
    get: jest.fn(),
    set: jest.fn(),
    incr: jest.fn(),
    expire: jest.fn(),
  };
}

// ─── suite ───────────────────────────────────────────────────────────────────

describe('TokenRepository', () => {
  let pool: jest.Mocked<Pool>;
  let redis: ReturnType<typeof buildMockRedis>;
  let repo: TokenRepository;

  beforeEach(() => {
    jest.clearAllMocks();
    pool = new Pool() as jest.Mocked<Pool>;
    redis = buildMockRedis();
    repo = new TokenRepository(pool, redis as unknown as RedisClientType);
  });

  // ── addToRevocationList ───────────────────────────────────────────────────────

  describe('addToRevocationList()', () => {
    it('should write to Redis with correct key and TTL, then insert to DB', async () => {
      redis.set.mockResolvedValue('OK');
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });

      const jti = 'test-jti-001';
      const expiresAt = new Date(Date.now() + 3600_000); // 1 hour from now
      await repo.addToRevocationList(jti, expiresAt);

      // Redis set call
      expect(redis.set).toHaveBeenCalledTimes(1);
      const [redisKey, value, options] = redis.set.mock.calls[0] as [string, string, { EX: number }];
      expect(redisKey).toBe(`revoked:${jti}`);
      expect(value).toBe('1');
      expect(options.EX).toBeGreaterThan(0);

      // DB insert call
      expect(pool.query).toHaveBeenCalledTimes(1);
      const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
      expect(sql).toContain('INSERT INTO token_revocations');
      expect(params).toContain(jti);
      expect(params).toContain(expiresAt);
    });

    it('should use a minimum TTL of 1 second for already-expired tokens', async () => {
      redis.set.mockResolvedValue('OK');
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });

      const jti = 'expired-jti';
      const expiresAt = new Date(Date.now() - 5000); // already expired
      await repo.addToRevocationList(jti, expiresAt);

      const [, , options] = redis.set.mock.calls[0] as [string, string, { EX: number }];
      expect(options.EX).toBe(1);
    });
  });

  // ── isRevoked ─────────────────────────────────────────────────────────────────

  describe('isRevoked()', () => {
    it('should return true immediately when Redis has the key', async () => {
      redis.get.mockResolvedValue('1');

      const result = await repo.isRevoked('revoked-jti');

      expect(result).toBe(true);
      // DB should NOT be queried
      expect(pool.query).not.toHaveBeenCalled();
    });

    it('should fall back to DB and return true when found there', async () => {
      redis.get.mockResolvedValue(null);
      (pool.query as jest.Mock).mockResolvedValueOnce({
        rows: [{ jti: 'db-revoked-jti', expires_at: new Date(), revoked_at: new Date() }],
        rowCount: 1,
      });

      const result = await repo.isRevoked('db-revoked-jti');

      expect(redis.get).toHaveBeenCalledTimes(1);
      expect(pool.query).toHaveBeenCalledTimes(1);
      expect(result).toBe(true);
    });

    it('should return false when neither Redis nor DB has the key', async () => {
      redis.get.mockResolvedValue(null);
      (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });

      const result = await repo.isRevoked('valid-jti');

      expect(result).toBe(false);
    });
  });

  // ── incrementMonthlyCount ─────────────────────────────────────────────────────

  describe('incrementMonthlyCount()', () => {
    it('should increment the Redis key and return the new count', async () => {
      redis.incr.mockResolvedValue(5);
      redis.expire.mockResolvedValue(true);

      const count = await repo.incrementMonthlyCount('client-001');

      expect(redis.incr).toHaveBeenCalledTimes(1);
      const [key] = redis.incr.mock.calls[0] as [string];
      expect(key).toMatch(/^monthly:tokens:client-001:/);
      expect(count).toBe(5);
    });

    it('should set TTL when count becomes 1 (first token of the month)', async () => {
      redis.incr.mockResolvedValue(1);
      redis.expire.mockResolvedValue(true);

      await repo.incrementMonthlyCount('client-new');

      expect(redis.expire).toHaveBeenCalledTimes(1);
      const [, ttl] = redis.expire.mock.calls[0] as [string, number];
      expect(ttl).toBeGreaterThan(0);
    });

    it('should NOT set TTL when count is greater than 1', async () => {
      redis.incr.mockResolvedValue(10);

      await repo.incrementMonthlyCount('client-existing');

      expect(redis.expire).not.toHaveBeenCalled();
    });
  });

  // ── getMonthlyCount ───────────────────────────────────────────────────────────

  describe('getMonthlyCount()', () => {
    it('should return the count from Redis', async () => {
      redis.get.mockResolvedValue('42');

      const count = await repo.getMonthlyCount('client-001');

      expect(redis.get).toHaveBeenCalledTimes(1);
      const [key] = redis.get.mock.calls[0] as [string];
      expect(key).toMatch(/^monthly:tokens:client-001:/);
      expect(count).toBe(42);
    });

    it('should return 0 when the Redis key does not exist', async () => {
      redis.get.mockResolvedValue(null);

      const count = await repo.getMonthlyCount('client-no-tokens');

      expect(count).toBe(0);
    });
  });
});
