/**
 * T2 · §4.3 capability-token VERIFY + DPoP proof-of-possession.
 *
 * `verifyCapabilityToken` keeps the FROZEN §4.3 signature verbatim (raw, expectedAud, now) — the
 * verifying key comes from the startup registry (config/keys.ts), never a parameter. The additive
 * `cnf.jkt` PoP claim (Finding-4) is NOT part of the frozen `CapabilityToken` shape, so it is kept
 * in a WeakMap keyed on the returned token object and read via `readCnfJkt` — the frozen type is
 * never mutated.
 */
import type { CapabilityToken } from 'relay-contracts'
import { CapabilityTokenSchema, encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
import { z } from 'zod'
import { verifyPaseto, peekPasetoClaims } from '../crypto/paseto.js'
import { verifyEd25519, importEd25519PublicRaw } from '../crypto/ed25519.js'
import { jwkThumbprint } from '../crypto/thumbprint.js'
import { getVerifyKey } from '../config/keys.js'
import { CapabilityError } from './errors.js'

/** Allowed clock skew for `iat` (seconds). */
export const CLOCK_SKEW_SEC = 5 as const
/** DPoP proof freshness window (seconds). */
export const DPOP_MAX_AGE_SEC = 30 as const

const CnfSchema = z.object({ jkt: z.string().min(1) }).strict()

/** Ties an additive `cnf.jkt` PoP binding to a verified token object without mutating §4.3. */
const cnfByToken = new WeakMap<CapabilityToken, string>()

/** Read the additive `cnf.jkt` PoP claim off a verified token (Finding-4). */
export function readCnfJkt(token: CapabilityToken): string {
  const jkt = cnfByToken.get(token)
  if (jkt === undefined) throw new CapabilityError('no_cnf')
  return jkt
}

/** Returns `token.sub` (the accountId, T1 convention). */
export function subAccountId(token: CapabilityToken): string {
  return token.sub
}

/** Read `exp` WITHOUT verifying — only safe after a prior successful verify (e.g. for consumeOnce). */
export function peekExp(raw: string): number {
  const claims = peekPasetoClaims(raw) as { exp?: unknown }
  if (typeof claims.exp !== 'number') throw new CapabilityError('malformed')
  return claims.exp
}

/**
 * FROZEN §4.3 signature. Verifies Ed25519 signature (startup key), `aud === expectedAud`, and
 * `now < exp` / `iat` skew; returns the validated §4.3 claims. Rejects deny-by-default.
 */
export async function verifyCapabilityToken(
  raw: string,
  expectedAud: string,
  now: number,
): Promise<CapabilityToken> {
  let claims: unknown
  try {
    claims = await verifyPaseto(raw, getVerifyKey())
  } catch {
    throw new CapabilityError('bad_signature')
  }
  const obj = claims as Record<string, unknown>
  const cnf = CnfSchema.safeParse(obj.cnf)
  if (!cnf.success) throw new CapabilityError('no_cnf')
  const { cnf: _cnf, ...core } = obj
  const parsed = CapabilityTokenSchema.safeParse(core)
  if (!parsed.success) throw new CapabilityError('malformed')
  const token = parsed.data as CapabilityToken
  if (token.aud !== expectedAud) throw new CapabilityError('aud_mismatch')
  if (token.iat > now + CLOCK_SKEW_SEC) throw new CapabilityError('not_yet_valid')
  if (token.exp <= now) throw new CapabilityError('expired')
  cnfByToken.set(token, cnf.data.jkt)
  return token
}

// ── DPoP proof-of-possession ────────────────────────────────────────────────────────────────────
export interface DpopContext {
  readonly proofJws: string
  readonly htu: string
  readonly htm: string
}

interface DpopHeader {
  readonly typ: string
  readonly jwk: { readonly crv: string; readonly kty: string; readonly x: string }
}
interface DpopPayload {
  readonly htu: string
  readonly htm: string
  readonly jti: string
  readonly iat: number
}

/** Bounded in-memory replay cache for DPoP `jti`s (single-process verifier). */
const seenDpopJti = new Map<string, number>()
function rememberDpop(jti: string, exp: number, now: number): boolean {
  for (const [k, e] of seenDpopJti) if (e < now) seenDpopJti.delete(k)
  if (seenDpopJti.has(jti)) return false
  seenDpopJti.set(jti, exp)
  return true
}

/** TEST-ONLY: clear the DPoP replay cache between suites. */
export function resetDpopCacheForTest(): void {
  seenDpopJti.clear()
}

/**
 * Verify a DPoP proof: the presenting connection signed (htu, htm, jti, iat) with the ephemeral
 * key whose JWK thumbprint MUST equal the token's `cnf.jkt`. false if thumbprint mismatch, replay,
 * or htu/htm/age mismatch (Finding-4).
 */
export async function verifyDpopProof(
  token: CapabilityToken,
  dpop: DpopContext,
  now: number,
): Promise<boolean> {
  // TOTALLY fail-safe (Finding-4): any thrown error — from readCnfJkt, jwkThumbprint,
  // importEd25519PublicRaw, or any decode — becomes `false`, never a rejected promise, so the
  // INV15 gate always denies through an audited path instead of an unhandled exception.
  try {
    const expectedJkt = readCnfJkt(token)
    const parts = dpop.proofJws.split('.')
    if (parts.length !== 3) return false
    const [h, p, s] = parts as [string, string, string]
    let header: DpopHeader
    let payload: DpopPayload
    try {
      header = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(h))) as DpopHeader
      payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DpopPayload
    } catch {
      return false
    }
    if (header.jwk?.kty !== 'OKP' || header.jwk?.crv !== 'Ed25519') return false
    let rawPub: Uint8Array
    try {
      rawPub = decodeBase64UrlBytes(header.jwk.x)
    } catch {
      return false
    }
    if ((await jwkThumbprint(rawPub)) !== expectedJkt) return false
    if (payload.htu !== dpop.htu || payload.htm !== dpop.htm) return false
    if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_MAX_AGE_SEC) return false
    const signed = new TextEncoder().encode(`${h}.${p}`)
    const pubKey = await importEd25519PublicRaw(rawPub)
    let sig: Uint8Array
    try {
      sig = decodeBase64UrlBytes(s)
    } catch {
      return false
    }
    if (!(await verifyEd25519(pubKey, sig, signed))) return false
    return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now)
  } catch {
    return false
  }
}

/** Build a DPoP proof (client-side helper; also used by tests). Signs with the ephemeral key. */
export async function buildDpopProof(
  ephemeralPrivate: CryptoKey,
  ephemeralPublicRaw: Uint8Array,
  ctx: { htu: string; htm: string; jti: string; iat: number },
): Promise<string> {
  const header: DpopHeader = {
    typ: 'dpop+ed25519',
    jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(ephemeralPublicRaw) },
  }
  const payload: DpopPayload = { htu: ctx.htu, htm: ctx.htm, jti: ctx.jti, iat: ctx.iat }
  const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
  const h = enc(header)
  const p = enc(payload)
  const { signEd25519 } = await import('../crypto/ed25519.js')
  const sig = await signEd25519(ephemeralPrivate, new TextEncoder().encode(`${h}.${p}`))
  return `${h}.${p}.${encodeBase64UrlBytes(sig)}`
}

/** Least-privilege check (INV15). */
export function hasRight(token: CapabilityToken, right: CapabilityToken['rights'][number]): boolean {
  return token.rights.includes(right)
}
