import { Ratelimit, type Duration } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// @ts-expect-error — JS module, no declaration file
import { captureSilentError } from '../../api/_sentry-edge.js';

// @upstash/redis defaults to 5 retries with exponential backoff (~4.3s total)
// before surfacing an unreachable-Redis error. The node test runner sets
// NODE_TEST_CONTEXT in the child that executes each file; in that context the
// fail-open / fail-closed rate-limit tests point UPSTASH_REDIS_REST_URL at a
// fake host and would otherwise burn that full backoff on every limiter call.
// Skip retries under the test runner only — production (env unset) keeps the
// resilient default untouched. Mirrors the retry:false already shipped on the
// MCP limiter to unblock the suite (PR #3963).
const REDIS_TEST_RETRY_OPTS: { retry?: false } = process.env.NODE_TEST_CONTEXT ? { retry: false } : {};

let ratelimit: Ratelimit | null = null;

function getRatelimit(): Ratelimit | null {
  if (ratelimit) return ratelimit;
  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) return null;

  ratelimit = new Ratelimit({
    redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }),
    limiter: Ratelimit.slidingWindow(600, '60 s'),
    prefix: 'rl',
    analytics: false,
  });
  return ratelimit;
}

// Sentinel returned when no trusted client-IP header is present. Routed
// through the Upstash limiter as a single shared bucket so the entire
// "no trusted identity" population is naturally rate-limited together —
// an attacker who strips cf-connecting-ip / x-real-ip can no longer rotate
// identities by toggling x-forwarded-for. See getClientIp / #3531.
export const UNKNOWN_CLIENT_IP = 'unknown';

// Structured one-line log so api/server log aggregation can grep for the
// "rate-limit available" gap independently of Sentry. Keep the prefix
// stable — operators and the api/_rate-limit.js mirror both emit it.
// Decide the Sentry level for a degraded-rate-limit capture. Upstash runtime
// transients — the Lua limiter script timing out under fan-out load
// (`ERR Error running script: execution timed out`), a dropped command, or a
// network/timeout blip — are absorbed by the fail-open / `failClosed`-503 path,
// so the user is unaffected. Capture those at `warning` so a sustained Redis
// outage still escalates by volume without a transient script-timeout drowning
// genuine error-level signal in the dashboard (WORLDMONITOR-RX; mirrors the
// SERVICE_UNAVAILABLE `level: 'warning'` precedent in api/user-prefs.ts). A
// `missing-config` stage is a real deploy misconfiguration and any novel error
// is unclassified — both stay at `error` so on-call still sees them.
// Mirrored verbatim in api/_rate-limit.js.
function rateLimitErrorLevel(stage: string, msg: string): 'warning' | 'error' {
  if (stage.includes('missing-config')) return 'error';
  if (/Error running script|execution timed out|Command failed|ETIMEDOUT|ECONNRESET|ENOTFOUND|fetch failed|network|timed out|socket hang up/i.test(msg)) {
    return 'warning';
  }
  return 'error';
}

function logRateLimitDegraded(stage: string, err: unknown): void {
  const msg = err instanceof Error ? err.message : String(err);
  console.error(`[rate-limit] redis-error stage=${stage} msg=${msg}`);
  captureSilentError(err, {
    tags: { surface: 'server', component: 'rate-limit', stage },
    fingerprint: ['rate-limit', 'redis-error', stage],
    level: rateLimitErrorLevel(stage, msg),
  });
}

const scopedMissingConfigStages = new Set<string>();

function logScopedRateLimitMissingConfig(scope: string): void {
  const stage = `checkScopedRateLimit:${scope}:missing-config`;
  if (scopedMissingConfigStages.has(stage)) return;
  scopedMissingConfigStages.add(stage);
  logRateLimitDegraded(stage, new Error('UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN missing'));
}

// Marker header set on every degraded (fail-closed) response so observability
// can correlate "rate-limit unavailable" windows with downstream behaviour
// without parsing the JSON body. Mirrored in api/_rate-limit.js.
export const RATE_LIMIT_DEGRADED_HEADERS = {
  'X-RateLimit-Mode': 'degraded',
  // Short Retry-After encourages clients to retry once the limiter is back,
  // rather than treating the 503 as a hard outage.
  'Retry-After': '5',
} as const;

// Header a Cloudflare Transform Rule injects on every proxied request to prove
// the request actually transited CF. Keep in sync with api/_client-ip.js.
const CF_EDGE_PROOF_HEADER = 'x-wm-edge-proof';

// Constant-time comparison for the edge-proof secret. Synchronous so getClientIp
// stays sync (per-request rate-limit hot path, several non-awaiting callers).
function constantTimeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

