//! Production [`IdTokenVerifier`] adapter — verifies PAS-issued OIDC
//! id_tokens.
//!
//! [`PasIdTokenVerifier`] is the only place inside the SDK that knows
//! the OIDC id_token wire format. It calls
//! [`ppoppo_token::id_token::verify::<S>`] under a TTL-cached JWKS
//! ([`crate::token::keyset::JwksCache`], shared type with
//! [`PasJwtVerifier`](crate::token::PasJwtVerifier) per ε1 — separate
//! instance per verifier, no shared state), maps the engine's
//! [`Claims<S>`] payload to an SDK-shaped [`IdAssertion<S>`], and
//! routes any [`AuthError`] to the boundary's [`IdVerifyError`] enum so
//! audit logs retain the M-code via Display fallback.
//!
//! Single textbook constructor [`Self::from_jwks_url`] — mirror of
//! [`PasJwtVerifier::from_jwks_url`]. The verifier is generic over
//! `S: ScopePiiReader`, so a consumer wires
//! `PasIdTokenVerifier::<scopes::EmailProfile>::from_jwks_url(...)` and
//! the type system narrows the resulting [`IdAssertion`] accessor
//! surface to exactly that scope's claims at compile time.
//!
//! ## ε1 invariant — separate JwksCache per verifier
//!
//! Each verifier ([`PasJwtVerifier`](crate::token::PasJwtVerifier),
//! [`PasIdTokenVerifier<S>`]) constructs its own
//! [`JwksCache`](crate::token::keyset::JwksCache) via
//! [`from_jwks_url`]. Two HTTP fetches at startup; two TTL refresh
//! schedules; isolated state. Cost: one extra HTTP fetch per consumer
//! at startup (cheap; both endpoints live on `accounts.ppoppo.com`).
//! Benefit: simpler API surface, no third "JwksProvider" type. ε2
//! (shared cache) is deferred to a post-launch ops measurement —
//! see RFC §6.11.1 D-04 and `project_jwt_adoption.md`.

use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::str::FromStr;
use std::sync::Arc;

use async_trait::async_trait;
use ppoppo_token::id_token::{AuthError, Claims, Nonce, VerifyConfig};
use ppoppo_token::SharedAuthError;
use time::OffsetDateTime;

use crate::audit::{AuditEvent, AuditSink, IdTokenFailureKind, VerifyErrorKind};
use crate::token::keyset::JwksCache;
use crate::token::Expectations;
use crate::types::PpnumId;

use super::port::{IdAssertion, IdTokenVerifier, IdVerifyError, ScopePiiReader};

/// PAS OIDC id_token verifier (RFC 9068 + OIDC Core 1.0, EdDSA).
///
/// Constructed once per consumer deployment with the JWKS URL and the
/// per-deployment [`Expectations`]. Cheap to clone — internal state is
/// `Arc`-shared, so you can store the verifier behind
/// `Arc<dyn IdTokenVerifier<S>>` in a request extension or per-route
/// layer without measurable overhead.
///
/// `Debug` is intentionally derived but only surfaces the
/// [`Expectations`]; the underlying [`JwksCache`] carries an HTTP
/// client and a parsed keyset that have no useful Debug representation.
///
/// As of 0.8.0 the containing `oidc::verifier` module is `pub(crate)`.
/// Production consumers reach the verify-half through
/// [`super::RelyingParty<S>::complete`], which constructs and consumes
/// this verifier internally. SDK boundary tests + downstream consumer
/// integration tests reach the type via the
/// [`crate::test_support::PasIdTokenVerifier`] re-export under the
/// `test-support` feature gate.
#[derive(Clone, Debug)]
pub struct PasIdTokenVerifier<S: ScopePiiReader> {
    jwks: JwksCache,
    expectations: Expectations,
    /// M48 audit emission port. `None` (the default from
    /// [`Self::from_jwks_url`]) means the verifier silently skips audit
    /// emission on failure — mirrors [`PasJwtVerifier`]'s pre-Phase-9
    /// shape so consumers who haven't opted in are unaffected. Opt in
    /// with [`Self::with_audit`].
    audit_sink: Option<Arc<dyn AuditSink>>,
    _scope: PhantomData<S>,
}

