/**
 * @wrnexus/jwt — dependency-free JSON Web Tokens (HS256) via WebCrypto, plus a
 * bearer-token auth middleware. Pairs with the session auth in @wrnexus/core for
 * stateless (API/mobile) authentication.
 *
 *   const token = await signJwt({ sub: user.id, role: "admin" }, secret, { expiresIn: 3600 });
 *   const claims = await verifyJwt(token, secret); // throws JwtError if invalid/expired
 */

import type { Context, Middleware } from "@wrnexus/core";

export class JwtError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "JwtError";
  }
}

export interface JwtClaims {
  /** Subject (user id). */
  sub?: string;
  /** Issued-at (seconds). */
  iat?: number;
  /** Expiry (seconds). */
  exp?: number;
  /** Not-before (seconds). */
  nbf?: number;
  [key: string]: unknown;
}

export interface SignOptions {
  /** Seconds until expiry (sets `exp`). */
  expiresIn?: number;
  /** Override issued-at (seconds). */
  now?: number;
  issuer?: string;
  audience?: string | string[];
  jwtId?: string;
  /** Key identifier placed in the protected header. */
  keyId?: string;
}

export interface VerifyOptions {
  now?: number;
  clockTolerance?: number;
  issuer?: string;
  audience?: string | string[];
  maxAge?: number;
}

const enc = new TextEncoder();
const MAX_CACHED_KEYS = 32;
const keyCache = new Map<string, Promise<CryptoKey>>();

function b64urlEncode(bytes: Uint8Array): string {
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(str: string): Uint8Array {
  const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
  const bin = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

async function hmacKey(secret: string): Promise<CryptoKey> {
  const cached = keyCache.get(secret);
  if (cached) {
    // Refresh insertion order so frequently used secrets stay hot.
    keyCache.delete(secret);
    keyCache.set(secret, cached);
    return cached;
  }
  const imported = crypto.subtle.importKey(
    "raw",
    enc.encode(secret) as BufferSource,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"],
  );
  keyCache.set(secret, imported);
  if (keyCache.size > MAX_CACHED_KEYS) keyCache.delete(keyCache.keys().next().value!);
  try {
    return await imported;
  } catch (error) {
    keyCache.delete(secret);
    throw error;
  }
}

/** Sign a payload into a JWT (HS256). */
export async function signJwt(
  payload: JwtClaims,
  secret: string,
  options: SignOptions = {},
): Promise<string> {
  const now = options.now ?? Math.floor(Date.now() / 1000);
  const claims: JwtClaims = { iat: now, ...payload };
  if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn;
  if (options.issuer !== undefined) claims.iss = options.issuer;
  if (options.audience !== undefined) claims.aud = options.audience;
  if (options.jwtId !== undefined) claims.jti = options.jwtId;

  const header = b64urlEncode(
    enc.encode(
      JSON.stringify({
        alg: "HS256",
        typ: "JWT",
        ...(options.keyId ? { kid: options.keyId } : {}),
      }),
    ),
  );
  const body = b64urlEncode(enc.encode(JSON.stringify(claims)));
  const data = `${header}.${body}`;
  const sig = new Uint8Array(
    await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(data) as BufferSource),
  );
  return `${data}.${b64urlEncode(sig)}`;
}

/** Verify a JWT and return its claims. Throws `JwtError` on any failure. */
export async function verifyJwt<T extends JwtClaims = JwtClaims>(
  token: string,
  secret: string,
  options: VerifyOptions = {},
): Promise<T> {
  const parts = token.split(".");
  if (parts.length !== 3) throw new JwtError("Malformed token");
  const [header, body, sig] = parts as [string, string, string];

  try {
    const parsed = JSON.parse(new TextDecoder().decode(b64urlDecode(header))) as {
      alg?: unknown;
      typ?: unknown;
    };
    if (parsed.alg !== "HS256" || (parsed.typ !== undefined && parsed.typ !== "JWT")) {
      throw new JwtError("Unsupported token header");
    }
  } catch (error) {
    if (error instanceof JwtError) throw error;
    throw new JwtError("Invalid token header");
  }

  let valid: boolean;
  try {
    valid = await crypto.subtle.verify(
      "HMAC",
      await hmacKey(secret),
      b64urlDecode(sig) as BufferSource,
      enc.encode(`${header}.${body}`) as BufferSource,
    );
  } catch {
    throw new JwtError("Invalid signature");
  }
  if (!valid) throw new JwtError("Invalid signature");

  let claims: T;
  try {
    claims = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as T;
  } catch {
    throw new JwtError("Invalid payload");
  }
  const now = options.now ?? Math.floor(Date.now() / 1000);
  const tolerance = Math.max(0, options.clockTolerance ?? 0);
  if (typeof claims.exp === "number" && now - tolerance >= claims.exp)
    throw new JwtError("Token expired");
  if (typeof claims.nbf === "number" && now + tolerance < claims.nbf)
    throw new JwtError("Token not yet valid");
  if (
    options.maxAge !== undefined &&
    typeof claims.iat === "number" &&
    now - claims.iat > options.maxAge + tolerance
  ) {
    throw new JwtError("Token is too old");
  }
  if (options.issuer !== undefined && claims.iss !== options.issuer)
    throw new JwtError("Invalid issuer");
  if (options.audience !== undefined) {
    const expected = Array.isArray(options.audience) ? options.audience : [options.audience];
    const actual = Array.isArray(claims.aud)
      ? claims.aud
      : typeof claims.aud === "string"
        ? [claims.aud]
        : [];
    if (!expected.some((audience) => actual.includes(audience)))
      throw new JwtError("Invalid audience");
  }
  return claims;
}

export interface JwtAuthOptions {
  secret: string;
  /** Where to read the token. Default: `Authorization: Bearer <token>`. */
  getToken?: (ctx: Context) => string | undefined;
  /** Reject unauthenticated requests with 401. Default true. */
  required?: boolean;
}

/**
 * Middleware that verifies a bearer JWT and sets `ctx.user` to its claims.
 * When `required` (default), a missing/invalid token gets a 401.
 */
export function jwtAuth(options: JwtAuthOptions): Middleware {
  const getToken = options.getToken ?? bearerToken;
  const required = options.required !== false;
  return async (ctx, next) => {
    const token = getToken(ctx);
    if (token) {
      try {
        ctx.user = await verifyJwt(token, options.secret);
      } catch {
        if (required) return unauthorized();
      }
    } else if (required) {
      return unauthorized();
    }
    return next();
  };
}

function bearerToken(ctx: Context): string | undefined {
  const header = ctx.req.headers.get("authorization") ?? "";
  const m = /^Bearer\s+(.+)$/i.exec(header);
  return m ? m[1] : undefined;
}

function unauthorized(): Response {
  return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
export { decodeJwt, createJwtKeyring, signWithKeyring, verifyWithKeyring } from "./keyring.ts";
export type { JwtKey, JwtKeyring } from "./keyring.ts";
export {
  extractBearerToken,
  tryVerifyJwt,
  assertJwtClaims,
  tokenScopes,
  hasScopes,
  requireScopes,
  createAccessToken,
  createRefreshToken,
  verifyAccessToken,
  verifyRefreshToken,
  readJwtCookie,
  jwtCookie,
  clearJwtCookie,
  createTokenPair,
  jwtResponse,
} from "./helpers.ts";
export type { AccessTokenClaims, RefreshTokenClaims, JwtTokenPair } from "./helpers.ts";
export { createRemoteJwks, verifyJwtWithJwks } from "./jwks.ts";
export type { RemoteJwks, RemoteJwksOptions } from "./jwks.ts";