// True only when the request proves it transited Cloudflare. If
// CF_EDGE_PROOF_SECRET is unset, do not trust cf-connecting-ip; fall back to
// x-real-ip/UNKNOWN so a missing deployment secret cannot silently reopen
// GHSA-c267.
function cfTransitProven(request: Request): boolean {
  const secret = (process.env.CF_EDGE_PROOF_SECRET ?? '').trim();
  if (!secret) return false;
  return constantTimeEqual((request.headers.get(CF_EDGE_PROOF_HEADER) ?? '').trim(), secret);
}

export function getClientIp(request: Request): string {
  // cf-connecting-ip is only unforgeable for traffic that actually transited
  // Cloudflare (x-real-ip is then the CF edge IP, shared across users). On a
  // direct-to-origin hit (bypassing CF) cf-connecting-ip is fully client-
  // controlled, so a caller sending a fresh value per request rotates the
  // per-IP window and neutralises the limit (GHSA-c267). Trust it only with
  // proof of CF transit. Otherwise fall back to x-real-ip (the real peer IP)
  // then the UNKNOWN_CLIENT_IP sentinel — the spoofable cf-connecting-ip and
  // the client-settable x-forwarded-for (#3531) are deliberately NOT fallbacks.
  //
  // Trim each header value before falling through — a whitespace-only
  // cf-connecting-ip would otherwise short-circuit past x-real-ip.
  const cf = (request.headers.get('cf-connecting-ip') ?? '').trim();
  const xr = (request.headers.get('x-real-ip') ?? '').trim();
  if (cf && cfTransitProven(request)) return cf;
  return xr || UNKNOWN_CLIENT_IP;
}

function tooManyRequestsResponse(limit: number, reset: number, corsHeaders: Record<string, string>): Response {
  return new Response(JSON.stringify({ error: 'Too many requests' }), {
    status: 429,
    headers: {
      'Content-Type': 'application/json',
      'X-RateLimit-Limit': String(limit),
      'X-RateLimit-Remaining': '0',
      'X-RateLimit-Reset': String(reset),
      'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
      ...corsHeaders,
    },
  });
}

function rateLimitDegradedResponse(corsHeaders: Record<string, string>): Response {
  return new Response(JSON.stringify({ error: 'Rate-limit service temporarily unavailable' }), {
    status: 503,
    headers: {
      'Content-Type': 'application/json',
      ...RATE_LIMIT_DEGRADED_HEADERS,
      ...corsHeaders,
    },
  });
}

export interface RateLimitOptions {
  /**
   * When true and Redis is unavailable, return a 503 (with the
   * `X-RateLimit-Mode: degraded` marker) instead of allowing the request
   * through. Pass `true` for endpoints where the rate-limit IS the abuse
   * defence (LLM, checkout, lead capture). Default `false` keeps the
   * availability-first posture for general traffic so a Redis blip doesn't
   * black-hole the whole site. (#3531)
   */
  failClosed?: boolean;
}

export async function checkRateLimit(request: Request, corsHeaders: Record<string, string>, opts: RateLimitOptions = {}): Promise<Response | null> {
  const rl = getRatelimit();
  if (!rl) {
    if (opts.failClosed) {
      logRateLimitDegraded('checkRateLimit:missing-config', new Error('Upstash Redis is not configured'));
      return rateLimitDegradedResponse(corsHeaders);
    }
    return null;
  }

  const ip = getClientIp(request);

  try {
    const { success, limit, reset } = await rl.limit(ip);

    if (!success) {
      return tooManyRequestsResponse(limit, reset, corsHeaders);
    }

    return null;
  } catch (err) {
    logRateLimitDegraded('checkRateLimit', err);
    if (opts.failClosed) return rateLimitDegradedResponse(corsHeaders);
    return null;
  }
}

// --- Per-endpoint rate limiting ---

interface EndpointRatePolicy {
  limit: number;
  window: Duration;
}

