import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

/**
 * The claim token: how an audit started by nobody becomes "my audit".
 *
 * The job is created before any account exists, so it cannot be keyed on a user
 * id. It is keyed on a secret the browser holds instead, and at the first
 * sign-in every job carrying that secret is handed to the new user.
 *
 * Values are signed rather than encrypted: nothing in them is secret from the
 * visitor — they *are* the visitor's — but a forged one would let somebody claim
 * another visitor's audit, so the value has to be one we issued.
 */

export const CLAIM_COOKIE = "hsc_claim";

/** A month is long enough to sign in later, short enough to expire. */
const CLAIM_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;

/**
 * The sign-in intent: the address this browser asked a magic link for.
 *
 * Holding a claim cookie cannot be the only condition for ownership, because a
 * cookie leaves with any top-level navigation — including a link an attacker
 * requested for their *own* address and then sent to a visitor who had just run
 * an audit ("voici votre rapport"). The click would create the attacker's
 * session while carrying the visitor's claim, and the audits would change
 * hands. Sealing the address asked for, and claiming only when the session that
 * turns up is that address, ties ownership to the browser that asked rather
 * than to the browser that clicked.
 */
export const INTENT_COOKIE = "hsc_intent";

/** The same fifteen minutes as the magic link: the intent outlives nothing. */
export const INTENT_MAX_AGE_SECONDS = 15 * 60;

function secret(): string {
  const value = process.env.BETTER_AUTH_SECRET;
  if (!value) throw new Error("BETTER_AUTH_SECRET is not set — see .env.example");
  return value;
}

function sign(payload: string): string {
  return createHmac("sha256", secret()).update(payload).digest("base64url");
}

/** A payload and its signature, so we can tell ours apart from anyone else's. */
function seal(payload: string): string {
  return `${payload}.${sign(payload)}`;
}

/**
 * The payload inside a value we signed, or null for anything else.
 *
 * The cut is on the last dot: a signature is base64url and never contains one,
 * so whatever the payload holds stays intact.
 */
function unseal(value: string | undefined | null): string | null {
  if (!value) return null;
  const cut = value.lastIndexOf(".");
  if (cut <= 0) return null;
  const payload = value.slice(0, cut);
  const given = Buffer.from(value.slice(cut + 1));
  const want = Buffer.from(sign(payload));
  // Lengths differ on a mangled cookie, and timingSafeEqual throws on that.
  if (given.length !== want.length) return null;
  return timingSafeEqual(given, want) ? payload : null;
}

export function newClaimToken(): string {
  return randomBytes(24).toString("base64url");
}

/** The cookie value: the claim token, signed. */
export function sealClaim(token: string): string {
  return seal(token);
}

/** The token inside a value we signed, or null for anything else. */
export function openClaim(value: string | undefined | null): string | null {
  return unseal(value);
}

/** The intent value: when it stops counting, and for whom. */
export function sealIntent(email: string, now: number = Date.now()): string {
  return seal(`${now + INTENT_MAX_AGE_SECONDS * 1000}:${email.trim().toLowerCase()}`);
}

/** The address a live intent was issued for, or null if forged or expired. */
export function openIntent(
  value: string | undefined | null,
  now: number = Date.now(),
): string | null {
  const payload = unseal(value);
  if (!payload) return null;
  const cut = payload.indexOf(":");
  if (cut <= 0) return null;
  const expiresAt = Number(payload.slice(0, cut));
  if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
  return payload.slice(cut + 1) || null;
}

type CookieOptions = {
  httpOnly: true;
  secure: boolean;
  sameSite: "none" | "lax";
  path: string;
  maxAge: number;
};

/**
 * The claim cookie as the API sets it, answering a form hosted on one of the
 * other two sites: third-party, so `SameSite=None; Secure` or no browser would
 * store it at all.
 *
 * Most browsers will drop it anyway — Safari blocks third-party cookies, Firefox
 * partitions them under the calling site — which is why it is not the only way
 * the token travels. The response body carries it too, and `/auth` sets it again
 * first-party with `firstPartyCookieOptions`. This one still works on Chrome and
 * costs nothing.
 */
export function claimCookieOptions(): CookieOptions {
  const crossSite = process.env.NODE_ENV === "production";
  return {
    httpOnly: true,
    secure: crossSite,
    sameSite: crossSite ? "none" : "lax",
    path: "/",
    maxAge: CLAIM_MAX_AGE_SECONDS,
  };
}

/**
 * A cookie this site sets on itself. `Lax` is enough — it still rides the
 * top-level navigation the magic link is — and, unlike `SameSite=None`, no
 * browser refuses it or files it under someone else's site.
 */
export function firstPartyCookieOptions(maxAge: number = CLAIM_MAX_AGE_SECONDS): CookieOptions {
  return {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge,
  };
}

/** One cookie's raw value, straight out of a `Cookie:` header. */
function fromCookieHeader(header: string | null | undefined, name: string): string | null {
  if (!header) return null;
  for (const part of header.split(";")) {
    const cut = part.indexOf("=");
    if (cut < 0) continue;
    if (part.slice(0, cut).trim() !== name) continue;
    return decodeURIComponent(part.slice(cut + 1).trim());
  }
  return null;
}

/** Read the claim token straight out of a `Cookie:` header. */
export function claimFromCookieHeader(header: string | null | undefined): string | null {
  return openClaim(fromCookieHeader(header, CLAIM_COOKIE));
}

/** Read the address this browser asked a link for, out of a `Cookie:` header. */
export function intentFromCookieHeader(
  header: string | null | undefined,
  now: number = Date.now(),
): string | null {
  return openIntent(fromCookieHeader(header, INTENT_COOKIE), now);
}

/**
 * The rate limit counts requests per address; it never needs to know the
 * address. Keyed HMAC rather than a bare hash, because the IPv4 space is small
 * enough to enumerate in seconds against an unkeyed digest.
 */
export function hashIp(ip: string): string {
  return createHmac("sha256", secret()).update(`ip:${ip}`).digest("hex");
}

/**
 * The caller's address, as the infrastructure in front reports it.
 *
 * `CF-Connecting-IP` first: the deployment sits behind a proxied Cloudflare
 * record, and that header is written by the edge, never by the caller — as long
 * as the origin only accepts Cloudflare's connections, which is a firewall's job
 * and not this function's.
 *
 * Then, and only as a fallback, the *rightmost* entry of `X-Forwarded-For`.
 * Every proxy appends to the right, so the leftmost entry is precisely the one
 * the client wrote itself: reading it would let `curl -H 'X-Forwarded-For: …'`
 * mint a fresh identity per request and walk straight through the hourly limit.
 * The rightmost is the peer our own proxy saw. Behind a longer chain it is a
 * proxy's address rather than the visitor's, which makes the limit coarser —
 * the direction that refuses too much, not too little.
 */
export function clientIp(headers: Headers): string {
  const edge = headers.get("cf-connecting-ip")?.trim();
  if (edge) return edge;

  const forwarded = headers.get("x-forwarded-for");
  if (forwarded) {
    const chain = forwarded
      .split(",")
      .map((entry) => entry.trim())
      .filter((entry) => entry !== "");
    const nearest = chain[chain.length - 1];
    if (nearest) return nearest;
  }

  return headers.get("x-real-ip")?.trim() || "unknown";
}