impl<S: ScopePiiReader> PasIdTokenVerifier<S> {
    /// Single textbook constructor — mirror of
    /// [`PasJwtVerifier::from_jwks_url`](crate::token::PasJwtVerifier::from_jwks_url).
    /// Performs an initial JWKS fetch + cache; subsequent verifications
    /// snapshot the cache, refreshing on TTL expiry.
    ///
    /// **Builder family**:
    ///
    /// - [`Self::with_audit`] — wire an [`AuditSink`] for M48
    ///   verify-failure emission (Phase 9 inheritance — same port as
    ///   [`PasJwtVerifier::with_audit`](crate::token::PasJwtVerifier::with_audit)).
    ///   Defaults to no emission; wrap in
    ///   [`crate::RateLimitedAuditSink`] for log-flood DoS defense (M49).
    ///
    /// # Errors
    ///
    /// Returns [`IdVerifyError::KeysetUnavailable`] if the initial JWKS
    /// fetch fails. The verifier cannot serve verifications without at
    /// least one usable key snapshot.
    pub async fn from_jwks_url(
        jwks_url: impl Into<String>,
        expectations: Expectations,
    ) -> Result<Self, IdVerifyError> {
        let jwks = JwksCache::fetch(jwks_url)
            .await
            .map_err(|_| IdVerifyError::KeysetUnavailable)?;
        Ok(Self {
            jwks,
            expectations,
            audit_sink: None,
            _scope: PhantomData,
        })
    }

    /// Wire an audit sink for M48 verify-failure emission.
    ///
    /// Every Err path inside [`IdTokenVerifier::verify`] will, after
    /// this builder, also emit an [`AuditEvent`] through the supplied
    /// sink. The sink is expected to be cheap (`Arc`-shared); it is
    /// not consulted on the success path.
    ///
    /// **Composition**: for log-flood DoS defense (M49), wrap the real
    /// sink in [`crate::RateLimitedAuditSink`] before passing here:
    ///
    /// ```ignore
    /// use std::sync::Arc;
    /// use pas_external::{
    ///     AuditSink, MemoryRateLimiter, PasIdTokenVerifier,
    ///     RateLimitedAuditSink,
    /// };
    /// use pas_external::oidc::Openid;
    /// # async fn wire(real_sink: Arc<dyn AuditSink>) -> Result<(), Box<dyn std::error::Error>> {
    /// let limited: Arc<dyn AuditSink> = Arc::new(RateLimitedAuditSink::new(
    ///     real_sink,
    ///     Arc::new(MemoryRateLimiter::default()),
    /// ));
    /// let verifier = PasIdTokenVerifier::<Openid>::from_jwks_url(
    ///     "https://accounts.ppoppo.com/.well-known/jwks.json",
    ///     pas_external::Expectations::new("accounts.ppoppo.com", "rp-client-id"),
    /// )
    /// .await?
    /// .with_audit(limited);
    /// # let _ = verifier;
    /// # Ok(()) }
    /// ```
    ///
    /// **Single-pipeline reuse**: pass the same `Arc<dyn AuditSink>` to
    /// both [`PasJwtVerifier::with_audit`](crate::token::PasJwtVerifier::with_audit)
    /// and [`Self::with_audit`] — the AuditSink port serves both
    /// verifiers; dashboards filter id_token failures via
    /// `match kind { VerifyErrorKind::IdToken(_) => ... }`.
    ///
    /// Calling repeatedly replaces the sink (no chaining); the last
    /// call wins.
    #[must_use]
    pub fn with_audit(mut self, sink: Arc<dyn AuditSink>) -> Self {
        self.audit_sink = Some(sink);
        self
    }

    /// Test-support ctor — constructs a verifier without performing a
    /// JWKS fetch. Mirror of
    /// [`PasJwtVerifier::for_test_skip_fetch`](crate::token::PasJwtVerifier::for_test_skip_fetch).
    ///
    /// The internal keyset is empty, so any engine verify path rejects
    /// on `KidUnknown` (mapped to [`IdVerifyError::SignatureInvalid`]
    /// per [`map_auth_error`]). Adapter-side rejection paths
    /// ([`IdVerifyError::InvalidFormat`]) are fully exercisable since
    /// they reject before consulting the keyset.
    ///
    /// Used by Phase 10.11.E's boundary tests to drive the verify path
    /// without a wiremock-shaped JWKS endpoint. NOT for production use
    /// — use [`Self::from_jwks_url`] instead.
    #[cfg(any(test, feature = "test-support"))]
    #[must_use]
    pub fn for_test_skip_fetch(expectations: Expectations) -> Self {
        Self {
            jwks: JwksCache::for_test_empty(),
            expectations,
            audit_sink: None,
            _scope: PhantomData,
        }
    }

