import type { NextFunction, Request, Response } from 'express';
import { createRateLimitMiddleware, readRateLimitConfig } from './rate-limit.middleware';

function makeReq(opts: {
  ip?: string;
  path: string;
  sessionID?: string;
  session?: { user?: { oid: string } };
}): Request {
  return {
    ip: opts.ip ?? '1.2.3.4',
    path: opts.path,
    sessionID: opts.sessionID,
    session: opts.session,
    headers: {},
    method: 'POST',
    get: () => undefined,
  } as unknown as Request;
}

function makeRes(): Response & { status: jest.Mock; json: jest.Mock; setHeader: jest.Mock } {
  const res = {
    status: jest.fn(),
    json: jest.fn(),
    setHeader: jest.fn(),
    getHeader: jest.fn(),
    headersSent: false,
  };
  res.status.mockReturnValue(res);
  res.json.mockReturnValue(res);
  return res as unknown as Response & {
    status: jest.Mock;
    json: jest.Mock;
    setHeader: jest.Mock;
  };
}

describe('readRateLimitConfig', () => {
  const origGeneral = process.env['RATE_LIMIT_PER_MINUTE'];
  const origAuth = process.env['RATE_LIMIT_AUTH_PER_MINUTE'];

  afterEach(() => {
    restore('RATE_LIMIT_PER_MINUTE', origGeneral);
    restore('RATE_LIMIT_AUTH_PER_MINUTE', origAuth);
  });

  it('returns conservative defaults when env is unset', () => {
    delete process.env['RATE_LIMIT_PER_MINUTE'];
    delete process.env['RATE_LIMIT_AUTH_PER_MINUTE'];
    expect(readRateLimitConfig()).toEqual({ perMinute: 120, authPerMinute: 10 });
  });

  it('parses positive integer overrides', () => {
    process.env['RATE_LIMIT_PER_MINUTE'] = '300';
    process.env['RATE_LIMIT_AUTH_PER_MINUTE'] = '5';
    expect(readRateLimitConfig()).toEqual({ perMinute: 300, authPerMinute: 5 });
  });

  it('rejects non-positive integers', () => {
    process.env['RATE_LIMIT_PER_MINUTE'] = '0';
    expect(() => readRateLimitConfig()).toThrow(/must be a positive integer/);
  });

  it('rejects non-numeric values', () => {
    process.env['RATE_LIMIT_PER_MINUTE'] = 'fast';
    expect(() => readRateLimitConfig()).toThrow(/must be a positive integer/);
  });
});

describe('createRateLimitMiddleware', () => {
  it('lets /api/health through without counting', async () => {
    const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 1 });
    const next = jest.fn() as unknown as NextFunction;
    // Hammer /api/health far above the limit — still passes.
    for (let i = 0; i < 5; i++) {
      (next as jest.Mock).mockClear();
      const res = makeRes();
      await mw(makeReq({ path: '/api/health' }), res, next);
      expect(next).toHaveBeenCalledTimes(1);
      expect(res.status).not.toHaveBeenCalledWith(429);
    }
  });

  it('enforces the general bucket: passes up to perMinute then 429s', async () => {
    const mw = createRateLimitMiddleware({ perMinute: 2, authPerMinute: 99 });
    const next = jest.fn() as unknown as NextFunction;
    const ip = '10.0.0.1';
    // First two: pass.
    for (let i = 0; i < 2; i++) {
      (next as jest.Mock).mockClear();
      const res = makeRes();
      await mw(makeReq({ ip, path: '/api/whatever' }), res, next);
      expect(next).toHaveBeenCalled();
    }
    // Third: rejected.
    const res = makeRes();
    await mw(makeReq({ ip, path: '/api/whatever' }), res, next);
    expect(res.status).toHaveBeenCalledWith(429);
    const body = res.json.mock.calls[0]?.[0] as { error: { code: string; message: string } };
    expect(body.error.code).toBe('rate_limited');
    expect(body.error.message).toBe('Too many requests');
  });

  it('enforces the stricter bucket on /api/auth/login', async () => {
    const mw = createRateLimitMiddleware({ perMinute: 99, authPerMinute: 1 });
    const next = jest.fn() as unknown as NextFunction;
    const ip = '10.0.0.2';
    // First /login: pass.
    let res = makeRes();
    await mw(makeReq({ ip, path: '/api/auth/login' }), res, next);
    expect(res.status).not.toHaveBeenCalledWith(429);
    // Second /login: blocked (authPerMinute=1).
    res = makeRes();
    await mw(makeReq({ ip, path: '/api/auth/login' }), res, next);
    expect(res.status).toHaveBeenCalledWith(429);
  });

  it('keys per-session when authenticated, isolating buckets', async () => {
    const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 99 });
    const next = jest.fn() as unknown as NextFunction;
    const session = { user: { oid: 'u1' } };

    // session sid-A: 1 hit allowed
    let res = makeRes();
    await mw(makeReq({ path: '/api/x', sessionID: 'sid-A', session }), res, next);
    expect(res.status).not.toHaveBeenCalledWith(429);
    // session sid-A: 2nd hit blocked
    res = makeRes();
    await mw(makeReq({ path: '/api/x', sessionID: 'sid-A', session }), res, next);
    expect(res.status).toHaveBeenCalledWith(429);

    // session sid-B: independent bucket, 1st hit still allowed
    res = makeRes();
    await mw(makeReq({ path: '/api/x', sessionID: 'sid-B', session }), res, next);
    expect(res.status).not.toHaveBeenCalledWith(429);
  });

  it('keys per-IP when anonymous (no req.session.user)', async () => {
    const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 99 });
    const next = jest.fn() as unknown as NextFunction;

    let res = makeRes();
    await mw(makeReq({ ip: '10.0.0.10', path: '/api/x' }), res, next);
    expect(res.status).not.toHaveBeenCalledWith(429);

    // Same IP, 2nd hit blocked.
    res = makeRes();
    await mw(makeReq({ ip: '10.0.0.10', path: '/api/x' }), res, next);
    expect(res.status).toHaveBeenCalledWith(429);

    // Different IP, independent bucket.
    res = makeRes();
    await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next);
    expect(res.status).not.toHaveBeenCalledWith(429);
  });
});

function restore(name: string, value: string | undefined): void {
  if (value === undefined) {
    delete process.env[name];
  } else {
    process.env[name] = value;
  }
}