// Exported so scripts/enforce-rate-limit-policies.mjs can import it directly
// (#3278) instead of regex-parsing this file. Internal callers should keep
// using checkEndpointRateLimit / hasEndpointRatePolicy below — the export is
// for tooling, not new runtime callers.
export const ENDPOINT_RATE_POLICIES: Record<string, EndpointRatePolicy> = {
  // LLM article summarization is Pro-gated, but still needs a scoped,
  // fail-closed budget so Redis degradation cannot silently lift the
  // per-endpoint spend control.
  '/api/news/v1/summarize-article': { limit: 30, window: '60 s' },
  '/api/news/v1/summarize-article-cache': { limit: 3000, window: '60 s' },
  '/api/intelligence/v1/classify-event': { limit: 600, window: '60 s' },
  // LLM-backed situational deduction (imports callLlmReasoning) can drive
  // provider spend on cache misses, so it must fail closed on Redis outage
  // rather than inherit the global fail-open fallback. Mirror the sibling
  // classify-event budget (same limit/window) — both are AI-backed Intelligence
  // RPCs. (#4676)
  '/api/intelligence/v1/deduct-situation': { limit: 600, window: '60 s' },
  // Batch humanitarian-summary fans out to the external HAPI (humdata) provider
  // on cache miss — up to 25 countries per request, 5 concurrent upstream
  // fetches. Batch aircraft-details fans out to the external Wingbits provider —
  // up to 10 ICAO24 lookups per request. Both proxy external providers, so keep
  // them at the same 30/min budget as the other provider-proxy routes
  // (sanctions lookup / resilience ranking); conservative because a single
  // request already amplifies into many upstream calls. (#4676)
  '/api/conflict/v1/get-humanitarian-summary-batch': { limit: 30, window: '60 s' },
  '/api/military/v1/get-aircraft-details-batch': { limit: 30, window: '60 s' },
  // Generic batch fan-out: one request re-dispatches up to 20 gateway GETs, so
  // cap the multiplier at the same 30/min budget as the other batch routes.
  '/api/batch/v1/execute': { limit: 30, window: '60 s' },
  // Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
  // that budget now that LookupSanctionEntity proxies OpenSanctions live.
  '/api/sanctions/v1/lookup-sanction-entity': { limit: 30, window: '60 s' },
  // Lead capture: preserve the 3/hr and 5/hr budgets from legacy api/contact.js
  // and api/register-interest.js. Lower limits than normal IP rate limit since
  // these hit Convex + Resend per request.
  '/api/leads/v1/submit-contact': { limit: 3, window: '1 h' },
  '/api/leads/v1/register-interest': { limit: 5, window: '1 h' },
  // Scenario engine: legacy /api/scenario/v1/run capped at 10 jobs/min/IP via
  // inline Upstash INCR. Gateway now enforces the same budget with per-IP
  // keying in checkEndpointRateLimit.
  '/api/scenario/v1/run-scenario': { limit: 10, window: '60 s' },
  // #3734: trigger-simulation PRO endpoint, same shape as run-scenario.
  // Per-IP keying matches run-scenario's production behavior. Pro-identity
  // primitive deferred (checkScopedRateLimit available if needed).
  '/api/forecast/v1/trigger-simulation': { limit: 10, window: '60 s' },
  // Live tanker map (Energy Atlas): one user with 6 chokepoints × 1 call/min
  // = 6 req/min/IP base load. 60/min headroom covers tab refreshes + zoom
  // pans within a single user without flagging legitimate traffic.
  '/api/maritime/v1/get-vessel-snapshot': { limit: 60, window: '60 s' },
  // Country Resilience ranking can synchronously warm the full country table
  // on cold/stale cache paths; keep it well below the global 600/min fallback.
  '/api/resilience/v1/get-resilience-ranking': { limit: 30, window: '60 s' },
  // #3805 / PR #3821: MCP proxy is a top-level Vercel Edge Function in
  // `api/mcp-proxy.ts` (registered as `external-protocol` in
  // api/api-route-exceptions.json — JSON-RPC shape dictated by the MCP spec),
  // so it does NOT flow through the gateway and `checkEndpointRateLimit`
  // never fires for it. The handler reads this policy and enforces it
  // in-handler via `checkScopedRateLimit` — keeping the registry as the
  // single source of truth so future audit additions (and the
  // enforce-rate-limit-policies lint) see the endpoint. The audit script
  // resolves edge-function paths via api/api-route-exceptions.json instead
  // of the OpenAPI specs.
  '/api/mcp-proxy': { limit: 30, window: '60 s' },
  // A2A concierge endpoint (`api/a2a.ts`, external-protocol exception —
  // JSON-RPC shape dictated by the A2A spec, served at /a2a). Anonymous and
  // quota-free by design (routes over the public tool catalog + public
  // freshness envelope only), so the per-IP minute limit is the whole abuse
  // defence; 60/min mirrors the MCP public-method posture. Enforced
  // in-handler via `checkScopedRateLimit`, same pattern as /api/mcp-proxy.
  '/api/a2a': { limit: 60, window: '60 s' },
  // NLWeb /ask endpoint (`api/ask.ts`, external-protocol exception — request/
  // response shape dictated by the NLWeb spec, served at /ask). Same anonymous
  // cheap-catalog posture as /api/a2a, same in-handler enforcement.
  '/api/ask': { limit: 60, window: '60 s' },
};

