/**
 * Token helpers — HS256 access JWTs (Web Crypto) + Firebase RS256 ID-token verification.
 *
 * Consolidates jwt.ts and firebase-jwt.ts into a single module.
 */

import { base64UrlDecodeBytes, base64UrlDecodeStr, bytesToHex } from '@/lib/encoding.js';
import { ACCESS_TOKEN_TTL_SECS } from '@/server/auth/cookies.js';
import { captureCaught } from '@/server/observability/capture.server';

// ─── HS256 Access Token ───────────────────────────────────────────────────────

export type AccessClaims = {
  sub: string;
  sv: number;
  roles: string[];
  /** Unix-seconds timestamp of email verification, or null if unverified. */
  evAt: number | null;
  /** Unix-seconds timestamp of onboarding completion, or null if not yet completed. */
  obAt: number | null;
  /** mh-cookie cache version — incremented on profile/avatar/display-name changes. */
  mhv: number;
  iat: number;
  exp: number;
};

const ALG = { name: 'HMAC', hash: 'SHA-256' } as const;

function bufToBase64Url(buf: ArrayBuffer): string {
  const bytes = new Uint8Array(buf);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function base64UrlToBytes(s: string): Uint8Array {
  return base64UrlDecodeBytes(s);
}

const enc = new TextEncoder();

function jsonToBase64Url(obj: unknown): string {
  return bufToBase64Url(enc.encode(JSON.stringify(obj)).buffer as ArrayBuffer);
}

// Module-level cache: secret → CryptoKey. Lives for the isolate lifetime.
// Avoids repeated importKey calls (each ~1–2 ms CPU) on the CF Workers Free 10 ms ceiling.
const _hmacKeyCache = new Map<string, CryptoKey>();

async function importHmacKey(secret: string): Promise<CryptoKey> {
  const cached = _hmacKeyCache.get(secret);
  if (cached) return cached;
  const key = await crypto.subtle.importKey('raw', enc.encode(secret), ALG, false, [
    'sign',
    'verify',
  ]);
  _hmacKeyCache.set(secret, key);
  return key;
}

const HEADER_B64 = jsonToBase64Url({ alg: 'HS256', typ: 'JWT' });

/**
 * Sign an HS256 JWT.
 * @param claims  sub + sv + roles (iat/exp injected here)
 * @param secret  HMAC secret string
 * @param ttlSec  access-token TTL in seconds (default ACCESS_TOKEN_TTL_SECS)
 */
export async function signAccessToken(
  claims: Omit<AccessClaims, 'iat' | 'exp'>,
  secret: string,
  ttlSec = ACCESS_TOKEN_TTL_SECS,
): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  const payload: AccessClaims = { ...claims, iat: now, exp: now + ttlSec };
  const headerPayload = `${HEADER_B64}.${jsonToBase64Url(payload)}`;
  const key = await importHmacKey(secret);
  const sig = await crypto.subtle.sign(ALG, key, enc.encode(headerPayload));
  return `${headerPayload}.${bufToBase64Url(sig)}`;
}

/**
 * Verify an HS256 JWT against one or more secrets (rotation support).
 * Returns parsed claims on success, null on any failure — never throws.
 * Clock-skew tolerance: 30 s.
 */
export async function verifyAccessToken(
  jwt: string,
  secrets: string | string[],
): Promise<AccessClaims | null> {
  try {
    const parts = jwt.split('.');
    if (parts.length !== 3) return null;

    const [headerB64, payloadB64, sigB64] = parts as [string, string, string];
    const sigBytes = base64UrlToBytes(sigB64);
    const headerPayload = enc.encode(`${headerB64}.${payloadB64}`);

    const secretList = Array.isArray(secrets) ? secrets : [secrets];
    let verified = false;
    for (const secret of secretList) {
      const key = await importHmacKey(secret);
      const ok = await crypto.subtle.verify(
        ALG,
        key,
        sigBytes.buffer as ArrayBuffer,
        headerPayload,
      );
      if (ok) {
        verified = true;
        break;
      }
    }
    if (!verified) return null;

    const raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payloadB64))) as Record<
      string,
      unknown
    >;
    const now = Math.floor(Date.now() / 1000);
    const SKEW = 30;

    if (typeof raw.exp !== 'number' || now > raw.exp + SKEW) return null;
    if (typeof raw.iat !== 'number' || raw.iat > now + SKEW) return null;
    if (
      typeof raw.sub !== 'string' ||
      typeof raw.sv !== 'number' ||
      !Array.isArray(raw.roles) ||
      typeof raw.mhv !== 'number'
    ) {
      return null;
    }

    return {
      sub: raw.sub,
      sv: raw.sv,
      roles: raw.roles as string[],
      evAt: typeof raw.evAt === 'number' ? raw.evAt : null,
      obAt: typeof raw.obAt === 'number' ? raw.obAt : null,
      mhv: raw.mhv,
      iat: raw.iat as number,
      exp: raw.exp as number,
    };
  } catch (err) {
    captureCaught(err, { scope: 'server.auth.tokens', severity: 'warning' });
    return null;
  }
}

