import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import {
  ServiceCredentialValidatorService,
  ValidateContext,
} from './service-credential-validator.service';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('ServiceCredentialValidatorService', () => {
  let service: ServiceCredentialValidatorService;
  let post: jest.Mock;

  const config: Record<string, unknown> = {
    LARAVEL_URL: 'http://laravel.test',
    SERVICE_CREDENTIALS_INTERNAL_SECRET: 'internal-secret-xyz',
    SERVICE_CREDENTIALS_ENVIRONMENT: 'production',
    SERVICE_CRED_CACHE_TTL_MS: 30_000,
  };

  const mockConfigService = {
    get: jest.fn((key: string) => config[key]),
  };

  const baseCtx: ValidateContext = {
    presentedKey: 'pf_sk_live_a1b2c3d4.SECRETSECRETSECRET',
    route: '/audit-events',
    method: 'POST',
    requiredScope: 'bff:audit-events:write',
    ip: '10.0.0.1',
    origin: 'https://atlas.planfi.com.br',
    correlationId: 'corr-1',
  };

  beforeEach(async () => {
    post = jest.fn();
    // axios.create returns an instance whose .post we control
    mockedAxios.create.mockReturnValue({
      post,
      interceptors: {
        request: { use: jest.fn() },
        response: { use: jest.fn() },
      },
    } as never);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ServiceCredentialValidatorService,
        { provide: ConfigService, useValue: mockConfigService },
      ],
    }).compile();

    service = module.get(ServiceCredentialValidatorService);
  });

  afterEach(() => jest.clearAllMocks());

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('returns valid:true and the credential for a 200 valid response', async () => {
    post.mockResolvedValue({
      status: 200,
      data: {
        valid: true,
        credential: {
          prefix: 'pf_sk_live_a1b2c3d4',
          owner_type: 'bff',
          type: 'service_key',
        },
      },
    });

    const res = await service.validate(baseCtx);

    expect(res.valid).toBe(true);
    expect(res.credential?.owner_type).toBe('bff');
    // Sends system='bff', route, method, and the internal auth header.
    expect(post).toHaveBeenCalledWith(
      '/v1/internal/service-credentials/validate',
      expect.objectContaining({
        presented_key: baseCtx.presentedKey,
        environment: 'production',
        system: 'bff',
        route: '/audit-events',
        method: 'POST',
        required_scope: 'bff:audit-events:write',
      }),
      expect.objectContaining({
        headers: expect.objectContaining({
          'X-Internal-Auth': 'internal-secret-xyz',
        }),
      }),
    );
  });

  it('returns valid:false with reason route_denied for a 200 invalid response', async () => {
    post.mockResolvedValue({
      status: 200,
      data: { valid: false, reason: 'route_denied' },
    });

    const res = await service.validate({ ...baseCtx, route: '/usuarios' });

    expect(res.valid).toBe(false);
    expect(res.reason).toBe('route_denied');
  });

  it('returns valid:false with reason revoked for a 200 invalid response', async () => {
    post.mockResolvedValue({
      status: 200,
      data: { valid: false, reason: 'revoked' },
    });

    const res = await service.validate(baseCtx);

    expect(res.valid).toBe(false);
    expect(res.reason).toBe('revoked');
  });

  it('fails closed (valid:false, validator_unreachable) on a network error', async () => {
    post.mockRejectedValue(new Error('ECONNREFUSED'));

    const res = await service.validate(baseCtx);

    expect(res.valid).toBe(false);
    expect(res.reason).toBe('validator_unreachable');
  });

  it('fails closed on a non-200 status (e.g. 500 from Laravel)', async () => {
    post.mockResolvedValue({ status: 500, data: { message: 'boom' } });

    const res = await service.validate(baseCtx);

    expect(res.valid).toBe(false);
    expect(res.reason).toBe('validator_unreachable');
  });

  it('caches a positive result: a second identical call does NOT hit the validator', async () => {
    post.mockResolvedValue({
      status: 200,
      data: {
        valid: true,
        credential: { prefix: 'pf_sk_live_a1b2c3d4', owner_type: 'bff' },
      },
    });

    await service.validate(baseCtx);
    await service.validate(baseCtx);

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

  it('does not collide cache entries across different route/method', async () => {
    post.mockResolvedValue({
      status: 200,
      data: {
        valid: true,
        credential: { prefix: 'pf_sk_live_a1b2c3d4', owner_type: 'bff' },
      },
    });

    await service.validate(baseCtx);
    await service.validate({ ...baseCtx, method: 'DELETE' });

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

  it('never logs the raw presented key', async () => {
    const errSpy = jest
      .spyOn(
        (service as unknown as { logger: { error: (m: string) => void } })
          .logger,
        'error',
      )
      .mockImplementation(() => undefined);
    post.mockRejectedValue(new Error('ECONNREFUSED'));

    await service.validate(baseCtx);

    for (const call of errSpy.mock.calls) {
      expect(String(call[0])).not.toContain('SECRETSECRETSECRET');
    }
  });

  it('uses SERVICE_CRED_CACHE_TTL_MS as the positive-cache window', async () => {
    jest.useFakeTimers().setSystemTime(0);
    // 50ms TTL for the test
    (mockConfigService.get as jest.Mock).mockImplementation((k: string) =>
      k === 'SERVICE_CRED_CACHE_TTL_MS' ? 50 : config[k],
    );
    post.mockResolvedValue({
      status: 200,
      data: {
        valid: true,
        credential: { prefix: 'pf_sk_live_a1b2c3d4', owner_type: 'bff' },
      },
    });

    await service.validate(baseCtx);
    jest.setSystemTime(40);
    await service.validate(baseCtx); // still inside the 50ms window → cached
    expect(post).toHaveBeenCalledTimes(1);

    jest.setSystemTime(60);
    await service.validate(baseCtx); // window expired → re-validates
    expect(post).toHaveBeenCalledTimes(2);

    jest.useRealTimers();
  });

  it('evict(prefix) drops the cached positive entry so the next call re-validates', async () => {
    post.mockResolvedValue({
      status: 200,
      data: {
        valid: true,
        credential: { prefix: 'pf_sk_live_a1b2c3d4', owner_type: 'bff' },
      },
    });

    await service.validate(baseCtx);
    expect(post).toHaveBeenCalledTimes(1);

    const removed = (
      service as unknown as { evict: (p: string) => number }
    ).evict('pf_sk_live_a1b2c3d4');
    expect(removed).toBeGreaterThanOrEqual(1);

    await service.validate(baseCtx); // cache was evicted → hits the validator again
    expect(post).toHaveBeenCalledTimes(2);
  });

  it('evict(prefix) returns 0 when nothing matches', () => {
    const removed = (
      service as unknown as { evict: (p: string) => number }
    ).evict('pf_sk_live_nomatch0');
    expect(removed).toBe(0);
  });

  it('caps the cache size (no unbounded growth from random keys)', () => {
    const svc = service as any;
    for (let i = 0; i < 6000; i++) {
      svc.toCache(`key-${i}`, { valid: false, reason: 'not_found' }, null);
    }
    expect(svc.cache.size).toBeLessThanOrEqual(5000);
  });
});
