/**
 * Cloudflare Turnstile verification gate.
 *
 * Closes the account-enumeration oracle on the credential/enumeration endpoints
 * (login-email, register, magic-link/send) by requiring a valid Turnstile token
 * in addition to the existing per-IP rate limit.
 *
 * Fail-mode split (locked decision):
 *   - Missing / invalid / expired / duplicate token → siteverify returns
 *     HTTP 200 { success: false, ... } → fail CLOSED → 403 TURNSTILE_FAILED.
 *     This is the whole point of the gate.
 *   - siteverify itself unreachable (fetch throws / non-2xx / timeout / bad JSON)
 *     → fail CLOSED → 503 TURNSTILE_FAILED. 503 (not 403) distinguishes a dependency
 *     outage from a client rejection in logs/metrics. Phone-OTP login does NOT route
 *     through this gate, so only email/register/magic-link degrade during a rare
 *     Cloudflare-siteverify outage. A bounded fail-open would need a DO/KV circuit
 *     breaker — the exact SPOF arch (A) deliberately avoids.
 *
 * Security: the Turnstile token and secret are NEVER logged. remoteip passed to
 * siteverify is the RAW client IP (siteverify needs the real IP, not the hash).
 */

import { respondError } from '@/server/api/error-envelope.js';
import { captureCaught } from '@/server/observability/capture.server';
import { verifyE2eProof } from '@/server/security/e2e-proof';
import { getIp } from './ip.js';
import type { MultidealEnv } from '@/server/env';

/** Cloudflare Turnstile server-side verification endpoint. */
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

/** Network timeout for the siteverify call. */
const SITEVERIFY_TIMEOUT_MS = 4000;

/**
 * Result of a Turnstile siteverify call.
 *
 * - `ok`          — siteverify returned HTTP 200 { success: true }.
 * - `rejected`    — siteverify returned HTTP 200 { success: false, ... }; carries
 *                   the CF error-codes for diagnostics (never the token).
 * - `unavailable` — siteverify was unreachable (fetch threw, non-2xx, timeout, or
 *                   the response body was not parseable JSON).
 */
export type TurnstileResult =
  | { outcome: 'ok' }
  | { outcome: 'rejected'; codes: string[] }
  | { outcome: 'unavailable' };

/** Shape of the Cloudflare siteverify JSON response we consume. */
interface SiteverifyResponse {
  success: boolean;
  'error-codes'?: string[];
}

/**
 * Verify a Turnstile token against Cloudflare's siteverify endpoint.
 *
 * @param secret   The TURNSTILE_SECRET_KEY binding value.
 * @param token    The Turnstile response token from the client widget.
 * @param remoteip Optional raw client IP (NOT a hash). Omitted when unknown.
 * @returns A discriminated TurnstileResult. Never throws — network/parse
 *          failures collapse to `{ outcome: 'unavailable' }`.
 */
export async function verifyTurnstile(
  secret: string,
  token: string,
  remoteip?: string,
): Promise<TurnstileResult> {
  const form = new URLSearchParams();
  form.set('secret', secret);
  form.set('response', token);
  if (remoteip) form.set('remoteip', remoteip);

  let res: Response;
  try {
    res = await fetch(SITEVERIFY_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: form,
      signal: AbortSignal.timeout(SITEVERIFY_TIMEOUT_MS),
    });
  } catch (err) {
    // fetch threw (network error or AbortSignal timeout) → unavailable; applyTurnstileFor decides policy (fail-closed).
    captureCaught(err, { scope: 'turnstile.verify', severity: 'warning' });
    return { outcome: 'unavailable' };
  }

  if (!res.ok) {
    // Non-2xx from siteverify → unavailable discriminant; applyTurnstileFor decides policy (fail-closed).
    captureCaught(new Error(`turnstile siteverify non-2xx: ${res.status}`), {
      scope: 'turnstile.verify',
      severity: 'warning',
    });
    return { outcome: 'unavailable' };
  }

  let data: SiteverifyResponse;
  try {
    data = (await res.json()) as SiteverifyResponse;
  } catch (err) {
    // Body was not parseable JSON → unavailable discriminant; applyTurnstileFor decides policy (fail-closed).
    captureCaught(err, { scope: 'turnstile.verify', severity: 'warning' });
    return { outcome: 'unavailable' };
  }

  if (data.success === true) {
    return { outcome: 'ok' };
  }

  return { outcome: 'rejected', codes: data['error-codes'] ?? [] };
}

/**
 * Verify a Turnstile token for an incoming request and produce the gate response.
 *
 * - `rejected`    → returns a 403 TURNSTILE_FAILED Response (fail CLOSED).
 * - `ok`          → returns `null` (proceed).
 * - `unavailable` → returns 503 TURNSTILE_FAILED (fail CLOSED — siteverify outage must
 *                   not reopen the enumeration oracle; phone-OTP login is unaffected).
 *
 * @param request The incoming Request (used to extract the raw client IP).
 * @param env     The Cloudflare runtime env (TURNSTILE_SECRET_KEY binding).
 * @param token   The Turnstile response token from the validated request body.
 * @returns A 403/503 TURNSTILE_FAILED Response when rejected or siteverify is unavailable, else `null`.
 */
export async function applyTurnstileFor(
  request: Request,
  env: MultidealEnv,
  token: string,
  rawBody?: string,
): Promise<Response | null> {
  // E2E headless bypass — time-bound, body-bound proof (see server/security/e2e-proof.ts).
  // PROD-UNREACHABLE: verifyE2eProof returns false when ENVIRONMENT==='production' or E2E_SECRET unset.
  if (await verifyE2eProof(request, env, rawBody)) return null;

  // getIp falls back to '0.0.0.0' when no IP header is present — omit that
  // sentinel rather than sending junk to siteverify (remoteip is optional).
  const ip = getIp(request);
  const remoteip = ip && ip !== '0.0.0.0' ? ip : undefined;

  const result = await verifyTurnstile(env.TURNSTILE_SECRET_KEY, token, remoteip);

  if (result.outcome === 'rejected') {
    // TURNSTILE_FAILED is not in the default status map — pass 403 explicitly.
    return respondError('TURNSTILE_FAILED', 'Verification failed', 403);
  }

  if (result.outcome === 'unavailable') {
    // Fail CLOSED: siteverify outage must not reopen the enumeration oracle. 503 (not 403)
    // distinguishes a dependency outage from a client rejection in logs/metrics. The rate
    // limit is still the floor; phone-OTP login does NOT route through this gate, so only
    // email/register/magic-link degrade during a rare Cloudflare-siteverify outage. A bounded
    // fail-open would need a DO/KV circuit breaker — the exact SPOF arch (A) deliberately avoids.
    return respondError('TURNSTILE_FAILED', 'Verification unavailable', 503);
  }

  // 'ok' → proceed.
  return null;
}