    /// Internal: emit an audit event before returning an `IdVerifyError`.
    ///
    /// Returns the original `err` so call sites stay terse:
    ///
    /// ```ignore
    /// return Err(self.emit_failure(token, IdVerifyError::InvalidFormat).await);
    /// ```
    ///
    /// When no sink is wired, this is a no-op that returns the error
    /// directly. The `Option` check happens before any token-decode
    /// work so the no-audit path stays zero-overhead.
    async fn emit_failure(&self, id_token: &str, err: IdVerifyError) -> IdVerifyError {
        let Some(sink) = self.audit_sink.as_ref() else {
            return err;
        };

        let kind = VerifyErrorKind::from(&err);
        let (azp_hint, aud_hint, kid_hint) = peek_id_token_hints(id_token);
        let mut metadata = BTreeMap::new();
        if let IdVerifyError::Other(msg) = &err {
            metadata.insert(
                "engine_msg".to_owned(),
                serde_json::Value::String(msg.clone()),
            );
        }
        let event = AuditEvent::from_id_token_hints(
            kind,
            OffsetDateTime::now_utc(),
            azp_hint,
            aud_hint,
            kid_hint,
            metadata,
        );
        sink.record_failure(event).await;
        err
    }
}

/// Best-effort decode the rejected id_token's `azp` (payload), first
/// `aud` element (payload), and `kid` (header) for δ2 audit-event
/// hinting.
///
/// Returns `(None, None, None)` for any token that fails to parse —
/// the token has already been rejected by definition, so its claims
/// are untrusted; consumers MUST NOT treat absence as a security
/// signal. The hints exist purely for grouping / dashboard pivot.
///
/// Defensive decode — no panics on malformed input. A garbage token
/// returns `(None, None, None)`, which composes via
/// [`compose_id_token_source_id`](crate::compose_id_token_source_id)
/// into the canonical `"anon::noaud::nokid"` bucket.
///
/// **Why first-element `aud`**: the wire `aud` may be a string or an
/// array (OIDC §2). For DoS-bucket discrimination, the first element
/// is sufficient — multi-aud tokens collapse into a single source key
/// per (azp, first_aud, kid) combination, which is the meaningful
/// per-RP grouping.
fn peek_id_token_hints(token: &str) -> (Option<String>, Option<String>, Option<String>) {
    use base64::Engine as _;

    let mut parts = token.split('.');
    let header_b64 = parts.next();
    let payload_b64 = parts.next();

    let kid_hint = header_b64.and_then(|h| {
        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(h).ok()?;
        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
        value.get("kid").and_then(|k| k.as_str()).map(str::to_owned)
    });

    let payload_value = payload_b64.and_then(|p| {
        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(p).ok()?;
        serde_json::from_slice::<serde_json::Value>(&bytes).ok()
    });

    let azp_hint = payload_value
        .as_ref()
        .and_then(|v| v.get("azp"))
        .and_then(|a| a.as_str())
        .map(str::to_owned);

    let aud_hint = payload_value.as_ref().and_then(|v| v.get("aud")).and_then(|aud| {
        match aud {
            serde_json::Value::String(s) => Some(s.clone()),
            serde_json::Value::Array(arr) => arr
                .first()
                .and_then(|first| first.as_str())
                .map(str::to_owned),
            _ => None,
        }
    });

    (azp_hint, aud_hint, kid_hint)
}

