import { HttpException, HttpStatus, type ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import {
  UserRateLimitGuard,
  DEFAULT_ROLE_LIMITS,
  DEFAULT_WINDOW_SECONDS,
} from '../guards/user-rate-limit.guard';

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

function mockRedis(overrides: Partial<{ evalResult: [number, number]; throwError: boolean }> = {}) {
  const evalFn = overrides.throwError
    ? vi.fn().mockRejectedValue(new Error('Redis connection lost'))
    : vi.fn().mockResolvedValue(overrides.evalResult ?? [1, 60]);

  return {
    getClient: vi.fn().mockReturnValue({ eval: evalFn }),
    isAvailable: vi.fn().mockReturnValue(!overrides.throwError),
    get: vi.fn(),
    set: vi.fn(),
    del: vi.fn(),
    onModuleDestroy: vi.fn(),
  } as any;
}

function mockLogger() {
  return {
    log: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
  } as any;
}

function mockReflector(options?: any) {
  return {
    getAllAndOverride: vi.fn().mockReturnValue(options ?? undefined),
  } as unknown as Reflector;
}

interface MockContextOptions {
  user?: { sub: string; role: string } | null;
  handler?: string;
  controller?: string;
}

function buildContext(opts: MockContextOptions = {}): ExecutionContext {
  const headers: Record<string, string> = {};
  const response = { setHeader: vi.fn() };
  const user = 'user' in opts ? opts.user : { sub: 'user-123', role: 'BUYER' };

  return {
    switchToHttp: () => ({
      getRequest: () => ({
        user,
        headers,
      }),
      getResponse: () => response,
    }),
    getHandler: () => ({ name: opts.handler ?? 'testHandler' }),
    getClass: () => ({ name: opts.controller ?? 'TestController' }),
  } as unknown as ExecutionContext;
}

// ── tests ────────────────────────────────────────────────────────────────────

describe('UserRateLimitGuard', () => {
  const originalNodeEnv = process.env['NODE_ENV'];

  beforeEach(() => {
    process.env['NODE_ENV'] = 'development';
  });

  afterEach(() => {
    process.env['NODE_ENV'] = originalNodeEnv;
  });

  it('allows request when user is within rate limit', async () => {
    const redis = mockRedis({ evalResult: [1, 60] });
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    const result = await guard.canActivate(buildContext());
    expect(result).toBe(true);
  });

  it('passes through for unauthenticated requests (no user)', async () => {
    const redis = mockRedis();
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    const result = await guard.canActivate(buildContext({ user: null }));
    expect(result).toBe(true);
  });

  it('passes through for requests without user.sub', async () => {
    const redis = mockRedis();
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    const result = await guard.canActivate(
      buildContext({ user: { sub: '', role: 'BUYER' } }),
    );
    expect(result).toBe(true);
  });

  it('rejects request with 429 when rate limit exceeded', async () => {
    const redis = mockRedis({ evalResult: [101, 45] }); // 101 > BUYER limit of 100
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    const ctx = buildContext();
    await expect(guard.canActivate(ctx)).rejects.toThrow(HttpException);

    try {
      await guard.canActivate(ctx);
    } catch (error) {
      expect(error).toBeInstanceOf(HttpException);
      expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
      const body = (error as HttpException).getResponse();
      expect(body).toMatchObject({
        statusCode: 429,
        retryAfter: 45,
      });
    }
  });

  it('sets rate limit headers on the response', async () => {
    const redis = mockRedis({ evalResult: [50, 30] });
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
    const ctx = buildContext();

    await guard.canActivate(ctx);

    const response = ctx.switchToHttp().getResponse();
    expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', DEFAULT_ROLE_LIMITS.BUYER);
    expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 50); // 100 - 50
    expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', 30);
  });

  it('sets Retry-After header when limit exceeded', async () => {
    const redis = mockRedis({ evalResult: [201, 42] }); // Exceeds AGENT limit of 200
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());
    const ctx = buildContext({ user: { sub: 'agent-1', role: 'AGENT' } });

    try {
      await guard.canActivate(ctx);
    } catch {
      // expected
    }

    const response = ctx.switchToHttp().getResponse();
    expect(response.setHeader).toHaveBeenCalledWith('Retry-After', 42);
  });

  it('uses role-specific limits (ADMIN gets 500)', async () => {
    const redis = mockRedis({ evalResult: [450, 20] });
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    const result = await guard.canActivate(
      buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
    );
    expect(result).toBe(true); // 450 < 500 ADMIN limit
  });

  it('uses role-specific limits (AGENT gets 200)', async () => {
    const redis = mockRedis({ evalResult: [199, 10] });
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    const result = await guard.canActivate(
      buildContext({ user: { sub: 'agent-1', role: 'AGENT' } }),
    );
    expect(result).toBe(true); // 199 < 200 AGENT limit
  });

  it('uses per-route overrides from @UserRateLimit decorator', async () => {
    const redis = mockRedis({ evalResult: [51, 30] });
    // Override BUYER limit to 50 for this route
    const reflector = mockReflector({ limits: { BUYER: 50 }, windowSeconds: 120 });
    const guard = new UserRateLimitGuard(redis, reflector, mockLogger());

    await expect(guard.canActivate(buildContext())).rejects.toThrow(HttpException);
  });

  it('passes Redis window seconds argument correctly', async () => {
    const redis = mockRedis({ evalResult: [1, 120] });
    const reflector = mockReflector({ windowSeconds: 120 });
    const guard = new UserRateLimitGuard(redis, reflector, mockLogger());

    await guard.canActivate(buildContext());

    const evalCall = redis.getClient().eval.mock.calls[0];
    expect(evalCall[3]).toBe(120); // windowSeconds arg passed to Lua
  });

  it('uses correct Redis key format with user ID', async () => {
    const redis = mockRedis({ evalResult: [1, 60] });
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    await guard.canActivate(buildContext({ user: { sub: 'user-xyz', role: 'BUYER' } }));

    const evalCall = redis.getClient().eval.mock.calls[0];
    expect(evalCall[2]).toBe('rate_limit:user:user-xyz');
  });

  it('fails open when Redis throws an error', async () => {
    const redis = mockRedis({ throwError: true });
    const logger = mockLogger();
    const guard = new UserRateLimitGuard(redis, mockReflector(), logger);

    const result = await guard.canActivate(buildContext());
    expect(result).toBe(true);
    expect(logger.warn).toHaveBeenCalledWith(
      expect.stringContaining('User rate limit check failed'),
      'UserRateLimitGuard',
    );
  });

  it('logs warning when rate limit exceeded', async () => {
    const redis = mockRedis({ evalResult: [101, 55] });
    const logger = mockLogger();
    const guard = new UserRateLimitGuard(redis, mockReflector(), logger);

    try {
      await guard.canActivate(buildContext());
    } catch {
      // expected
    }

    expect(logger.warn).toHaveBeenCalledWith(
      expect.stringContaining('User rate limit exceeded'),
      'UserRateLimitGuard',
    );
  });

  it('uses DEFAULT_WINDOW_SECONDS when no override provided', async () => {
    const redis = mockRedis({ evalResult: [1, DEFAULT_WINDOW_SECONDS] });
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    await guard.canActivate(buildContext());

    const evalCall = redis.getClient().eval.mock.calls[0];
    expect(evalCall[3]).toBe(DEFAULT_WINDOW_SECONDS);
  });

  it('defaults to BUYER limit for unknown roles', async () => {
    const redis = mockRedis({ evalResult: [101, 30] }); // > BUYER's 100
    const guard = new UserRateLimitGuard(redis, mockReflector(), mockLogger());

    // Force an unexpected role value
    await expect(
      guard.canActivate(buildContext({ user: { sub: 'u1', role: 'UNKNOWN_ROLE' } })),
    ).rejects.toThrow(HttpException);
  });
});
