import { Test } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import { REDIS_CLIENT } from '../redis/redis.token';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
import { AuditStatsReader } from './audit-stats.service';

interface MockTx {
  $executeRawUnsafe: jest.Mock;
  $queryRawUnsafe: jest.Mock;
}

interface MockPrisma {
  $transaction: jest.Mock;
  tx: MockTx;
}

interface MockRedis {
  get: jest.Mock;
  set: jest.Mock;
}

function buildPrisma(opts?: {
  dailyRows?: Array<{ day: Date; count: bigint }>;
  outcomeRows?: Array<{ outcome: string; count: bigint }>;
  eventTypeRows?: Array<{ day: Date; event_type: string; count: bigint }>;
}): MockPrisma {
  const tx: MockTx = {
    $executeRawUnsafe: jest.fn().mockResolvedValue(0),
    $queryRawUnsafe: jest
      .fn()
      // Order matches the service: dailyVolume → outcomeBreakdown → eventTypeByDay.
      .mockResolvedValueOnce(opts?.dailyRows ?? [])
      .mockResolvedValueOnce(opts?.outcomeRows ?? [])
      .mockResolvedValueOnce(opts?.eventTypeRows ?? []),
  };
  return {
    tx,
    $transaction: jest.fn(async (fn: (tx: MockTx) => Promise<unknown>) => fn(tx)),
  };
}

function buildRedis(cached?: string): MockRedis {
  return {
    get: jest.fn().mockResolvedValue(cached ?? null),
    set: jest.fn().mockResolvedValue('OK'),
  };
}

async function createSubject(prisma: MockPrisma, redis: MockRedis): Promise<AuditStatsReader> {
  const moduleRef = await Test.createTestingModule({
    providers: [
      AuditStatsReader,
      { provide: PrismaService, useValue: prisma },
      { provide: REDIS_CLIENT, useValue: redis },
    ],
  }).compile();
  return moduleRef.get(AuditStatsReader);
}

describe('AuditStatsReader', () => {
  it('returns the cached payload when Redis has a hit (no DB call)', async () => {
    const cached = JSON.stringify({
      dailyVolume: [{ day: '2026-05-13', count: 12 }],
      outcomeBreakdown: [{ outcome: 'success', count: 12 }],
      eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 12 }],
      total: 12,
    });
    const prisma = buildPrisma();
    const redis = buildRedis(cached);
    const reader = await createSubject(prisma, redis);

    const result = await reader.getStats({});

    expect(result.total).toBe(12);
    expect(prisma.$transaction).not.toHaveBeenCalled();
    expect(prisma.tx.$queryRawUnsafe).not.toHaveBeenCalled();
    expect(redis.set).not.toHaveBeenCalled();
  });

  it('locks the transaction to audit_reader before running GROUP BY queries', async () => {
    const prisma = buildPrisma();
    const redis = buildRedis();
    const reader = await createSubject(prisma, redis);

    await reader.getStats({});

    const setRoleCalls = prisma.tx.$executeRawUnsafe.mock.calls;
    expect(setRoleCalls[0]?.[0]).toBe('SET LOCAL ROLE audit_reader');
  });

  it('issues exactly three GROUP BY queries (daily / outcome / by-event-type)', async () => {
    const prisma = buildPrisma();
    const redis = buildRedis();
    const reader = await createSubject(prisma, redis);

    await reader.getStats({});

    const queries = prisma.tx.$queryRawUnsafe.mock.calls;
    expect(queries).toHaveLength(3);
    expect(queries[0]?.[0]).toMatch(
      /date_trunc\('day', created_at\)::date AS day[\s\S]*GROUP BY day/,
    );
    expect(queries[1]?.[0]).toMatch(/outcome::text AS outcome[\s\S]*GROUP BY outcome/);
    expect(queries[2]?.[0]).toMatch(/GROUP BY day, event_type/);
  });

  it('projects rows into the SPA-facing shape (ISO day, numeric count, computed total)', async () => {
    const prisma = buildPrisma({
      dailyRows: [
        { day: new Date('2026-05-13T00:00:00.000Z'), count: 8n },
        { day: new Date('2026-05-14T00:00:00.000Z'), count: 12n },
      ],
      outcomeRows: [
        { outcome: 'success', count: 18n },
        { outcome: 'failure', count: 2n },
      ],
      eventTypeRows: [
        { day: new Date('2026-05-13T00:00:00.000Z'), event_type: 'auth.sign_in', count: 8n },
      ],
    });
    const redis = buildRedis();
    const reader = await createSubject(prisma, redis);

    const result = await reader.getStats({});

    expect(result.dailyVolume).toEqual([
      { day: '2026-05-13', count: 8 },
      { day: '2026-05-14', count: 12 },
    ]);
    expect(result.outcomeBreakdown).toEqual([
      { outcome: 'success', count: 18 },
      { outcome: 'failure', count: 2 },
    ]);
    expect(result.eventTypeByDay).toEqual([
      { day: '2026-05-13', eventType: 'auth.sign_in', count: 8 },
    ]);
    expect(result.total).toBe(20);
  });

  it('writes the result to Redis with the 5-minute TTL on cache miss', async () => {
    const prisma = buildPrisma();
    const redis = buildRedis();
    const reader = await createSubject(prisma, redis);

    await reader.getStats({ outcome: 'denied' });

    expect(redis.set).toHaveBeenCalledTimes(1);
    const [key, payload, mode, ttl] = redis.set.mock.calls[0] as [string, string, string, number];
    expect(key).toMatch(/^audit:stats:[0-9a-f]{16}$/);
    expect(JSON.parse(payload)).toMatchObject({ total: 0 });
    expect(mode).toBe('EX');
    expect(ttl).toBe(300);
  });

  it('uses the same cache key for identical filter shapes regardless of key order', async () => {
    const prisma1 = buildPrisma();
    const redis1 = buildRedis();
    const reader1 = await createSubject(prisma1, redis1);
    const prisma2 = buildPrisma();
    const redis2 = buildRedis();
    const reader2 = await createSubject(prisma2, redis2);

    const a: AdminAuditStatsQueryDto = { eventType: 'auth.sign_in', outcome: 'success' };
    const b: AdminAuditStatsQueryDto = { outcome: 'success', eventType: 'auth.sign_in' };

    await reader1.getStats(a);
    await reader2.getStats(b);

    const key1 = (redis1.get.mock.calls[0] as [string])[0];
    const key2 = (redis2.get.mock.calls[0] as [string])[0];
    expect(key1).toBe(key2);
  });

  it('passes filter values as positional parameters (no string concatenation)', async () => {
    const prisma = buildPrisma();
    const redis = buildRedis();
    const reader = await createSubject(prisma, redis);

    await reader.getStats({ eventType: 'auth.sign_in', outcome: 'denied' });

    // All three queries should carry the same `params` tail —
    // event_type then outcome cast to enum. Verify on the first one.
    const [, ...params] = prisma.tx.$queryRawUnsafe.mock.calls[0] as [string, ...unknown[]];
    expect(params).toEqual(['auth.sign_in', 'denied']);
  });

  it('survives a Redis-write failure on cache miss (best-effort cache, response still flows)', async () => {
    const prisma = buildPrisma({
      dailyRows: [{ day: new Date('2026-05-13T00:00:00.000Z'), count: 5n }],
    });
    const redis = buildRedis();
    redis.set.mockRejectedValueOnce(new Error('redis unavailable'));
    const reader = await createSubject(prisma, redis);

    const result = await reader.getStats({});

    expect(result.total).toBe(5);
  });
});
