import { isIP } from "node:net";

/**
 * In-memory fixed-window rate limiter plus trusted client-IP extraction.
 *
 * ## Why a fixed window
 * A fixed window is simple to reason about, cheap (two map lookups per call)
 * and gives a hard cap of `limit` requests per `windowMs` per key — exactly
 * what brute-force protection needs. A sliding window smooths the boundary
 * between windows but at the cost of holding per-request timestamps; the
 * added precision is not worth the memory for an auth flood control.
 *
 * ## Deployment caveat (read before scaling out)
 * The counters live in the process heap, so each server instance enforces
 * its OWN budget: N replicas behind a load balancer multiply the effective
 * limit by N and a restart clears every bucket. That is acceptable for a
 * single-instance or low-replica deployment — the limiter is a speed bump
 * against scripted abuse, not the primary defence (email verification,
 * unique-email keys and session invalidation are). Before scaling out,
 * move the counters to a shared store (Redis with INCR+EXPIRE, or a
 * Postgres table) keyed by the same `route:scope:ip` strings.
 *
 * ## Key composition
 * Callers build the key as `<route>:<scope>:<id>` (e.g. `login:ip:203.0.113.7`
 * or `login:email:timo%40example.com`), so the same helper serves both
 * per-IP flood control and per-account brute-force protection.
 */

interface Bucket {
  count: number;
  resetAt: number;
}

/** Hard ceiling on tracked keys so a buggy caller cannot grow the map unbounded. */
const MAX_BUCKETS = 10_000;

const buckets = new Map<string, Bucket>();

/** Opportunistic eviction of expired buckets once the map gets large. */
function sweep(now: number): void {
  if (buckets.size < MAX_BUCKETS) return;
  for (const [key, bucket] of buckets) {
    if (bucket.resetAt <= now) buckets.delete(key);
  }
}

export interface RateLimitResult {
  allowed: boolean;
  /** Seconds until the window resets — surface as `Retry-After`. 0 when allowed. */
  retryAfter: number;
}

/**
 * Records one attempt for `key`. The first `limit` calls within `windowMs`
 * are allowed; every call after that is denied until the window rolls over.
 * Malformed input is treated as "allow" so a bad configuration can never
 * lock out real users.
 */
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
  if (!key || !Number.isFinite(limit) || limit < 1 || !Number.isFinite(windowMs) || windowMs < 1) {
    return { allowed: true, retryAfter: 0 };
  }

  const now = Date.now();
  sweep(now);

  const bucket = buckets.get(key);
  if (!bucket || bucket.resetAt <= now) {
    buckets.set(key, { count: 1, resetAt: now + windowMs });
    return { allowed: true, retryAfter: 0 };
  }

  bucket.count += 1;
  if (bucket.count > limit) {
    return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
  }
  return { allowed: true, retryAfter: 0 };
}

/** Empties every bucket. Used by tests and by tooling that wants a clean slate. */
export function resetRateLimits(): void {
  buckets.clear();
}

/**
 * Best-effort client address for rate-limit keys.
 *
 * ## Header policy (documented, do not change silently)
 * The app runs behind a reverse proxy that APPENDS the real peer address as
 * the last element of `x-forwarded-for`. Every earlier element is attacker
 * controllable, so we walk the chain from the END and take the last entry
 * that parses as an IP — never the first. If the deployment stops appending
 * (e.g. a proxy that overwrites instead), switch to `x-real-ip` only and
 * have the proxy set it; never trust a raw client-supplied header.
 *
 * Falls back to `x-real-ip` (a header set by the trusted proxy) and finally
 * to the literal `"unknown"` — a spoofed value can never be used as a key
 * because the result must pass `isIP()`.
 */
export function clientIp(request: Request): string {
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    const candidates = forwarded
      .split(",")
      .map((part) => part.trim())
      .filter(Boolean);
    const ip = [...candidates].reverse().find((candidate) => isIP(candidate) > 0);
    if (ip) return ip;
  }

  const realIp = request.headers.get("x-real-ip");
  if (realIp && isIP(realIp.trim()) > 0) return realIp.trim();

  return "unknown";
}
