// src/lib/mcp/rate-limit.ts — in-memory fixed-window rate limiter for the MCP
// HTTP routes (/api/mcp, /api/worker/gsc-token).
//
// Scope & rationale: the reference deployment runs long-lived systemd services
// (see deploy/systemd/) — a single process per route, not serverless fan-out.
// A per-process in-memory counter is therefore an acceptable FIRST line of
// defence: it caps the request RATE (débit) before the expensive work (sha256 +
// DB SELECT for auth, atomic org-row UPDATE for quota) is ever reached. It is
// deliberately NOT the Better Auth `rate_limit` table (that table is owned by
// the Better Auth contract, src/lib/db/auth-schema.ts) and does nothing durable.
//
// Limitations (honest): counters live in a single process's heap. Multiple
// instances each get their own window (effective limit ~= N × configured), and
// a restart clears all windows. For a hard, cluster-wide guarantee, terminate
// abuse at the edge (Nginx `limit_req` today, a WAF/edge rate-limit later).
//
// Pure & testable: the clock is injectable (`now`), the store is module-scoped
// with lazy eviction of expired windows, and there is no I/O.

type Bucket = {
  /** Requests counted in the current window. */
  count: number;
  /** Epoch-ms when the current window opened. */
  windowStart: number;
  /** Window length for this bucket, kept so eviction is window-aware. */
  windowMs: number;
};

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

// Periodic sweep: eviction is O(n) so we amortise it across calls rather than
// scanning on every request. After PURGE_EVERY checks we drop every bucket
// whose window has fully elapsed, so abandoned keys (IPs, key ids) cannot leak
// memory unbounded.
const PURGE_EVERY = 256;
let callsSincePurge = 0;

function sweepExpired(now: number): void {
  for (const [key, bucket] of store) {
    if (now - bucket.windowStart >= bucket.windowMs) store.delete(key);
  }
}

export type RateLimitOptions = {
  /** Max requests allowed per window. */
  limit: number;
  /** Window length in milliseconds. */
  windowMs: number;
  /** Injectable clock (epoch ms). Defaults to Date.now — override in tests. */
  now?: () => number;
};

export type RateLimitResult = {
  /** True when the request is under the limit and may proceed. */
  ok: boolean;
  /** Seconds until the window resets. 0 when ok; >= 1 when rejected. */
  retryAfterSec: number;
};

/**
 * Fixed-window rate limit check for `bucketKey`.
 *  - First hit in a window (or a window that has fully elapsed) opens a fresh
 *    window and is allowed.
 *  - Up to `limit` hits per window are allowed; the `limit`-th is the last ok.
 *  - Beyond `limit` → `{ ok: false }` with `retryAfterSec` = whole seconds left
 *    in the current window (clamped to >= 1).
 * Deterministic and side-effect-free apart from the module store.
 */
export function checkRateLimit(
  bucketKey: string,
  opts: RateLimitOptions,
): RateLimitResult {
  const now = opts.now?.() ?? Date.now();
  const { limit, windowMs } = opts;

  if (++callsSincePurge >= PURGE_EVERY) {
    callsSincePurge = 0;
    sweepExpired(now);
  }

  const bucket = store.get(bucketKey);

  // No bucket, or the previous window has fully elapsed → open a fresh window.
  if (!bucket || now - bucket.windowStart >= windowMs) {
    store.set(bucketKey, { count: 1, windowStart: now, windowMs });
    return { ok: true, retryAfterSec: 0 };
  }

  if (bucket.count < limit) {
    bucket.count += 1;
    return { ok: true, retryAfterSec: 0 };
  }

  // Over the limit: report whole seconds left in the window (>= 1).
  const msLeft = windowMs - (now - bucket.windowStart);
  const retryAfterSec = Math.max(1, Math.ceil(msLeft / 1000));
  return { ok: false, retryAfterSec };
}

/**
 * Best-effort client IP from the reverse-proxy headers. The reference stack
 * puts Nginx in front of the systemd services, so `x-forwarded-for` /
 * `x-real-ip` are set by a TRUSTED upstream — do not trust these behind an
 * untrusted proxy or a client could spoof its bucket. Prefers the first
 * (client-most) IP of `x-forwarded-for`, then `x-real-ip`, then a constant
 * "unknown" so unattributable traffic still shares one bounded bucket.
 */
export function clientIpFromHeaders(headers: Headers): string {
  const xff = headers.get("x-forwarded-for");
  if (xff) {
    const first = xff.split(",")[0]?.trim();
    if (first) return first;
  }
  const real = headers.get("x-real-ip")?.trim();
  if (real) return real;
  return "unknown";
}

/** Test-only: clear the store and the sweep counter for hermetic tests. */
export function __resetRateLimitStore(): void {
  store.clear();
  callsSincePurge = 0;
}

/** Test-only: current number of live buckets (for eviction assertions). */
export function __rateLimitStoreSize(): number {
  return store.size;
}
