/**
 * JWT verification, with HS256 as the primary path.
 *
 * That ordering is a correction from verified fact rather than a preference. The
 * issuer this runs against — the fleet's Supabase — signs `{"alg":"HS256"}`, so
 * a JWKS-only implementation would reject every real token that ever arrived.
 * Asymmetric verification is here and works, but it sits behind
 * `TERA_AUTH_JWT_VERIFY=jwks`. CONTRACT.md §6.
 *
 * Written against `node:crypto` rather than a JWT library on purpose: HS256 is
 * an HMAC and a handful of claim checks, and this service's dependency list is
 * short enough to read in one breath. The parts that are easy to get wrong —
 * verifying before parsing, comparing signatures in constant time, rejecting
 * `alg: none` and rejecting an algorithm the operator did not ask for — are all
 * below and all deliberate.
 */

import { createHmac, createPublicKey, timingSafeEqual, verify as cryptoVerify } from "node:crypto";
import type { KeyObject } from "node:crypto";
import { getJson } from "../http.ts";
import type { AuthConfig } from "../config.ts";

export interface Claims {
  sub?: string;
  exp?: number;
  nbf?: number;
  iss?: string;
  aud?: string | string[];
  [claim: string]: unknown;
}

/** Sixty seconds of tolerance for clocks that disagree, which they do. */
const CLOCK_SKEW_SECONDS = 60;

export async function verifyJwt(token: string, config: AuthConfig): Promise<Claims | null> {
  const parts = token.split(".");
  if (parts.length !== 3) return null;
  const [headerPart, payloadPart, signaturePart] = parts;
  if (headerPart === undefined || payloadPart === undefined || signaturePart === undefined) {
    return null;
  }

  const header = decodeJson<{ alg?: string; kid?: string }>(headerPart);
  if (header === null) return null;

  const signed = `${headerPart}.${payloadPart}`;
  const signature = Buffer.from(signaturePart, "base64url");

  const signatureOk =
    config.jwtVerify === "jwks"
      ? await verifyAsymmetric(header, signed, signature, config)
      : verifyHs256(header.alg, signed, signature, config.jwtSecret);
  if (!signatureOk) return null;

  // Only now is the payload worth reading. Parsing claims out of an unverified
  // token and checking the signature afterwards is how `alg: none` bugs happen.
  const claims = decodeJson<Claims>(payloadPart);
  if (claims === null) return null;
  return claimsValid(claims, config) ? claims : null;
}

function verifyHs256(
  alg: string | undefined,
  signed: string,
  signature: Buffer,
  secret: string,
): boolean {
  // Pinned, not merely checked against a list: an operator who configured a
  // shared secret has said what algorithm they expect, and accepting anything
  // else here is the classic confusion attack.
  if (alg !== "HS256" || secret === "") return false;
  const expected = createHmac("sha256", secret).update(signed).digest();
  if (expected.length !== signature.length) return false;
  return timingSafeEqual(expected, signature);
}

// ---- JWKS -----------------------------------------------------------------

interface Jwk {
  kid?: string;
  kty?: string;
  alg?: string;
  [field: string]: unknown;
}

const ASYMMETRIC: Record<string, { algorithm: string; ieeeP1363: boolean }> = {
  RS256: { algorithm: "RSA-SHA256", ieeeP1363: false },
  RS384: { algorithm: "RSA-SHA384", ieeeP1363: false },
  RS512: { algorithm: "RSA-SHA512", ieeeP1363: false },
  ES256: { algorithm: "SHA256", ieeeP1363: true },
  ES384: { algorithm: "SHA384", ieeeP1363: true },
};

const keyCache = new Map<string, KeyObject>();
let keysFetchedAt = 0;

async function verifyAsymmetric(
  header: { alg?: string; kid?: string },
  signed: string,
  signature: Buffer,
  config: AuthConfig,
): Promise<boolean> {
  const alg = header.alg ?? "";
  const spec = ASYMMETRIC[alg];
  if (spec === undefined) return false;

  const key = await resolveKey(header.kid ?? "", config.jwksUrl);
  if (key === null) return false;

  try {
    return cryptoVerify(spec.algorithm, Buffer.from(signed), {
      key,
      // ECDSA signatures in a JWT are the raw r‖s pair, not the DER sequence
      // OpenSSL expects. Without this, every ES256 token fails to verify.
      ...(spec.ieeeP1363 ? { dsaEncoding: "ieee-p1363" as const } : {}),
    }, signature);
  } catch {
    return false;
  }
}

async function resolveKey(kid: string, jwksUrl: string): Promise<KeyObject | null> {
  const cached = keyCache.get(kid);
  if (cached !== undefined) return cached;

  // Refetch on an unseen kid, but not more than once a minute — a rotated key
  // should be picked up quickly, and a token with a junk kid should not be able
  // to turn one request into one upstream fetch.
  if (Date.now() - keysFetchedAt < 60_000) return null;
  keysFetchedAt = Date.now();

  const jwks = await getJson<{ keys?: Jwk[] }>(jwksUrl);
  for (const jwk of jwks?.keys ?? []) {
    if (typeof jwk.kid !== "string") continue;
    try {
      keyCache.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" }));
    } catch {
      // A key this build of Node cannot represent is not a reason to drop the rest.
    }
  }
  return keyCache.get(kid) ?? null;
}

// ---- Claims ---------------------------------------------------------------

function claimsValid(claims: Claims, config: AuthConfig): boolean {
  const now = Math.floor(Date.now() / 1000);
  if (typeof claims.exp === "number" && claims.exp + CLOCK_SKEW_SECONDS < now) return false;
  if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_SECONDS > now) return false;
  if (config.issuer !== "" && claims.iss !== config.issuer) return false;
  if (config.audience !== "") {
    const aud = claims.aud;
    const matches = Array.isArray(aud) ? aud.includes(config.audience) : aud === config.audience;
    if (!matches) return false;
  }
  return true;
}

function decodeJson<T>(part: string): T | null {
  try {
    return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
  } catch {
    return null;
  }
}
