import { and, count, eq, gte } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { auditJob, db } from "@/db";
import { CLAIM_COOKIE, claimCookieOptions, clientIp, hashIp, newClaimToken, openClaim, sealClaim } from "@/lib/claim";
import { resolveAuditTarget, settleWwwRedirect } from "@/lib/public-target";
import { corsHeaders, isAllowedOrigin, siteFromOrigin } from "@/lib/sites";
import { verifyTurnstile } from "@/lib/turnstile";

/**
 * Three an hour, per address. Enough to retry a typo, not enough to mine.
 *
 * Raised, never removed, in development: the count below still runs, so the
 * guard that ships is the guard we exercise. An `if (dev) skip` here would leave
 * the query untested until the day it matters.
 */
const JOBS_PER_HOUR = positiveIntOr(process.env.AUDIT_JOBS_PER_HOUR, 3);

/**
 * A typo must not disarm the guard.
 *
 * `Number("trois")` is NaN, and `count >= NaN` is false for every count — so a
 * mangled value would not raise the ceiling, it would remove it, silently and
 * in production.
 */
function positiveIntOr(raw: string | undefined, fallback: number): number {
  const parsed = Number(raw);
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

type Body = { url?: unknown; turnstileToken?: unknown };

function refuse(status: number, reason: string, origin: string | null): NextResponse {
  return NextResponse.json(
    { error: reason },
    { status, headers: isAllowedOrigin(origin) ? corsHeaders(origin) : {} },
  );
}

/** The browser's preflight, for a form hosted on one of the other two sites. */
export function OPTIONS(request: NextRequest): NextResponse {
  const origin = request.headers.get("origin");
  if (!isAllowedOrigin(origin)) return new NextResponse(null, { status: 403 });
  return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
}

/**
 * Queue an audit.
 *
 * The four guards run in this order on purpose, cheapest and most decisive
 * first: a header comparison, then one request to Cloudflare, then one count
 * against an index, and only then a DNS lookup. Nothing is written, and no
 * lookup is paid for, until a request has passed all three of the ones that do
 * not depend on what the visitor typed.
 */
export async function POST(request: NextRequest): Promise<NextResponse> {
  // 1. Origin. A browser sets it and script cannot forge it, so this keeps our
  //    own three forms working and every other page's out.
  const origin = request.headers.get("origin");
  if (!isAllowedOrigin(origin)) {
    return refuse(403, "this form is not hosted on a site we serve", origin);
  }

  let body: Body;
  try {
    body = (await request.json()) as Body;
  } catch {
    return refuse(400, "send a JSON body", origin);
  }
  const url = typeof body.url === "string" ? body.url : "";
  const turnstileToken = typeof body.turnstileToken === "string" ? body.turnstileToken : "";

  const ip = clientIp(request.headers);

  // 2. Turnstile. A token is single-use and short-lived; without one this is a
  //    scraper, whatever the Origin header says.
  if (!(await verifyTurnstile(turnstileToken, ip))) {
    return refuse(403, "the anti-robot check did not pass — reload and try again", origin);
  }

  // 3. The per-address limit, counted in the same table the jobs live in. No
  //    second store to run, and a restart cannot forget what it counted.
  const ipHash = hashIp(ip);
  const since = new Date(Date.now() - 60 * 60 * 1000);
  const [recent] = await db
    .select({ n: count() })
    .from(auditJob)
    .where(and(eq(auditJob.ipHash, ipHash), gte(auditJob.createdAt, since)));
  if ((recent?.n ?? 0) >= JOBS_PER_HOUR) {
    return refuse(429, `${JOBS_PER_HOUR} audits par heure — revenez un peu plus tard`, origin);
  }

  // 4. The target. Last because it costs a DNS round trip, and because its
  //    answer is the one worth spelling out to the visitor.
  const target = await resolveAuditTarget(url);
  if (!target.ok) return refuse(400, target.reason, origin);

  // Which of the two names actually serves the site. Only after the guards
  // above: this is the one step that puts a request on the wire, so it happens
  // once a caller has proved it is a browser on one of our pages, within its
  // hourly ration, asking about a target we have already vetted.
  const site = await settleWwwRedirect(target.origin);

  // Reuse the browser's existing claim token when it has one, so a visitor who
  // runs two audits before signing in gets both, not just the last.
  const existing = openClaim(request.cookies.get(CLAIM_COOKIE)?.value);
  const claimToken = existing ?? newClaimToken();

  const [job] = await db
    .insert(auditJob)
    .values({
      target: url.trim(),
      origin: site,
      site: siteFromOrigin(origin),
      status: "queued",
      claimToken,
      ipHash,
    })
    .returning({ id: auditJob.id });

  if (!job) return refuse(500, "the audit could not be queued", origin);

  // The token travels twice, because the cookie alone does not arrive.
  //
  // `Set-Cookie` is third-party here: Safari drops it, Firefox files it under
  // the calling site, and it is then missing on `seo.seoinsight.app` when the
  // magic link lands — the visitor leaves an email and reaches an empty
  // dashboard, with no way back to the report since ownership is a `WHERE`.
  // So the sealed token is in the body as well: the form carries it to
  // `/auth?claim=…`, which sets the same cookie first-party.
  //
  // Handing it to the browser changes nothing: it was already that browser's
  // secret, it is signed so it cannot be forged into someone else's, and only
  // the three allowed origins can read this response.
  const sealed = sealClaim(claimToken);
  const response = NextResponse.json(
    { id: job.id, claim: sealed },
    { headers: corsHeaders(origin) },
  );
  response.cookies.set(CLAIM_COOKIE, sealed, claimCookieOptions());
  return response;
}
