/**
 * Firebase ID-token verification — auth-2fa.
 *
 * Verifies a Firebase phone-auth idToken using only crypto.subtle (no Node.js,
 * no third-party JWT library). Compatible with Cloudflare Workers / workerd.
 *
 * Flow:
 *   1. Fetch Google securetoken x509 public keys (cached via caches.default).
 *   2. Base64url-decode the JWT header; pick cert by `kid`.
 *   3. Import RSA public key (SPKI from PEM x509 cert), verify RS256 signature.
 *   4. Validate standard claims (iss, aud, exp, iat, auth_time, sub, phone_number).
 *
 * All claim string comparisons use timing-safe equality.
 */

import { asBufferSource, timingSafeEqual } from './crypto'

/** Workers CacheStorage exposes `default`; DOM lib types omit it. */
type WorkerCacheStorage = CacheStorage & { readonly default: Cache }

const GOOGLE_CERTS_URL =
  'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'

export interface FirebaseUser {
  uid: string
  phoneNumber: string
}

export class FirebaseTokenError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'FirebaseTokenError'
  }
}

/** Base64url → Uint8Array */
function base64UrlDecode(s: string): Uint8Array {
  // Pad to 4-byte boundary, convert base64url → base64
  const padded = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=')
  const binary = atob(padded)
  const bytes = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
  return bytes
}

/**
 * Parse a PEM certificate (x509) and return the SPKI DER bytes of the embedded
 * public key. We rely on the fact that the x509 SubjectPublicKeyInfo is the
 * entire PEM body — Google's securetoken certs are RSA 2048.
 *
 * We use SubtleCrypto importKey with 'pkcs8' / 'spki' formats. The Google certs
 * are full x509 certs (not raw SPKI), so we strip the PEM armor and pass the
 * DER as "raw" to importKey with algorithm { name: 'X509' } — but that isn't
 * available in all runtimes. Instead we use the standard approach: extract the
 * SubjectPublicKeyInfo from the x509 DER manually.
 */
function pemCertToSpkiDer(pem: string): Uint8Array {
  const b64 = pem
    .replace(/-----BEGIN CERTIFICATE-----/, '')
    .replace(/-----END CERTIFICATE-----/, '')
    .replace(/\s+/g, '')
  const der = base64UrlDecode(b64.replace(/\+/g, '+').replace(/\//g, '/'))
  // Extract SubjectPublicKeyInfo from the x509 DER (TBSCertificate.subjectPublicKeyInfo)
  // DER structure: SEQUENCE { tbsCertificate SEQUENCE { ... subjectPublicKeyInfo ... } ... }
  // We scan for the RSA public key OID (1.2.840.113549.1.1.1 = 2a 86 48 86 f7 0d 01 01 01)
  const rsaOid = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01])
  outer: for (let i = 0; i < der.length - rsaOid.length; i++) {
    for (let j = 0; j < rsaOid.length; j++) {
      if (der[i + j] !== rsaOid[j]) continue outer
    }
    // Found OID at i. Back up to the SEQUENCE tag of SubjectPublicKeyInfo.
    // The SubjectPublicKeyInfo starts with SEQUENCE (0x30) before the OID sequence.
    // Structure: SEQUENCE(0x30) len AlgorithmIdentifier(SEQUENCE(0x30) len OID ...)  BIT STRING
    // We need to find the enclosing SEQUENCE that starts the SubjectPublicKeyInfo.
    // Typical: 0x30 0x0d 0x06 0x09 <oid> 0x05 0x00 (AlgId), then BIT STRING
    // Walk back to the SEQUENCE(0x30) just before OID sequence (0x30)
    for (let k = i - 3; k >= 0; k--) {
      if (der[k] === 0x30) {
        // Try to parse length from der[k+1..]
        const lenByte = der[k + 1]!
        let spkiLen: number
        let headerLen: number
        if (lenByte! < 0x80) {
          spkiLen = lenByte!
          headerLen = 2
        } else {
          const nBytes = lenByte! & 0x7f
          spkiLen = 0
          for (let b = 0; b < nBytes; b++) {
            spkiLen = (spkiLen << 8) | der[k + 2 + b]!
          }
          headerLen = 2 + nBytes
        }
        const end = k + headerLen + spkiLen
        if (end <= der.length && end > i + rsaOid.length) {
          return der.subarray(k, end)
        }
      }
    }
    break
  }
  throw new FirebaseTokenError('Cannot extract SPKI from x509 certificate')
}

