/**
 * Password-Reset Rate-Limit Security Suite
 *
 * Pure-logic tests for the auth flood control the password-reset endpoints run
 * on: the fixed-window limiter, the trusted client-IP extraction and the shared
 * 429 response helper. No database, no network — the route handlers themselves
 * are not imported because they call `requireDatabase()` first, which needs a
 * live Postgres. The primitives they call are exactly what is exercised here.
 *
 * Covered budgets (kept in sync with the routes):
 *   forgot-password    : forgot:ip:<ip> 5/h   + forgot:email:<key> 3/h
 *   reset-password     : reset:burst:ip:<ip> 5/10min  + reset:ip:<ip> 10/h
 *   resend-verification: resend:ip:<ip> 5/h   + resend:email:<key> 3/h
 *   login              : login:ip:<ip> 20/15min + login:email:<key> 10/15min
 *   signup             : signup:ip:<ip> 5/h   + signup:email:<key> 3/h
 *   change-password    : change:ip:<ip> 10/15min
 *   verify             : verify:ip:<ip> 60/h
 */

import { describe, test, expect, runAllTests } from "../e2e/runner";
import {
  clientIp,
  rateLimit,
  resetRateLimits,
} from "../../src/lib/security/rateLimit";

// sucrase-node runs plain CJS without Next.js's path-alias loader, so the
// "@/..." imports inside the shared HTTP helper would not resolve. Teach Node's
// resolver to map "@/x" onto <cwd>/src/x before loading that helper. This is
// module plumbing only — no test logic and no database.
const nodeModule = require("node:module") as typeof import("node:module") & {
  _resolveFilename: (request: string, ...args: unknown[]) => string;
};
const nodePath: typeof import("node:path") = require("node:path");
const originalResolve = nodeModule._resolveFilename;
nodeModule._resolveFilename = function (
  this: unknown,
  request: string,
  ...args: unknown[]
): string {
  if (request.startsWith("@/")) {
    return originalResolve.call(
      this,
      nodePath.join(process.cwd(), "src", request.slice(2)),
      ...args
    );
  }
  return originalResolve.call(this, request, ...args);
};

// Must be loaded after the shim above is installed.
const { rateLimited } = require("../../src/lib/auth/http") as typeof import("../../src/lib/auth/http");

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;

describe("Rate limit — fixed-window semantics", () => {
  test("the first `limit` calls within a window are allowed", () => {
    resetRateLimits();
    expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
    expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
    expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
  });

  test("the next call is denied with a positive retryAfter", () => {
    resetRateLimits();
    rateLimit("rl:b", 1, MINUTE);
    const denied = rateLimit("rl:b", 1, MINUTE);
    expect(denied.allowed).toBe(false);
    expect(denied.retryAfter).toBeGreaterThan(0);
  });

  test("denied calls never push the window back (fixed window)", () => {
    resetRateLimits();
    rateLimit("rl:c", 1, MINUTE);
    const first = rateLimit("rl:c", 1, MINUTE);
    const second = rateLimit("rl:c", 1, MINUTE);
    expect(first.allowed).toBe(false);
    expect(second.allowed).toBe(false);
    // retryAfter only ever shrinks as time passes; a larger value would mean the
    // window was extended on a denial, which must not happen.
    expect(second.retryAfter).toBeLessThanOrEqual(first.retryAfter);
  });

  test("different keys are fully isolated", () => {
    resetRateLimits();
    rateLimit("rl:exhausted", 1, MINUTE);
    expect(rateLimit("rl:exhausted", 1, MINUTE).allowed).toBe(false);
    expect(rateLimit("rl:fresh", 1, MINUTE).allowed).toBe(true);
    expect(rateLimit("rl:other", 5, MINUTE).allowed).toBe(true);
  });

  test("the window resets after it elapses", async () => {
    resetRateLimits();
    expect(rateLimit("rl:window", 1, 50).allowed).toBe(true);
    expect(rateLimit("rl:window", 1, 50).allowed).toBe(false);
    await sleep(60);
    expect(rateLimit("rl:window", 1, 50).allowed).toBe(true);
  });

  test("malformed input fails open instead of locking anyone out", () => {
    resetRateLimits();
    expect(rateLimit("", 5, MINUTE).allowed).toBe(true);
    expect(rateLimit("rl:zero-limit", 0, MINUTE).allowed).toBe(true);
    expect(rateLimit("rl:zero-window", 5, 0).allowed).toBe(true);
    expect(rateLimit("rl:nan", Number.NaN, MINUTE).allowed).toBe(true);
  });

  test("resetRateLimits empties every bucket", () => {
    resetRateLimits();
    rateLimit("rl:drain", 1, MINUTE);
    expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(false);
    resetRateLimits();
    expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(true);
  });
});