/** Generate a cryptographically random 32-byte opaque refresh token (base64url, ~43 chars). */
export function generateRefreshToken(): string {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return bufToBase64Url(bytes.buffer);
}

/** SHA-256 hex digest of a refresh token string. */
export async function hashRefreshToken(rt: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', enc.encode(rt));
  return bytesToHex(buf);
}

// ─── Firebase RS256 ID Token ──────────────────────────────────────────────────

const JWKS_URL =
  'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
const FIREBASE_PROJECT = 'multideal-49eff';
const EXPECTED_ISS = `https://securetoken.google.com/${FIREBASE_PROJECT}`;

interface JwkKey {
  kid: string;
  n: string;
  e: string;
  kty: string;
  use: string;
  alg: string;
}

interface JwksResponse {
  keys: JwkKey[];
}

export interface FirebaseTokenPayload {
  phone: string;
}

function fail(msg: string): never {
  throw new Error(`TOKEN_INVALID: ${msg}`);
}

function base64urlDecode(str: string): ArrayBuffer {
  return base64UrlDecodeBytes(str).buffer as ArrayBuffer;
}

async function fetchJwks(forceRefresh = false): Promise<JwkKey[]> {
  const cacheKey = new Request(JWKS_URL);

  const cfCache = (caches as unknown as { default: Cache }).default;

  if (!forceRefresh) {
    const cached = await cfCache.match(cacheKey);
    if (cached) {
      const data = (await cached.json()) as JwksResponse;
      return data.keys;
    }
  }

  const res = await fetch(JWKS_URL);
  if (!res.ok) fail('failed to fetch JWKS');

  const resForJson = res.clone();
  await cfCache.put(cacheKey, res);

  const data = (await resForJson.json()) as JwksResponse;
  return data.keys;
}

async function importPublicKey(jwk: JwkKey): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    'jwk',
    jwk,
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['verify'],
  );
}

async function verifyRsaSignature(
  key: CryptoKey,
  signature: ArrayBuffer,
  data: string,
): Promise<boolean> {
  return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, new TextEncoder().encode(data));
}

/**
 * Verifies a Firebase ID token (RS256 JWT) using Google's JWKS.
 * JWKS is cached via Cache API (TTL from Cache-Control max-age).
 * On signature failure with cached keys: force-refreshes JWKS once and retries.
 *
 * @throws Error with message starting `TOKEN_INVALID:` on any verification failure.
 */
export async function verifyFirebaseIdToken(idToken: string): Promise<FirebaseTokenPayload> {
  const parts = idToken.split('.');
  if (parts.length !== 3) fail('malformed JWT');
  const [rawHeader, rawPayload, rawSig] = parts as [string, string, string];

  let header: { alg?: string; kid?: string };
  let payload: Record<string, unknown>;
  try {
    header = JSON.parse(base64UrlDecodeStr(rawHeader));
    payload = JSON.parse(base64UrlDecodeStr(rawPayload));
  } catch (err) {
    captureCaught(err, { scope: 'server.auth.tokens', severity: 'warning' });
    fail('malformed JWT parts');
  }

  if (header.alg !== 'RS256') fail('unexpected algorithm');
  if (!header.kid) fail('missing kid');

  const now = Math.floor(Date.now() / 1000);
  if (typeof payload.exp !== 'number' || payload.exp <= now) fail('token expired');
  if (payload.aud !== FIREBASE_PROJECT) fail('invalid aud');
  if (payload.iss !== EXPECTED_ISS) fail('invalid iss');
  if (typeof payload.phone_number !== 'string' || !payload.phone_number)
    fail('missing phone_number claim');

  const signature = base64urlDecode(rawSig);
  const sigInput = `${rawHeader}.${rawPayload}`;

  async function tryVerify(forceRefresh: boolean): Promise<boolean> {
    const keys = await fetchJwks(forceRefresh);
    const jwk = keys.find((k) => k.kid === header.kid);
    if (!jwk) return false;
    const cryptoKey = await importPublicKey(jwk);
    return verifyRsaSignature(cryptoKey, signature, sigInput);
  }

  let valid = await tryVerify(false);
  if (!valid) valid = await tryVerify(true);
  if (!valid) fail('signature verification failed');

  return { phone: payload.phone_number as string };
}