interface RateLimitPolicyDecision {
  reason: string;
}

// Repo-native guardrail for routes where the rate-limit is part of the abuse
// defence. scripts/enforce-rate-limit-policies.mjs fails if any route listed
// here can drift back to the gateway's availability-first global fallback.
export const FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED: Record<string, RateLimitPolicyDecision> = {
  '/api/news/v1/summarize-article': {
    reason: 'LLM-backed summarization can drive provider spend on cache misses.',
  },
  '/api/intelligence/v1/classify-event': {
    reason: 'AI classification performs expensive provider-backed analysis.',
  },
  '/api/intelligence/v1/deduct-situation': {
    reason: 'LLM-backed situational deduction can drive provider spend on cache misses.',
  },
  '/api/conflict/v1/get-humanitarian-summary-batch': {
    reason: 'Batch summary fans out to the external HAPI (humdata) provider on cache miss.',
  },
  '/api/military/v1/get-aircraft-details-batch': {
    reason: 'Batch enrichment fans out to the external Wingbits provider on cache miss.',
  },
  '/api/batch/v1/execute': {
    reason: 'Generic batch fan-out multiplies one request into up to 20 gateway sub-requests.',
  },
  '/api/sanctions/v1/lookup-sanction-entity': {
    reason: 'Live sanctions lookup proxies an external provider.',
  },
  '/api/leads/v1/submit-contact': {
    reason: 'Lead capture writes to Convex and sends email.',
  },
  '/api/leads/v1/register-interest': {
    reason: 'Lead capture writes to Convex and sends email.',
  },
  '/api/scenario/v1/run-scenario': {
    reason: 'Scenario runs are mutation-like jobs with a historical 10/min cap.',
  },
  '/api/forecast/v1/trigger-simulation': {
    reason: 'Forecast simulation trigger starts expensive backend work.',
  },
  '/api/maritime/v1/get-vessel-snapshot': {
    reason: 'Live vessel snapshots can generate high-frequency upstream load.',
  },
  '/api/resilience/v1/get-resilience-ranking': {
    reason: 'Cold/stale cache paths can synchronously warm the full country table.',
  },
};

// Explicit examples of read-only gateway routes where the global per-IP
// fallback remains acceptable during Redis degradation. New expensive/provider
// routes should not be added here; add them to ENDPOINT_RATE_POLICIES and
// FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED instead.
export const GLOBAL_RATE_LIMIT_FALLBACK_READ_ROUTES: Record<string, RateLimitPolicyDecision> = {
  '/api/aviation/v1/list-airport-delays': {
    reason: 'Read-only cache-backed airport delay listing; availability-first fallback is acceptable.',
  },
};

// Explicit allow-list of NON-GET (post/put/patch/delete) gateway routes that are
// permitted to inherit the global availability-first fallback during a Redis
// outage instead of declaring an ENDPOINT_RATE_POLICIES entry. The audit
// scripts/enforce-rate-limit-policies.mjs fails CI if any generated non-GET
// route is neither in ENDPOINT_RATE_POLICIES nor listed here — so a newly added
// expensive/mutation route can no longer silently fail open. Every entry MUST
// carry a justification for why fail-open is safe for that route. When a route
// becomes provider-backed / spend-bearing, move it to ENDPOINT_RATE_POLICIES +
// FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED instead of keeping it here. (#4676)
export const RATE_LIMIT_MUTATION_FALLBACK_EXEMPT: Record<string, RateLimitPolicyDecision> = {
  '/api/economic/v1/get-fred-series-batch': {
    reason:
      'Read-only despite POST shape: reads seeded FRED data from the Redis seed cache only; all external FRED API calls happen in the Railway seed job, so a cache miss never fans out to an external provider.',
  },
  '/api/infrastructure/v1/record-baseline-snapshot': {
    reason:
      'Redis-only write (setCachedJson) with no external provider or LLM call; if Redis is degraded the write itself cannot land, so the fail-open fallback carries no spend/abuse risk.',
  },
  '/api/v2/shipping/webhooks': {
    reason:
      'Webhook registration is API-key authenticated (validateApiKey) and premium-gated before any work, so unauthenticated abuse is already blocked; the handler only writes to Redis, with no external provider or LLM spend.',
  },
};

const endpointLimiters = new Map<string, Ratelimit>();