describe("Rate limit — the password-reset budgets in production", () => {
  test("reset endpoint: the burst cap (5/10min) blocks a rapid scripted run", () => {
    resetRateLimits();
    const key = "reset:burst:ip:203.0.113.7";
    for (let i = 0; i < 5; i++) {
      expect(rateLimit(key, 5, 10 * MINUTE).allowed).toBe(true);
    }
    const denied = rateLimit(key, 5, 10 * MINUTE);
    expect(denied.allowed).toBe(false);
    expect(denied.retryAfter).toBeGreaterThan(0);
  });

  test("reset endpoint: the hourly budget (10/h) still applies on top", () => {
    resetRateLimits();
    const key = "reset:ip:203.0.113.7";
    for (let i = 0; i < 10; i++) {
      expect(rateLimit(key, 10, HOUR).allowed).toBe(true);
    }
    expect(rateLimit(key, 10, HOUR).allowed).toBe(false);
  });

  test("verify endpoint: the generous per-IP budget (60/h) survives a normal click", () => {
    resetRateLimits();
    const key = "verify:ip:203.0.113.7";
    for (let i = 0; i < 60; i++) {
      expect(rateLimit(key, 60, HOUR).allowed).toBe(true);
    }
    expect(rateLimit(key, 60, HOUR).allowed).toBe(false);
  });

  test("forgot-password: IP 5/h and per-email 3/h are independent", () => {
    resetRateLimits();
    for (let i = 0; i < 5; i++) {
      expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(true);
    }
    expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(false);
    for (let i = 0; i < 3; i++) {
      expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(true);
    }
    expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(false);
  });
});

describe("Rate limit — the 429 response helper", () => {
  test("rateLimited() answers 429 with a Retry-After header", () => {
    const response = rateLimited(42);
    expect(response.status).toBe(429);
    expect(response.headers.get("retry-after")).toBe("42");
  });

  test("rateLimited() carries the stable rate_limited error code", async () => {
    const response = rateLimited(9);
    const body = await response.json();
    expect(body.error).toBe("rate_limited");
    expect(body.retryAfter).toBe(9);
  });

  test("the limiter's retryAfter round-trips into the Retry-After header", () => {
    resetRateLimits();
    const key = "rl:roundtrip";
    rateLimit(key, 1, MINUTE);
    const denied = rateLimit(key, 1, MINUTE);
    const response = rateLimited(denied.retryAfter);
    expect(response.status).toBe(429);
    expect(Number(response.headers.get("retry-after"))).toBe(denied.retryAfter);
    expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0);
  });
});

describe("Rate limit — client IP extraction", () => {
  const request = (headers: Record<string, string>): Request =>
    new Request("https://example.com/api/auth/reset-password", { headers });

  test("x-forwarded-for: the right-most valid IP wins over a spoofed prefix", () => {
    const req = request({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" });
    expect(clientIp(req)).toBe("203.0.113.7");
  });

  test("x-forwarded-for: a three-hop chain resolves to the proxy-appended tail", () => {
    const req = request({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 198.51.100.9" });
    expect(clientIp(req)).toBe("198.51.100.9");
  });

  test("x-forwarded-for: junk entries are skipped, the last valid one wins", () => {
    const req = request({ "x-forwarded-for": "not-an-ip, also-junk, 203.0.113.7" });
    expect(clientIp(req)).toBe("203.0.113.7");
  });

  test("x-real-ip is the fallback when x-forwarded-for is absent", () => {
    const req = request({ "x-real-ip": "198.51.100.9" });
    expect(clientIp(req)).toBe("198.51.100.9");
  });

  test("x-real-ip also saves the day when x-forwarded-for has no valid IP", () => {
    const req = request({ "x-forwarded-for": "garbage", "x-real-ip": "198.51.100.9" });
    expect(clientIp(req)).toBe("198.51.100.9");
  });

  test("an empty or invalid header chain resolves to \"unknown\"", () => {
    expect(clientIp(request({}))).toBe("unknown");
    expect(clientIp(request({ "x-forwarded-for": "nonsense" }))).toBe("unknown");
    expect(clientIp(request({ "x-real-ip": "nonsense" }))).toBe("unknown");
  });
});

if (typeof require !== "undefined" && require.main === module) {
  runAllTests().then((ok) => process.exit(ok ? 0 : 1));
}