/// Map [`IdVerifyError`] to the audit-layer kind enum.
///
/// id_token-specific rows nest under
/// [`VerifyErrorKind::IdToken`](crate::VerifyErrorKind::IdToken)
/// (Phase 10.11.B); JOSE-layer rejections (shared with access_token)
/// flatten to the existing top-level variants. Drift detector lives
/// in `tests/id_token_verifier_boundary.rs` (Phase 10.11.E).
impl From<&IdVerifyError> for VerifyErrorKind {
    fn from(err: &IdVerifyError) -> Self {
        use IdTokenFailureKind as K;
        use IdVerifyError as E;
        match err {
            // Adapter-side
            E::InvalidFormat => Self::InvalidFormat,
            // JOSE-layer (shared with access_token)
            E::SignatureInvalid => Self::SignatureInvalid,
            E::Expired => Self::Expired,
            E::IssuerInvalid => Self::IssuerInvalid,
            E::AudienceInvalid => Self::AudienceInvalid,
            E::MissingClaim(c) => Self::MissingClaim((*c).to_owned()),
            E::KeysetUnavailable => Self::KeysetUnavailable,
            // OIDC-specific (nested under IdToken)
            E::NonceMissing => Self::IdToken(K::NonceMissing),
            E::NonceMismatch => Self::IdToken(K::NonceMismatch),
            E::AtHashMissing => Self::IdToken(K::AtHashMissing),
            E::AtHashMismatch => Self::IdToken(K::AtHashMismatch),
            E::CHashMissing => Self::IdToken(K::CHashMissing),
            E::CHashMismatch => Self::IdToken(K::CHashMismatch),
            E::AzpMissing => Self::IdToken(K::AzpMissing),
            E::AzpMismatch => Self::IdToken(K::AzpMismatch),
            E::AuthTimeMissing => Self::IdToken(K::AuthTimeMissing),
            E::AuthTimeStale => Self::IdToken(K::AuthTimeStale),
            E::AcrMissing => Self::IdToken(K::AcrMissing),
            E::AcrNotAllowed => Self::IdToken(K::AcrNotAllowed),
            E::UnknownClaim(name) => Self::IdToken(K::UnknownClaim(name.clone())),
            E::CatMismatch(value) => Self::IdToken(K::CatMismatch(value.clone())),
            // Catch-all
            E::Other(_) => Self::Other,
        }
    }
}

#[async_trait]
impl<S: ScopePiiReader> IdTokenVerifier<S> for PasIdTokenVerifier<S> {
    async fn verify(
        &self,
        id_token: &str,
        expected_nonce: &Nonce,
    ) -> Result<IdAssertion<S>, IdVerifyError> {
        // Adapter-side reject before engine entry — JWS Compact has
        // exactly three segments separated by `.`. Mirror of
        // `PasJwtVerifier::verify`'s upstream reject; rejecting at the
        // SDK boundary keeps the audit log signal cleaner ("malformed
        // at SDK boundary" vs "engine M07/M11/M15").
        if id_token.is_empty() || !looks_like_jws_compact(id_token) {
            return Err(self
                .emit_failure(id_token, IdVerifyError::InvalidFormat)
                .await);
        }

        // No M73-equivalent guard here — id_token IS what this verifier
        // expects. The engine's M29-mirror `cat="id"` check (Phase
        // 10.10.D) catches access_tokens presented to the id_token
        // verifier; that refusal surfaces as
        // `IdVerifyError::CatMismatch(_)` post-engine.

        let cfg = VerifyConfig::id_token(
            self.expectations.issuer.clone(),
            self.expectations.audience.clone(),
            expected_nonce.clone(),
        );
        let keyset = self.jwks.snapshot().await;
        let claims = match ppoppo_token::id_token::verify::<S>(id_token, &cfg, &keyset).await {
            Ok(c) => c,
            Err(e) => {
                let mapped = IdVerifyError::from(e);
                return Err(self.emit_failure(id_token, mapped).await);
            }
        };

        match claims_to_assertion::<S>(claims) {
            Ok(assertion) => Ok(assertion),
            Err(err) => Err(self.emit_failure(id_token, err).await),
        }
    }
}

fn looks_like_jws_compact(token: &str) -> bool {
    token.split('.').count() == 3
}

/// Convert engine [`Claims<S>`] to SDK [`IdAssertion<S>`].
///
/// Mirror of `claims_to_auth_session` in `token::jwt`. Parses the
/// stable subject identifier (ULID), converts Unix-timestamp times to
/// [`OffsetDateTime`], and lays out base fields. Per-scope PII is
/// layered in via [`ScopePiiReader::fill_pii`] — the trait's per-scope
/// impls compose the four `fill_*` helpers in `oidc::port`.
///
/// # Errors
///
/// - [`IdVerifyError::MissingClaim`] — `sub` is not a parseable ULID,
///   `exp`/`iat` are out of representable range. The engine's
///   `Claims<S>` always populates these from the wire, so reaching
///   this error means an issuer drift (or a verifier-engine version
///   skew, which `Cargo.toml` workspace pinning prevents).
fn claims_to_assertion<S: ScopePiiReader>(
    claims: Claims<S>,
) -> Result<IdAssertion<S>, IdVerifyError> {
    let sub = ulid::Ulid::from_str(&claims.sub)
        .map(PpnumId)
        .map_err(|_| IdVerifyError::MissingClaim("sub"))?;

    let exp = OffsetDateTime::from_unix_timestamp(claims.exp)
        .map_err(|_| IdVerifyError::MissingClaim("exp"))?;
    let iat = OffsetDateTime::from_unix_timestamp(claims.iat)
        .map_err(|_| IdVerifyError::MissingClaim("iat"))?;

    let auth_time = match claims.auth_time {
        Some(ts) => Some(
            OffsetDateTime::from_unix_timestamp(ts)
                .map_err(|_| IdVerifyError::MissingClaim("auth_time"))?,
        ),
        None => None,
    };

    let mut assertion = IdAssertion::<S>::new_base(
        claims.iss.clone(),
        sub,
        claims.aud.clone(),
        exp,
        iat,
        claims.nonce.clone(),
        claims.azp.clone(),
        auth_time,
        claims.acr.clone(),
        claims.amr.clone(),
    );

    S::fill_pii(&claims, &mut assertion);
    Ok(assertion)
}