/** Fetch Google securetoken x509 keys, cached by Cache-Control max-age. */
async function fetchPublicKeys(): Promise<Record<string, string>> {
  const cache = (caches as WorkerCacheStorage).default
  const cacheKey = new Request(GOOGLE_CERTS_URL)

  const cached = await cache.match(cacheKey)
  if (cached) {
    try {
      return await cached.json<Record<string, string>>()
    } catch {
      // Cache miss / invalid — fall through to network
    }
  }

  const resp = await fetch(GOOGLE_CERTS_URL)
  if (!resp.ok) {
    throw new FirebaseTokenError(`Failed to fetch Google public keys: ${resp.status}`)
  }

  // Clone before consuming so we can cache the original response
  const cloned = resp.clone()
  await cache.put(cacheKey, cloned)

  return resp.json<Record<string, string>>()
}

/**
 * Verify a Firebase RS256 ID token and return { uid, phoneNumber }.
 * Throws FirebaseTokenError on any failure.
 */
export async function verifyFirebaseIdToken(
  idToken: string,
  env: { FIREBASE_PROJECT_ID: string },
): Promise<FirebaseUser> {
  const parts = idToken.split('.')
  if (parts.length !== 3) {
    throw new FirebaseTokenError('Malformed JWT: expected 3 parts')
  }
  const [headerB64, payloadB64, sigB64] = parts as [string, string, string]

  // Decode header
  let header: { alg?: string; kid?: string }
  try {
    header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)))
  } catch {
    throw new FirebaseTokenError('Invalid JWT header')
  }

  if (header.alg !== 'RS256') {
    throw new FirebaseTokenError(`Unexpected algorithm: ${header.alg}`)
  }
  if (!header.kid) {
    throw new FirebaseTokenError('Missing kid in JWT header')
  }

  // Decode payload
  let payload: Record<string, unknown>
  try {
    payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)))
  } catch {
    throw new FirebaseTokenError('Invalid JWT payload')
  }

  // Fetch and pick key
  const certs = await fetchPublicKeys()
  const pemCert = certs[header.kid]
  if (!pemCert) {
    throw new FirebaseTokenError(`Unknown kid: ${header.kid}`)
  }

  // Import public key
  const spkiDer = pemCertToSpkiDer(pemCert)
  let publicKey: CryptoKey
  try {
    publicKey = await crypto.subtle.importKey(
      'spki',
      asBufferSource(spkiDer),
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      false,
      ['verify'],
    )
  } catch (e) {
    throw new FirebaseTokenError(`Failed to import public key: ${(e as Error).message}`)
  }

  // Verify signature over "header.payload"
  const signedData = new TextEncoder().encode(`${headerB64}.${payloadB64}`)
  const sigBytes = base64UrlDecode(sigB64)
  const valid = await crypto.subtle.verify(
    'RSASSA-PKCS1-v1_5',
    publicKey,
    asBufferSource(sigBytes),
    signedData,
  )
  if (!valid) {
    throw new FirebaseTokenError('Invalid JWT signature')
  }

  // Validate claims
  const now = Math.floor(Date.now() / 1000)
  const expectedIss = `https://securetoken.google.com/${env.FIREBASE_PROJECT_ID}`

  if (typeof payload['iss'] !== 'string' || !timingSafeEqual(payload['iss'], expectedIss)) {
    throw new FirebaseTokenError('Invalid iss claim')
  }
  if (
    typeof payload['aud'] !== 'string' ||
    !timingSafeEqual(payload['aud'], env.FIREBASE_PROJECT_ID)
  ) {
    throw new FirebaseTokenError('Invalid aud claim')
  }
  if (typeof payload['exp'] !== 'number' || payload['exp'] <= now) {
    throw new FirebaseTokenError('Token expired')
  }
  if (typeof payload['iat'] !== 'number' || payload['iat'] > now) {
    throw new FirebaseTokenError('Token issued in the future')
  }
  if (typeof payload['auth_time'] !== 'number') {
    throw new FirebaseTokenError('Missing auth_time claim')
  }
  if (typeof payload['sub'] !== 'string' || payload['sub'].length === 0) {
    throw new FirebaseTokenError('Missing or empty sub claim')
  }
  if (typeof payload['phone_number'] !== 'string' || payload['phone_number'].length === 0) {
    throw new FirebaseTokenError('Missing or empty phone_number claim')
  }

  return {
    uid: payload['sub'],
    phoneNumber: payload['phone_number'],
  }
}
