import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import { Request } from 'express';
import { AutomationRateLimitGuard } from './automation-rate-limit.guard';

type Bucket = { count: number; resetAt: number };

type GuardInternals = {
  buckets: Map<string, Bucket>;
  windowMs: number;
  maxRequests: number;
  maxKeys: number;
};

function internals(guard: AutomationRateLimitGuard): GuardInternals {
  return guard as unknown as GuardInternals;
}

function buildContext(request: Partial<Request>): ExecutionContext {
  return {
    switchToHttp: () => ({
      getRequest: () => request as Request,
    }),
  } as unknown as ExecutionContext;
}

function makeRequest(
  ip: string | undefined,
  forwardedFor?: string,
  remoteAddress?: string,
): Partial<Request> {
  return {
    ip,
    headers: forwardedFor ? { 'x-forwarded-for': forwardedFor } : {},
    socket: { remoteAddress } as Request['socket'],
  };
}

describe('AutomationRateLimitGuard', () => {
  let guard: AutomationRateLimitGuard;

  beforeEach(() => {
    guard = new AutomationRateLimitGuard();
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

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

  it('ignores client-supplied X-Forwarded-For and keys by req.ip (trust proxy resolves it)', () => {
    const maxRequests = internals(guard).maxRequests;

    // Both requests share the SAME real client ip but rotate x-forwarded-for.
    // If the guard keyed by the header, each call would land in a fresh bucket
    // and never trip the limit. Keying by req.ip means they all share one bucket.
    for (let i = 0; i < maxRequests; i += 1) {
      const ctx = buildContext(
        makeRequest('203.0.113.7', `1.2.3.${i}, 10.0.0.1`),
      );
      expect(guard.canActivate(ctx)).toBe(true);
    }

    const overLimitCtx = buildContext(
      makeRequest('203.0.113.7', '9.9.9.9, 10.0.0.1'),
    );
    expect(() => guard.canActivate(overLimitCtx)).toThrow(HttpException);
    try {
      guard.canActivate(overLimitCtx);
    } catch (error) {
      expect((error as HttpException).getStatus()).toBe(
        HttpStatus.TOO_MANY_REQUESTS,
      );
    }
  });

  it('gives different real IPs independent buckets', () => {
    const maxRequests = internals(guard).maxRequests;

    for (let i = 0; i < maxRequests; i += 1) {
      expect(guard.canActivate(buildContext(makeRequest('198.51.100.1')))).toBe(
        true,
      );
      expect(guard.canActivate(buildContext(makeRequest('198.51.100.2')))).toBe(
        true,
      );
    }

    // Neither key tripped — they did not share a bucket.
    expect(internals(guard).buckets.size).toBe(2);
  });

  it('resets the window once resetAt has passed', () => {
    const maxRequests = internals(guard).maxRequests;
    const windowMs = internals(guard).windowMs;
    const base = 1_000_000;
    const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(base);

    for (let i = 0; i < maxRequests; i += 1) {
      expect(guard.canActivate(buildContext(makeRequest('192.0.2.50')))).toBe(
        true,
      );
    }
    // One more in the same window is over the limit.
    expect(() =>
      guard.canActivate(buildContext(makeRequest('192.0.2.50'))),
    ).toThrow(HttpException);

    // Advance past the window: the key is allowed again (count resets to 1).
    nowSpy.mockReturnValue(base + windowMs + 1);
    expect(guard.canActivate(buildContext(makeRequest('192.0.2.50')))).toBe(
      true,
    );
    expect(internals(guard).buckets.get('192.0.2.50')?.count).toBe(1);
  });

  it('evicts expired buckets so the Map stays bounded over time', () => {
    const windowMs = internals(guard).windowMs;
    const base = 5_000_000;
    const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(base);

    // Create many distinct keys in the first window.
    for (let i = 0; i < 1000; i += 1) {
      guard.canActivate(
        buildContext(makeRequest(`10.0.${i >> 8}.${i & 0xff}`)),
      );
    }
    expect(internals(guard).buckets.size).toBe(1000);

    // Advance past the window so all prior buckets are expired, then make a
    // single new request: the sweep must prune the stale entries.
    nowSpy.mockReturnValue(base + windowMs + 1);
    guard.canActivate(buildContext(makeRequest('172.16.0.1')));

    expect(internals(guard).buckets.size).toBeLessThanOrEqual(2);
  });

  it('enforces a hard cap on the number of tracked keys within a window', () => {
    const maxKeys = internals(guard).maxKeys;
    expect(typeof maxKeys).toBe('number');

    const base = 7_000_000;
    jest.spyOn(Date, 'now').mockReturnValue(base);

    // Churn distinct keys past the cap within a single window.
    for (let i = 0; i < maxKeys + 50; i += 1) {
      guard.canActivate(
        buildContext(makeRequest(`100.64.${(i >> 8) & 0xff}.${i & 0xff}`)),
      );
    }

    expect(internals(guard).buckets.size).toBeLessThanOrEqual(maxKeys);
  });

  it('falls back to socket.remoteAddress then "unknown" when req.ip is undefined', () => {
    expect(
      guard.canActivate(
        buildContext(makeRequest(undefined, undefined, '192.0.2.99')),
      ),
    ).toBe(true);
    expect(internals(guard).buckets.has('192.0.2.99')).toBe(true);

    expect(
      guard.canActivate(
        buildContext(makeRequest(undefined, undefined, undefined)),
      ),
    ).toBe(true);
    expect(internals(guard).buckets.has('unknown')).toBe(true);
  });
});