function getEndpointRatelimit(pathname: string): Ratelimit | null {
  const policy = ENDPOINT_RATE_POLICIES[pathname];
  if (!policy) return null;

  const cached = endpointLimiters.get(pathname);
  if (cached) return cached;

  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) return null;

  const rl = new Ratelimit({
    redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }),
    limiter: Ratelimit.slidingWindow(policy.limit, policy.window),
    prefix: 'rl:ep',
    analytics: false,
  });
  endpointLimiters.set(pathname, rl);
  return rl;
}

export function hasEndpointRatePolicy(pathname: string): boolean {
  return pathname in ENDPOINT_RATE_POLICIES;
}

export async function checkEndpointRateLimit(request: Request, pathname: string, corsHeaders: Record<string, string>, opts: RateLimitOptions = {}): Promise<Response | null> {
  if (!hasEndpointRatePolicy(pathname)) return null;

  const rl = getEndpointRatelimit(pathname);
  if (!rl) {
    const failClosed = opts.failClosed ?? true;
    if (failClosed) {
      logRateLimitDegraded(`checkEndpointRateLimit:${pathname}:missing-config`, new Error('Upstash Redis is not configured'));
      return rateLimitDegradedResponse(corsHeaders);
    }
    return null;
  }

  const ip = getClientIp(request);

  try {
    const { success, limit, reset } = await rl.limit(`${pathname}:${ip}`);

    if (!success) {
      return tooManyRequestsResponse(limit, reset, corsHeaders);
    }

    return null;
  } catch (err) {
    logRateLimitDegraded(`checkEndpointRateLimit:${pathname}`, err);
    // Per-endpoint policies exist precisely because the limit IS the abuse
    // defence — an LLM endpoint or a 3/hr lead-capture endpoint is the
    // worst place to silently fall through during a Redis outage. Default
    // to fail-closed; callers can opt out via opts.failClosed = false.
    const failClosed = opts.failClosed ?? true;
    if (failClosed) return rateLimitDegradedResponse(corsHeaders);
    return null;
  }
}

// --- In-handler scoped rate limits ---
//
// Handlers that need a per-subscope cap *in addition to* the gateway-level
// endpoint policy (e.g. a tighter budget for one request variant) use this
// helper. Gateway's checkEndpointRateLimit still runs first — this is a
// second stage.

const scopedLimiters = new Map<string, Ratelimit>();

function getScopedRatelimit(scope: string, limit: number, window: Duration): Ratelimit | null {
  const cacheKey = `${scope}|${limit}|${window}`;
  const cached = scopedLimiters.get(cacheKey);
  if (cached) return cached;

  const url = process.env.UPSTASH_REDIS_REST_URL;
  const token = process.env.UPSTASH_REDIS_REST_TOKEN;
  if (!url || !token) return null;

  const rl = new Ratelimit({
    redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }),
    limiter: Ratelimit.slidingWindow(limit, window),
    prefix: 'rl:scope',
    analytics: false,
  });
  scopedLimiters.set(cacheKey, rl);
  return rl;
}

export interface ScopedRateLimitResult {
  allowed: boolean;
  limit: number;
  reset: number;
  /**
   * True when Redis was unreachable and the helper fell back to the
   * fail-open default. Callers that need fail-closed semantics should
   * gate on this — e.g. lead-capture handlers can refuse the write to
   * preserve the 3/hr budget across a Redis blip. (#3531)
   */
  degraded: boolean;
}

/**
 * Returns whether the request is under the scoped budget. `scope` is an
 * opaque namespace (e.g. `${pathname}#desktop`); `identifier` is usually the
 * client IP but can be any stable caller identifier. Fail-open on Redis errors
 * to stay consistent with checkRateLimit / checkEndpointRateLimit semantics,
 * but the `degraded` flag lets callers escalate to fail-closed locally
 * (#3531). The Redis error itself is logged once per call so silent bypass
 * windows are visible in logs / Sentry.
 */
export async function checkScopedRateLimit(scope: string, limit: number, window: Duration, identifier: string): Promise<ScopedRateLimitResult> {
  const rl = getScopedRatelimit(scope, limit, window);
  if (!rl) {
    logScopedRateLimitMissingConfig(scope);
    return { allowed: true, limit, reset: 0, degraded: true };
  }
  try {
    const result = await rl.limit(`${scope}:${identifier}`);
    return {
      allowed: result.success,
      limit: result.limit,
      reset: result.reset,
      degraded: false,
    };
  } catch (err) {
    logRateLimitDegraded(`checkScopedRateLimit:${scope}`, err);
    return { allowed: true, limit, reset: 0, degraded: true };
  }
}
