import { HttpException, HttpStatus, type ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import { type EndpointRateLimitOptions } from '../decorators/endpoint-rate-limit.decorator';
import { EndpointRateLimitGuard } from '../guards/endpoint-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, 0]);

  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?: EndpointRateLimitOptions | undefined) {
  return {
    getAllAndOverride: vi.fn().mockReturnValue(options ?? undefined),
  } as unknown as Reflector;
}

interface MockContextOptions {
  user?: { sub: string; role: string } | null;
  method?: string;
  path?: string;
  routePath?: string;
  ip?: string;
  forwarded?: string | string[];
}

function buildContext(opts: MockContextOptions = {}): ExecutionContext {
  const headers: Record<string, string | string[] | undefined> = {};
  if (opts.forwarded !== undefined) {
    headers['x-forwarded-for'] = opts.forwarded;
  }

  const response = { setHeader: vi.fn() };
  const user = 'user' in opts ? opts.user : null;

  return {
    switchToHttp: () => ({
      getRequest: () => ({
        user,
        method: opts.method ?? 'GET',
        path: opts.path ?? '/search',
        route: opts.routePath ? { path: opts.routePath } : undefined,
        ip: opts.ip ?? '192.168.1.1',
        headers,
      }),
      getResponse: () => response,
    }),
    getHandler: () => ({ name: 'testHandler' }),
    getClass: () => ({ name: 'TestController' }),
  } as unknown as ExecutionContext;
}

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

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

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

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

  describe('when no @EndpointRateLimit decorator is present', () => {
    it('allows request (skips rate limiting)', async () => {
      const redis = mockRedis();
      const guard = new EndpointRateLimitGuard(redis, mockReflector(), mockLogger());

      const result = await guard.canActivate(buildContext());
      expect(result).toBe(true);
      // Redis should not be called at all
      expect(redis.getClient).not.toHaveBeenCalled();
    });
  });

  describe('when @EndpointRateLimit is present', () => {
    const defaultOptions: EndpointRateLimitOptions = {
      limit: 5,
      windowSeconds: 60,
      keyStrategy: 'ip',
    };

    it('allows request within rate limit', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector(defaultOptions),
        mockLogger(),
      );

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

    it('rejects request with 429 when limit exceeded', async () => {
      // current=5 (at limit), retryAfterMs=30000
      const redis = mockRedis({ evalResult: [5, 30_000] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector(defaultOptions),
        mockLogger(),
      );

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

      try {
        await guard.canActivate(buildContext());
      } 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,
          message: 'Too many requests. Please try again later.',
          retryAfter: 30, // 30000ms → 30s
        });
      }
    });

    it('sets rate limit headers on allowed requests', async () => {
      const redis = mockRedis({ evalResult: [3, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector(defaultOptions),
        mockLogger(),
      );

      const ctx = buildContext();
      await guard.canActivate(ctx);

      const response = ctx.switchToHttp().getResponse();
      expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', 5);
      expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 2); // 5 - 3
      expect(response.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', 60);
    });

    it('sets Retry-After header when limit exceeded', async () => {
      const redis = mockRedis({ evalResult: [5, 45_000] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector(defaultOptions),
        mockLogger(),
      );

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

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

  describe('key strategy', () => {
    it('uses IP-based key by default', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

      await guard.canActivate(
        buildContext({ ip: '10.0.0.1', method: 'POST', routePath: '/auth/login' }),
      );

      const evalCall = redis.getClient().eval.mock.calls[0];
      const key: string = evalCall[2];
      expect(key).toContain('ip:10.0.0.1');
      expect(key).toContain('POST');
      expect(key).toContain('/auth/login');
    });

    it('uses X-Forwarded-For IP when available', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

      await guard.canActivate(
        buildContext({ forwarded: '203.0.113.5, 70.41.3.18', ip: '127.0.0.1' }),
      );

      const evalCall = redis.getClient().eval.mock.calls[0];
      const key: string = evalCall[2];
      expect(key).toContain('ip:203.0.113.5');
    });

    it('uses authenticated user ID when strategy is "user"', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 10, keyStrategy: 'user' }),
        mockLogger(),
      );

      await guard.canActivate(
        buildContext({
          user: { sub: 'user-abc', role: 'BUYER' },
          method: 'POST',
          routePath: '/listings',
        }),
      );

      const evalCall = redis.getClient().eval.mock.calls[0];
      const key: string = evalCall[2];
      expect(key).toContain('user:user-abc');
      expect(key).toContain('POST');
      expect(key).toContain('/listings');
    });

    it('falls back to IP when strategy is "user" but no authenticated user', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 30, keyStrategy: 'user' }),
        mockLogger(),
      );

      await guard.canActivate(
        buildContext({ user: null, ip: '10.0.0.5', routePath: '/search' }),
      );

      const evalCall = redis.getClient().eval.mock.calls[0];
      const key: string = evalCall[2];
      expect(key).toContain('ip:10.0.0.5');
    });
  });

  describe('admin bypass', () => {
    it('bypasses rate limit for ADMIN users by default', async () => {
      const redis = mockRedis();
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

      const result = await guard.canActivate(
        buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
      );
      expect(result).toBe(true);
      // Redis should not be called — admin bypasses
      expect(redis.getClient).not.toHaveBeenCalled();
    });

    it('does not bypass ADMIN when adminBypass is false', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 100, keyStrategy: 'ip', adminBypass: false }),
        mockLogger(),
      );

      await guard.canActivate(
        buildContext({ user: { sub: 'admin-1', role: 'ADMIN' } }),
      );

      // Redis WAS called — no bypass
      expect(redis.getClient).toHaveBeenCalled();
    });

    it('does not bypass for non-ADMIN roles', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

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

      expect(redis.getClient).toHaveBeenCalled();
    });
  });

  describe('fail-open behavior', () => {
    it('allows request when Redis throws an error', async () => {
      const redis = mockRedis({ throwError: true });
      const logger = mockLogger();
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        logger,
      );

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

    it('re-throws 429 errors (does not swallow intentional rejections)', async () => {
      // Simulate a situation where Redis returns limit exceeded
      const redis = mockRedis({ evalResult: [5, 30_000] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

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

  describe('window seconds', () => {
    it('defaults to 60 seconds when windowSeconds is not specified', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5 }), // no windowSeconds
        mockLogger(),
      );

      await guard.canActivate(buildContext());

      const evalCall = redis.getClient().eval.mock.calls[0];
      // ARGV[2] = windowMs = 60 * 1000
      expect(evalCall[4]).toBe(60_000);
      // ARGV[5] = windowSeconds = 60
      expect(evalCall[7]).toBe(60);
    });

    it('uses custom window seconds when specified', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 10, windowSeconds: 120 }),
        mockLogger(),
      );

      await guard.canActivate(buildContext());

      const evalCall = redis.getClient().eval.mock.calls[0];
      // ARGV[2] = windowMs = 120 * 1000
      expect(evalCall[4]).toBe(120_000);
      // ARGV[5] = windowSeconds = 120
      expect(evalCall[7]).toBe(120);
    });
  });

  describe('logging', () => {
    it('logs warning when rate limit exceeded', async () => {
      const redis = mockRedis({ evalResult: [5, 30_000] });
      const logger = mockLogger();
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        logger,
      );

      try {
        await guard.canActivate(buildContext({ method: 'POST', routePath: '/auth/login' }));
      } catch {
        // expected
      }

      expect(logger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Endpoint rate limit exceeded'),
        'EndpointRateLimitGuard',
      );
    });

    it('logs warning on Redis failure', async () => {
      const redis = mockRedis({ throwError: true });
      const logger = mockLogger();
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        logger,
      );

      await guard.canActivate(buildContext());

      expect(logger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Redis error'),
        'EndpointRateLimitGuard',
      );
    });
  });

  describe('Retry-After computation', () => {
    it('ensures minimum Retry-After of 1 second', async () => {
      // retryAfterMs = 500 → should ceil to 1 second
      const redis = mockRedis({ evalResult: [5, 500] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

      const ctx = buildContext();
      try {
        await guard.canActivate(ctx);
      } catch (error) {
        const body = (error as HttpException).getResponse() as any;
        expect(body.retryAfter).toBe(1);
      }
    });

    it('rounds up Retry-After to next second', async () => {
      // retryAfterMs = 15100 → should ceil to 16 seconds
      const redis = mockRedis({ evalResult: [5, 15_100] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 5, keyStrategy: 'ip' }),
        mockLogger(),
      );

      const ctx = buildContext();
      try {
        await guard.canActivate(ctx);
      } catch (error) {
        const body = (error as HttpException).getResponse() as any;
        expect(body.retryAfter).toBe(16);
      }
    });
  });

  describe('endpoint path in key', () => {
    it('uses route path when available', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 100, keyStrategy: 'ip' }),
        mockLogger(),
      );

      await guard.canActivate(
        buildContext({ routePath: '/payments/callback/:provider', path: '/payments/callback/vnpay' }),
      );

      const evalCall = redis.getClient().eval.mock.calls[0];
      const key: string = evalCall[2];
      // Should use the route pattern, not the resolved path
      expect(key).toContain('/payments/callback/:provider');
    });

    it('falls back to request path when no route defined', async () => {
      const redis = mockRedis({ evalResult: [1, 0] });
      const guard = new EndpointRateLimitGuard(
        redis,
        mockReflector({ limit: 30, keyStrategy: 'ip' }),
        mockLogger(),
      );

      await guard.canActivate(buildContext({ path: '/search' }));

      const evalCall = redis.getClient().eval.mock.calls[0];
      const key: string = evalCall[2];
      expect(key).toContain('/search');
    });
  });
});