/// Map engine [`AuthError`] to the SDK boundary [`IdVerifyError`].
///
/// id_token-specific rows (M66-M73 + M29-mirror `CatMismatch`) map
/// 1:1 to the dedicated SDK variants. JOSE-layer rejections (carried
/// via the engine's `Jose(SharedAuthError)` carrier) collapse to the
/// shared `SignatureInvalid` / `Expired` / `IssuerInvalid` /
/// `AudienceInvalid` / `MissingClaim` / `InvalidFormat` variants —
/// audit logs retain the precise M-row identifier via the engine's
/// Display when needed.
fn map_auth_error(err: AuthError) -> IdVerifyError {
    use AuthError as E;
    use SharedAuthError as S;
    match err {
        // ── OIDC-specific (M66-M73 + M29-mirror) ─────────────────────
        E::NonceMissing => IdVerifyError::NonceMissing,
        E::NonceMismatch => IdVerifyError::NonceMismatch,
        E::NonceConfigEmpty => IdVerifyError::MissingClaim("nonce"),
        E::AtHashMissing => IdVerifyError::AtHashMissing,
        E::AtHashMismatch => IdVerifyError::AtHashMismatch,
        E::CHashMissing => IdVerifyError::CHashMissing,
        E::CHashMismatch => IdVerifyError::CHashMismatch,
        E::AzpMissing => IdVerifyError::AzpMissing,
        E::AzpMismatch => IdVerifyError::AzpMismatch,
        E::AuthTimeMissing => IdVerifyError::AuthTimeMissing,
        E::AuthTimeStale => IdVerifyError::AuthTimeStale,
        E::AcrMissing => IdVerifyError::AcrMissing,
        E::AcrNotAllowed => IdVerifyError::AcrNotAllowed,
        E::UnknownClaim(name) => IdVerifyError::UnknownClaim(name),
        E::CatMismatch(value) => IdVerifyError::CatMismatch(value),

        // ── JOSE-layer (shared with access_token, mirrors token::jwt) ─
        E::Jose(
            S::AlgNone
            | S::AlgNotWhitelisted
            | S::AlgHmacRejected
            | S::AlgRsaRejected
            | S::AlgEcdsaRejected
            | S::HeaderJku
            | S::HeaderX5u
            | S::HeaderJwk
            | S::HeaderX5c
            | S::HeaderCrit
            | S::HeaderExtraParam
            | S::HeaderB64False
            | S::KidUnknown
            | S::TypMismatch
            | S::NestedJws
            | S::DuplicateJsonKeys
            | S::HeaderUnparseable
            | S::PayloadUnparseable
            | S::NotJwsCompact,
        ) => IdVerifyError::SignatureInvalid,
        E::Jose(
            S::OversizedToken | S::JwsJsonRejected | S::JwePayload | S::LaxBase64,
        ) => IdVerifyError::InvalidFormat,
        // Note: id_token::AuthError is currently exhaustively covered
        // by the OIDC-specific arms above + the two Jose arms (every
        // SharedAuthError variant carries through one of the Jose
        // patterns). When the engine adds a new outer variant in a
        // future phase, this match will fail to compile — that is the
        // intended forward-compat signal: the SDK gets the new
        // `IdVerifyError` row paired with the engine variant in the
        // same PR rather than silently swallowing it via a catch-all.
    }
}

impl From<AuthError> for IdVerifyError {
    fn from(err: AuthError) -> Self {
        map_auth_error(err)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    //! Verifier-internal unit tests. Boundary-test coverage (AuditSink
    //! emission, MemoryIdTokenVerifier round-trip, compile_fail
    //! narrowing) lives in `tests/id_token_verifier_boundary.rs` and
    //! lands with Phase 10.11.E.
    use super::*;
    use ppoppo_token::id_token::scopes;

    #[tokio::test]
    async fn from_jwks_url_with_invalid_url_yields_keyset_unavailable() {
        // .invalid is RFC 6761-reserved as guaranteed-unresolvable; the
        // initial fetch in `JwksCache::fetch` must fail. The typed
        // `KeysetUnavailable` variant is the audit-log signal that
        // distinguishes "we couldn't reach the IdP" from "the IdP said no."
        let result = PasIdTokenVerifier::<scopes::Openid>::from_jwks_url(
            "http://nonexistent.invalid/.well-known/jwks.json",
            Expectations::new("accounts.ppoppo.com", "rp-client-id"),
        )
        .await;
        let err = result.expect_err("bad URL must fail construction");
        assert_eq!(err, IdVerifyError::KeysetUnavailable);
    }

    #[tokio::test]
    async fn verify_empty_token_yields_invalid_format() {
        let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
            Expectations::new("accounts.ppoppo.com", "rp-client-id"),
        );
        let nonce = Nonce::new("test-nonce").unwrap();
        let err = verifier.verify("", &nonce).await.expect_err("empty rejects");
        assert_eq!(err, IdVerifyError::InvalidFormat);
    }

    #[tokio::test]
    async fn verify_two_segment_token_yields_invalid_format() {
        // JWS Compact requires exactly three segments. A two-segment
        // token must reject at the SDK boundary, not reach the engine.
        let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
            Expectations::new("accounts.ppoppo.com", "rp-client-id"),
        );
        let nonce = Nonce::new("test-nonce").unwrap();
        let err = verifier
            .verify("aaa.bbb", &nonce)
            .await
            .expect_err("2-segment rejects");
        assert_eq!(err, IdVerifyError::InvalidFormat);
    }

    #[test]
    fn from_auth_error_covers_oidc_specific_rows() {
        // Sample of M66-M73 + M29-mirror — every dedicated variant
        // routes to the matching IdVerifyError row.
        assert_eq!(
            IdVerifyError::from(AuthError::NonceMissing),
            IdVerifyError::NonceMissing
        );
        assert_eq!(
            IdVerifyError::from(AuthError::AzpMismatch),
            IdVerifyError::AzpMismatch
        );
        assert_eq!(
            IdVerifyError::from(AuthError::CatMismatch("access".to_owned())),
            IdVerifyError::CatMismatch("access".to_owned())
        );
        assert_eq!(
            IdVerifyError::from(AuthError::UnknownClaim("backdoor".to_owned())),
            IdVerifyError::UnknownClaim("backdoor".to_owned())
        );
        // NonceConfigEmpty (engine construction-time invariant) maps
        // to MissingClaim — verify-time SDK contract is "the nonce
        // claim is required and the value passed in must be non-empty".
        assert_eq!(
            IdVerifyError::from(AuthError::NonceConfigEmpty),
            IdVerifyError::MissingClaim("nonce")
        );
    }

    #[test]
    fn from_auth_error_collapses_jose_to_signature_invalid_or_invalid_format() {
        // JOSE algorithm + header rejections collapse to SignatureInvalid
        // (mirrors `token::jwt::map_auth_error`). Serialization-shape
        // rejections collapse to InvalidFormat.
        assert_eq!(
            IdVerifyError::from(AuthError::Jose(SharedAuthError::AlgNone)),
            IdVerifyError::SignatureInvalid
        );
        assert_eq!(
            IdVerifyError::from(AuthError::Jose(SharedAuthError::KidUnknown)),
            IdVerifyError::SignatureInvalid
        );
        assert_eq!(
            IdVerifyError::from(AuthError::Jose(SharedAuthError::TypMismatch)),
            IdVerifyError::SignatureInvalid
        );
        assert_eq!(
            IdVerifyError::from(AuthError::Jose(SharedAuthError::OversizedToken)),
            IdVerifyError::InvalidFormat
        );
        assert_eq!(
            IdVerifyError::from(AuthError::Jose(SharedAuthError::JwsJsonRejected)),
            IdVerifyError::InvalidFormat
        );
    }
}
