//! γ port — `IdTokenVerifier`, `IdAssertion`, `IdVerifyError`.
//!
//! The SDK's OIDC id_token verification surface, format-blind by design.
//! Consumers receive an [`IdAssertion<S>`] that exposes typed accessors
//! for the values they need (`sub`, `iss`, `aud`, `exp`, plus
//! scope-bounded PII via the marker traits in
//! [`ppoppo_token::id_token::scopes`]) without ever seeing the
//! underlying JWT or the `jsonwebtoken` / `ppoppo_token` types. Swapping
//! the production [`super::PasIdTokenVerifier<S>`] adapter for the
//! in-memory test adapter
//! ([`super::MemoryIdTokenVerifier<S>`](super::memory::MemoryIdTokenVerifier),
//! gated behind `test-support`) requires zero consumer changes — the
//! port is the contract.
//!
//! D-04 (locked γ, 2026-05-05): port-and-adapter SDK boundary; the
//! engine becomes the only place that knows the OIDC wire format.
//!
//! ── Why a separate port from `BearerVerifier` ───────────────────────────
//!
//! `BearerVerifier::verify(&self, bearer_token: &str)` and
//! `IdTokenVerifier::verify(&self, id_token: &str, expected_nonce: &Nonce)`
//! are not interchangeable. Engine docs: id_tokens authenticate the user
//! *to the RP*; access_tokens authorize the RP *to the resource server*
//! (OIDC Core §1.2 / RFC 9068 §1). Folding the two into a single port
//! would force every caller to disambiguate at the call site (Phase 6.1
//! audit Finding 4 rationale, transposed).

use std::marker::PhantomData;

use async_trait::async_trait;
use ppoppo_token::id_token::{
    AddressClaim, Claims, HasAddress, HasEmail, HasPhone, HasProfile, Nonce, ScopeSet,
    scopes::{
        Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
    },
};
use time::OffsetDateTime;

use crate::types::PpnumId;

/// Verification port for incoming OIDC id_tokens.
///
/// Implementations swap the cryptographic backend without altering the
/// caller's surface. The production [`super::PasIdTokenVerifier<S>`]
/// verifies PAS-issued id_tokens against a TTL-cached JWKS; the
/// test-support [`super::MemoryIdTokenVerifier<S>`] returns canned
/// [`IdAssertion<S>`] values keyed by the bare token string.
///
/// `verify` is async because the production adapter performs
/// stale-on-failure JWKS refresh inside the verify path, and any future
/// 3rd-party adapter is free to make HTTP calls. Caller middleware that
/// needs synchronous semantics wraps the call in `tokio::block_on`; the
/// port itself stays uniformly async.
///
/// **Per-request `expected_nonce`**: the RP mints a per-session nonce at
/// the auth-request boundary and stores it bound to the user's browser
/// session (cookie, etc). On callback, the same nonce is fed here as
/// `&Nonce` (engine validates non-empty at construction). The verifier
/// does NOT cache nonce — a single verifier instance handles many
/// concurrent sessions, each with its own nonce.
///
/// `M67` / `M68` access_token / authorization_code bindings (hybrid +
/// implicit flows) are *not* surfaced on this trait — they would force
/// every caller to pass `Option<&str>` for two rarely-used parameters.
/// When the first hybrid-flow consumer arrives, a sibling
/// `verify_with_bindings(...)` method on [`super::PasIdTokenVerifier<S>`]
/// (or a `VerifyRequest` builder) is the right shape; the trait stays
/// minimal until then.
#[async_trait]
pub trait IdTokenVerifier<S: ScopeSet>: Send + Sync {
    async fn verify(
        &self,
        id_token: &str,
        expected_nonce: &Nonce,
    ) -> Result<IdAssertion<S>, IdVerifyError>;
}

// ── Address — SDK-shaped mirror of engine's `AddressClaim` ──────────────
//
// β1 invariant: the engine type never crosses the SDK boundary. The
// engine's `AddressClaim` carries six `Option<String>` fields; the SDK
// mirrors that shape so a future engine-side change (added field,
// renamed field) is an SDK-side mapping update, not a consumer-API break.

/// OIDC Core 1.0 §5.1.1 — `address` is a structured claim, not a flat
/// string. All fields optional; an issuer may emit any subset.
///
/// SDK-shaped mirror of [`ppoppo_token::id_token::AddressClaim`]; the
/// engine type is intentionally not re-exported (γ port invariant).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Address {
    pub formatted: Option<String>,
    pub street_address: Option<String>,
    pub locality: Option<String>,
    pub region: Option<String>,
    pub postal_code: Option<String>,
    pub country: Option<String>,
}

impl From<&AddressClaim> for Address {
    fn from(c: &AddressClaim) -> Self {
        Self {
            formatted: c.formatted.clone(),
            street_address: c.street_address.clone(),
            locality: c.locality.clone(),
            region: c.region.clone(),
            postal_code: c.postal_code.clone(),
            country: c.country.clone(),
        }
    }
}

/// Verified id_token outcome, opaque to the underlying token format.
///
/// Internal storage holds SDK-shaped values (`PpnumId`,
/// `OffsetDateTime`, `Vec<String>` for aud, [`Address`] for address, and
/// `Option<String>` for the PII fields). No `into_inner` escape hatch by
/// design (β1 invariant — same rationale as Phase 6.1 audit Finding 4
/// for [`AuthSession`](crate::AuthSession)): every claim consumer code
/// might need is exposed as a typed accessor. If a future field is
/// needed, add an accessor here before the consumer ships — never widen
/// to raw claims.
///
/// **Scope-bounded PII**: the `email` / `email_verified` / `name` / etc.
/// accessors live in `impl<S: HasX>` blocks below. A
/// `IdAssertion<scopes::Openid>` carries no syntactic path to
/// `.email()` — the call doesn't compile. M72 is structurally enforced
/// at the SDK boundary just as it is in the engine.
///
/// **Compile_fail acceptance**:
///
/// ```compile_fail,E0599
/// use pas_external::oidc::{IdAssertion, Openid};
///
/// fn _compile_fail(a: &IdAssertion<Openid>) -> &str {
///     a.email() // ERROR: method `email` not in scope (requires HasEmail)
/// }
/// ```
///
/// Granting the `email` scope at construction time satisfies the bound:
///
/// ```ignore
/// use pas_external::oidc::{IdAssertion, Email};
///
/// fn _compiles(a: &IdAssertion<Email>) -> &str { a.email() }
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdAssertion<S: ScopeSet> {
    // ── Core (always present, per OIDC §2) ────────────────────────────────
    iss: String,
    sub: PpnumId,
    aud: Vec<String>,
    exp: OffsetDateTime,
    iat: OffsetDateTime,
    nonce: String,
    azp: Option<String>,
    auth_time: Option<OffsetDateTime>,
    acr: Option<String>,
    amr: Option<Vec<String>>,

    // ── PII — gated by scope-bounded accessor methods below ───────────────
    pub(crate) email: Option<String>,
    pub(crate) email_verified: Option<bool>,

    pub(crate) name: Option<String>,
    pub(crate) given_name: Option<String>,
    pub(crate) family_name: Option<String>,
    pub(crate) middle_name: Option<String>,
    pub(crate) nickname: Option<String>,
    pub(crate) preferred_username: Option<String>,
    pub(crate) profile: Option<String>,
    pub(crate) picture: Option<String>,
    pub(crate) website: Option<String>,
    pub(crate) gender: Option<String>,
    pub(crate) birthdate: Option<String>,
    pub(crate) zoneinfo: Option<String>,
    pub(crate) locale: Option<String>,
    pub(crate) updated_at: Option<OffsetDateTime>,

    pub(crate) phone_number: Option<String>,
    pub(crate) phone_number_verified: Option<bool>,

    pub(crate) address: Option<Address>,

    pub(crate) _scope: PhantomData<S>,
}

impl<S: ScopeSet> IdAssertion<S> {
    /// Build from typed components. SDK-internal —
    /// [`super::PasIdTokenVerifier<S>`] constructs after engine
    /// `verify::<S>` returns; [`super::MemoryIdTokenVerifier<S>`]
    /// constructs in test setup. Marked `pub(crate)` so external
    /// adapters cannot fabricate assertions outside the SDK's
    /// verification path.
    ///
    /// PII fields default to `None`; per-scope hydration happens through
    /// [`ScopePiiReader::fill_pii`] for production paths and
    /// `for_test*` builders for test paths.
    ///
    /// `dead_code` allowed because under just `feature = "token"` (no
    /// `well-known-fetch`, no `test-support`) only the `for_test`
    /// constructor reaches it (and that one is `cfg`-gated). The
    /// production [`PasIdTokenVerifier`](super::PasIdTokenVerifier)
    /// adapter (Phase 10.11.C) is the third call site; until that
    /// lands, the constructor exists for symmetry with
    /// [`AuthSession::new`](crate::AuthSession).
    #[allow(clippy::too_many_arguments, dead_code)]
    pub(crate) fn new_base(
        iss: String,
        sub: PpnumId,
        aud: Vec<String>,
        exp: OffsetDateTime,
        iat: OffsetDateTime,
        nonce: String,
        azp: Option<String>,
        auth_time: Option<OffsetDateTime>,
        acr: Option<String>,
        amr: Option<Vec<String>>,
    ) -> Self {
        Self {
            iss,
            sub,
            aud,
            exp,
            iat,
            nonce,
            azp,
            auth_time,
            acr,
            amr,
            email: None,
            email_verified: None,
            name: None,
            given_name: None,
            family_name: None,
            middle_name: None,
            nickname: None,
            preferred_username: None,
            profile: None,
            picture: None,
            website: None,
            gender: None,
            birthdate: None,
            zoneinfo: None,
            locale: None,
            updated_at: None,
            phone_number: None,
            phone_number_verified: None,
            address: None,
            _scope: PhantomData,
        }
    }

    /// Test-support constructor — minimal openid-scope assertion. PII
    /// fields default to `None`; scope-bounded `for_test_with_*`
    /// builders (gated behind the relevant marker traits) layer PII on
    /// top.
    #[cfg(any(test, feature = "test-support"))]
    #[allow(clippy::too_many_arguments)]
    #[must_use]
    pub fn for_test(
        iss: impl Into<String>,
        sub: PpnumId,
        aud: Vec<String>,
        exp: OffsetDateTime,
        iat: OffsetDateTime,
        nonce: impl Into<String>,
    ) -> Self {
        Self::new_base(
            iss.into(),
            sub,
            aud,
            exp,
            iat,
            nonce.into(),
            None,
            None,
            None,
            None,
        )
    }

    // ── Always-on accessors (any S: ScopeSet) ─────────────────────────────

    /// Issuer (`iss` claim). Stable identity of the OP that minted this
    /// id_token. Consumer middleware verifies it equals the configured
    /// expected issuer (e.g. `accounts.ppoppo.com`) — the engine has
    /// already enforced this in [`IdTokenVerifier::verify`], so the
    /// value is informational by the time it reaches consumer code.
    #[must_use]
    pub fn iss(&self) -> &str {
        &self.iss
    }

    /// Subject identifier (`sub` claim). PAS issues ULIDs for human
    /// users (mirror of [`AuthSession::ppnum_id`](crate::AuthSession)).
    /// Trust decisions key off this stable identifier; downstream
    /// `ppnum` (digit-form) is display-only and not surfaced on
    /// id_tokens (id_tokens carry only the OIDC-canonical `sub`).
    #[must_use]
    pub fn sub(&self) -> &PpnumId {
        &self.sub
    }

    /// Audience (`aud` claim). OIDC Core §2 permits a string OR an
    /// array; the engine normalizes to a `Vec`. Multi-aud tokens MUST
    /// also carry `azp` (M69) — already enforced by the engine.
    #[must_use]
    pub fn aud(&self) -> &[String] {
        &self.aud
    }

    /// Expiry (`exp` claim) as a wall-clock instant. The engine has
    /// already enforced expiry; this value is informational.
    #[must_use]
    pub fn exp(&self) -> OffsetDateTime {
        self.exp
    }

    /// Issued-at (`iat` claim) as a wall-clock instant.
    #[must_use]
    pub fn iat(&self) -> OffsetDateTime {
        self.iat
    }

    /// Per-session nonce (`nonce` claim). The engine has already
    /// matched this against the RP-stored `expected_nonce` (M66); this
    /// accessor exposes the on-wire value for consumer-side echo /
    /// audit logging.
    #[must_use]
    pub fn nonce(&self) -> &str {
        &self.nonce
    }

    /// Authorized party (`azp` claim, OIDC §2). Present whenever the
    /// IdP asserts a multi-aud or sibling-client scenario; equals the
    /// RP's `client_id` when present (M69 enforced engine-side).
    #[must_use]
    pub fn azp(&self) -> Option<&str> {
        self.azp.as_deref()
    }

    /// Authentication time (`auth_time` claim). When the RP configured
    /// `max_age`, the engine has already enforced
    /// `now - auth_time <= max_age` (M70); the accessor exposes the
    /// raw value for consumer-side step-up logic.
    #[must_use]
    pub fn auth_time(&self) -> Option<OffsetDateTime> {
        self.auth_time
    }

    /// Authentication Context Class Reference (`acr` claim, OIDC §2).
    /// When the RP configured `acr_values`, the engine has already
    /// enforced membership (M71).
    #[must_use]
    pub fn acr(&self) -> Option<&str> {
        self.acr.as_deref()
    }

    /// Authentication Methods References (`amr` claim, e.g. `["pwd",
    /// "mfa"]`). OIDC §2 — informational, no engine-side enforcement.
    #[must_use]
    pub fn amr(&self) -> Option<&[String]> {
        self.amr.as_deref()
    }
}

// ── Scope-bounded accessor blocks ───────────────────────────────────────
//
// Reading these top-down: each `impl<S: HasX>` block exposes exactly
// the field set OIDC §5.4 binds to scope `X`. Mirror of
// `ppoppo_token::id_token::Claims<S>` accessor catalog, with SDK-shaped
// types (`OffsetDateTime` for `updated_at`, [`Address`] for `address`).
//
// Adding a new claim inside a scope is one accessor here (and one
// hydration line in the matching [`fill_*`] helper below); adding a new
// scope is a re-export in `oidc/mod.rs` plus one more
// [`ScopePiiReader`] impl.

/// `email` scope — OIDC §5.4.
impl<S: HasEmail> IdAssertion<S> {
    /// `email` is REQUIRED if the issuer emits the email scope at all
    /// (OIDC §5.4). Engine deserialization populates `Some(_)` when the
    /// wire contains the claim; the accessor unwraps via `expect()`
    /// because reaching this method bound (`S: HasEmail`) already
    /// proves the IdP honored the scope. A missing email on a
    /// `HasEmail` token is an issuer drift, surfaced as a panic so the
    /// regression is loud — *if* this path is reachable in production.
    /// Engine Phase 10.8 (M72) verify-time rejection makes the panic
    /// structurally unreachable; the SDK gets the fix transitively.
    #[must_use]
    pub fn email(&self) -> &str {
        self.email
            .as_deref()
            .expect("HasEmail bound implies email Some — IdP drift if absent")
    }

    #[must_use]
    pub fn email_verified(&self) -> Option<bool> {
        self.email_verified
    }
}

/// `profile` scope — OIDC §5.4 (name / locale / updated_at family).
impl<S: HasProfile> IdAssertion<S> {
    #[must_use]
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    #[must_use]
    pub fn given_name(&self) -> Option<&str> {
        self.given_name.as_deref()
    }

    #[must_use]
    pub fn family_name(&self) -> Option<&str> {
        self.family_name.as_deref()
    }

    #[must_use]
    pub fn middle_name(&self) -> Option<&str> {
        self.middle_name.as_deref()
    }

    #[must_use]
    pub fn nickname(&self) -> Option<&str> {
        self.nickname.as_deref()
    }

    #[must_use]
    pub fn preferred_username(&self) -> Option<&str> {
        self.preferred_username.as_deref()
    }

    #[must_use]
    pub fn profile(&self) -> Option<&str> {
        self.profile.as_deref()
    }

    #[must_use]
    pub fn picture(&self) -> Option<&str> {
        self.picture.as_deref()
    }

    #[must_use]
    pub fn website(&self) -> Option<&str> {
        self.website.as_deref()
    }

    #[must_use]
    pub fn gender(&self) -> Option<&str> {
        self.gender.as_deref()
    }

    #[must_use]
    pub fn birthdate(&self) -> Option<&str> {
        self.birthdate.as_deref()
    }

    #[must_use]
    pub fn zoneinfo(&self) -> Option<&str> {
        self.zoneinfo.as_deref()
    }

    #[must_use]
    pub fn locale(&self) -> Option<&str> {
        self.locale.as_deref()
    }

    /// `updated_at` claim. Engine deserializes from `i64` Unix seconds
    /// to [`OffsetDateTime`] at construction; the SDK accessor surfaces
    /// the wall-clock instant directly (consumer code does not need to
    /// re-convert from epoch).
    #[must_use]
    pub fn updated_at(&self) -> Option<OffsetDateTime> {
        self.updated_at
    }
}

/// `phone` scope — OIDC §5.4.
impl<S: HasPhone> IdAssertion<S> {
    #[must_use]
    pub fn phone_number(&self) -> Option<&str> {
        self.phone_number.as_deref()
    }

    #[must_use]
    pub fn phone_number_verified(&self) -> Option<bool> {
        self.phone_number_verified
    }
}

/// `address` scope — OIDC §5.4 (single structured claim).
impl<S: HasAddress> IdAssertion<S> {
    #[must_use]
    pub fn address(&self) -> Option<&Address> {
        self.address.as_ref()
    }
}

// ── Per-scope PII hydration trait ───────────────────────────────────────
//
// The conversion from engine [`Claims<S>`] to SDK [`IdAssertion<S>`]
// can't read scope-bounded PII fields generically (Rust has no
// specialization on stable). Each scope marker provides its own
// hydration recipe via [`ScopePiiReader`]; the production verifier
// dispatches via the trait at monomorphization. Adding a new scope is
// one impl block here.

/// Per-scope PII hydration. Implemented for each engine scope marker;
/// the production [`super::PasIdTokenVerifier<S>`] uses
/// `S::fill_pii(&claims, &mut assertion)` after building the base
/// assertion to layer in scope-bounded fields.
///
/// **Why a sibling trait, not a method on [`ScopeSet`]**: engine's
/// [`ScopeSet`] is sealed (only the engine adds variants). The SDK
/// can't extend the engine trait, but it can define its own trait whose
/// bound is `Self: ScopeSet` and provide impls per-scope by name.
///
/// **Bound rationale**:
/// - `Sized`: helper functions take `&Claims<Self>` by reference.
/// - `Send + Sync + 'static`: `PhantomData<S>` inside
///   [`super::PasIdTokenVerifier<S>`] must be `Send + Sync` for
///   `#[async_trait]` to produce a `Send + 'static` future.
/// - `Clone + Debug + PartialEq + Eq`: lifted to make
///   `#[derive(...)]` on [`IdAssertion<S>`] generate impls without
///   per-impl boilerplate. Rust's conservative derive adds
///   `S: Trait` bounds for each generic type parameter (even when only
///   `PhantomData<S>` carries it), so the trait must promise the
///   bounds. Every engine scope marker is a `Copy` unit struct
///   `#[derive(Debug, Clone, Copy, PartialEq, Eq)]`, so all of these
///   are auto-satisfied.
pub trait ScopePiiReader:
    ScopeSet + Sized + Clone + std::fmt::Debug + PartialEq + Eq + Send + Sync + 'static
{
    /// Read per-scope PII claims from the engine output into an SDK
    /// assertion already populated with base fields.
    fn fill_pii(claims: &Claims<Self>, assertion: &mut IdAssertion<Self>);
}

// Common scope-bounded helpers — composed into the per-scope impls
// below. Each helper requires the matching marker bound, which makes
// the engine's scope-bounded accessors reachable.

fn fill_email<S: HasEmail>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.email = Some(claims.email().to_owned());
    a.email_verified = claims.email_verified();
}

fn fill_profile<S: HasProfile>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.name = claims.name().map(str::to_owned);
    a.given_name = claims.given_name().map(str::to_owned);
    a.family_name = claims.family_name().map(str::to_owned);
    a.middle_name = claims.middle_name().map(str::to_owned);
    a.nickname = claims.nickname().map(str::to_owned);
    a.preferred_username = claims.preferred_username().map(str::to_owned);
    a.profile = claims.profile().map(str::to_owned);
    a.picture = claims.picture().map(str::to_owned);
    a.website = claims.website().map(str::to_owned);
    a.gender = claims.gender().map(str::to_owned);
    a.birthdate = claims.birthdate().map(str::to_owned);
    a.zoneinfo = claims.zoneinfo().map(str::to_owned);
    a.locale = claims.locale().map(str::to_owned);
    a.updated_at = claims
        .updated_at()
        .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok());
}

fn fill_phone<S: HasPhone>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.phone_number = claims.phone_number().map(str::to_owned);
    a.phone_number_verified = claims.phone_number_verified();
}

fn fill_address<S: HasAddress>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.address = claims.address().map(Address::from);
}

impl ScopePiiReader for Openid {
    fn fill_pii(_: &Claims<Self>, _: &mut IdAssertion<Self>) {}
}

impl ScopePiiReader for Email {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
    }
}

impl ScopePiiReader for Profile {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_profile(claims, a);
    }
}

impl ScopePiiReader for EmailProfile {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
        fill_profile(claims, a);
    }
}

impl ScopePiiReader for EmailProfilePhone {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
        fill_profile(claims, a);
        fill_phone(claims, a);
    }
}

impl ScopePiiReader for EmailProfilePhoneAddress {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
        fill_profile(claims, a);
        fill_phone(claims, a);
        fill_address(claims, a);
    }
}

// ── Test-support PII builders (gated) ────────────────────────────────────
//
// These mirror the engine's `IssueRequest<S>` builder shape on the
// SDK side: scope-bounded so a test cannot construct a `HasEmail`-only
// `IdAssertion` with profile/phone PII set. The full builder catalog
// (`with_given_name`, `with_locale`, etc.) is intentionally narrow —
// boundary tests cover the *structural* assertion that scope-bounded
// accessors return the populated values; engine `tests/id_token_round_trip.rs`
// covers the per-claim deserialization fidelity. Add a builder here
// only when a boundary test would otherwise be unable to exercise a
// scope-bound accessor at all.

#[cfg(any(test, feature = "test-support"))]
impl<S: ScopeSet> IdAssertion<S> {
    /// Test builder — populate `azp` (authorized party) for tests that
    /// exercise the IdP-asserted claim accessors.
    #[must_use]
    pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
        self.azp = Some(azp.into());
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasEmail> IdAssertion<S> {
    /// Test builder — populate `email` (and optional `email_verified`).
    #[must_use]
    pub fn with_email(mut self, email: impl Into<String>, verified: Option<bool>) -> Self {
        self.email = Some(email.into());
        self.email_verified = verified;
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasProfile> IdAssertion<S> {
    /// Test builder — populate `name`. Single-claim builder (boundary
    /// test exercises the `name()` accessor; engine round-trip tests
    /// cover the rest of the profile family).
    #[must_use]
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasPhone> IdAssertion<S> {
    /// Test builder — populate `phone_number` (and optional
    /// `phone_number_verified`).
    #[must_use]
    pub fn with_phone_number(
        mut self,
        phone: impl Into<String>,
        verified: Option<bool>,
    ) -> Self {
        self.phone_number = Some(phone.into());
        self.phone_number_verified = verified;
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasAddress> IdAssertion<S> {
    /// Test builder — populate `address`.
    #[must_use]
    pub fn with_address(mut self, address: Address) -> Self {
        self.address = Some(address);
        self
    }
}

// ── IdVerifyError ────────────────────────────────────────────────────────

/// id_token verification failure surface.
///
/// One variant per logical failure class; mirrors
/// [`VerifyError`](crate::token::VerifyError) for access tokens but
/// adds OIDC-specific rows (M66-M73 + M29-mirror `CatMismatch`). The
/// PAS-engine variants reflect the boundary contract: audit logs map
/// them 1:1 to engine [`ppoppo_token::id_token::AuthError`] rows.
/// Adapter-side variants (`InvalidFormat`) cover failures upstream of
/// engine entry.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum IdVerifyError {
    // ── Adapter-side rejection (upstream of engine entry) ───────────────
    /// Token did not parse as a JWS Compact serialization.
    #[error("invalid id_token format")]
    InvalidFormat,

    // ── JOSE-layer (shared with access_token) ───────────────────────────
    /// Cryptographic signature verification failed.
    #[error("signature verification failed")]
    SignatureInvalid,

    /// `exp` claim is in the past.
    #[error("id_token expired")]
    Expired,

    /// `iss` did not match the verifier's expected issuer.
    #[error("issuer invalid")]
    IssuerInvalid,

    /// `aud` did not match the verifier's expected audience.
    #[error("audience invalid")]
    AudienceInvalid,

    /// A required claim was absent or malformed.
    #[error("missing required claim: {0}")]
    MissingClaim(&'static str),

    /// JWKS fetch failed and there is no usable cached snapshot.
    #[error("keyset unavailable")]
    KeysetUnavailable,

    // ── OIDC-specific (M66-M73 + M29-mirror) ────────────────────────────
    /// M66 — `nonce` claim is absent from the id_token payload.
    #[error("M66: nonce claim absent from payload")]
    NonceMissing,

    /// M66 — payload `nonce` is present but does not match the
    /// `expected_nonce` the RP stored at the auth-request boundary.
    #[error("M66: nonce does not match expected value")]
    NonceMismatch,

    /// M67 — `at_hash` claim absent from payload while the verifier was
    /// configured with an expected access_token binding (hybrid +
    /// implicit flows).
    #[error("M67: at_hash claim absent from payload")]
    AtHashMissing,

    /// M67 — payload `at_hash` is present but does not match the
    /// expected access_token binding.
    #[error("M67: at_hash does not match expected access_token binding")]
    AtHashMismatch,

    /// M68 — `c_hash` claim absent while the verifier was configured
    /// with an expected authorization-code binding (hybrid flow).
    #[error("M68: c_hash claim absent from payload")]
    CHashMissing,

    /// M68 — payload `c_hash` is present but does not match the
    /// expected authorization-code binding.
    #[error("M68: c_hash does not match expected authorization_code binding")]
    CHashMismatch,

    /// M69 — `azp` claim absent on multi-audience id_token.
    #[error("M69: azp claim absent on multi-audience id_token")]
    AzpMissing,

    /// M69 — payload `azp` does not equal the RP's client_id.
    #[error("M69: azp does not match expected client_id")]
    AzpMismatch,

    /// M70 — `auth_time` claim absent while the verifier was configured
    /// with a `max_age` window.
    #[error("M70: auth_time claim absent while max_age is configured")]
    AuthTimeMissing,

    /// M70 — `now - auth_time > max_age`. The user authenticated too
    /// long ago for this RP's freshness policy.
    #[error("M70: auth_time exceeds max_age window — re-authentication required")]
    AuthTimeStale,

    /// M71 — `acr` claim absent while the verifier was configured with
    /// `acr_values`.
    #[error("M71: acr claim absent while acr_values is configured")]
    AcrMissing,

    /// M71 — payload `acr` not in the RP's `acr_values` allowlist.
    #[error("M71: acr value not in configured acr_values allowlist")]
    AcrNotAllowed,

    /// M72 — id_token payload contains a claim outside the per-scope
    /// allowlist. Carries the offending name for audit log
    /// disambiguation (forgery vs issuer drift).
    #[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
    UnknownClaim(String),

    /// M29-mirror — id_token payload carries a `cat` claim whose value
    /// is not `"id"`. Refuses access_token shapes presented to the
    /// id_token verifier (the symmetric counterpart to M73 on the
    /// access-token side). Carries the offending value.
    #[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
    CatMismatch(String),

    // ── Catch-all (preserves engine M-row identifier via Display) ──────
    /// Catch-all for engine variants that don't map to a structural
    /// SDK rejection. Carries the engine's [`AuthError`] Display so the
    /// audit log retains the precise M-code.
    #[error("verification failed: {0}")]
    Other(String),
}

#[cfg(test)]
mod tests {
    //! Port-level invariant tests. The boundary tests (live in
    //! `tests/id_token_verifier_boundary.rs`) exercise the production
    //! adapter; the unit tests below verify the static shape of the
    //! port surface.
    use super::*;
    use ppoppo_token::id_token::scopes;
    use ulid::Ulid;

    fn fixture_sub() -> PpnumId {
        PpnumId(
            Ulid::from_string("01HK0000000000000000000001")
                .expect("test ulid")
        )
    }

    #[test]
    fn for_test_constructor_yields_openid_assertion() {
        let now = OffsetDateTime::now_utc();
        let a: IdAssertion<scopes::Openid> = IdAssertion::for_test(
            "accounts.ppoppo.com",
            fixture_sub(),
            vec!["rp-client".to_owned()],
            now + time::Duration::hours(1),
            now,
            "n-0S6_WzA2Mj",
        );
        assert_eq!(a.iss(), "accounts.ppoppo.com");
        assert_eq!(a.aud(), &["rp-client".to_owned()]);
        assert_eq!(a.nonce(), "n-0S6_WzA2Mj");
        assert!(a.azp().is_none());
        assert!(a.auth_time().is_none());
    }

    #[test]
    fn with_email_builder_populates_pii() {
        let now = OffsetDateTime::now_utc();
        let a: IdAssertion<scopes::Email> = IdAssertion::for_test(
            "accounts.ppoppo.com",
            fixture_sub(),
            vec!["rp-client".to_owned()],
            now + time::Duration::hours(1),
            now,
            "nonce",
        )
        .with_email("user@example.com", Some(true));
        assert_eq!(a.email(), "user@example.com");
        assert_eq!(a.email_verified(), Some(true));
    }

    #[test]
    fn id_verify_error_display_preserves_m_codes() {
        // Audit log relies on Display to surface the M-row identifier
        // (caller routes structured fields, but Display is the human-
        // readable axis for grep over CloudLogging output).
        assert_eq!(
            format!("{}", IdVerifyError::NonceMismatch),
            "M66: nonce does not match expected value"
        );
        assert_eq!(
            format!("{}", IdVerifyError::CatMismatch("access".to_owned())),
            "M29-mirror: id_token cat must be 'id', got 'access'"
        );
        assert_eq!(
            format!("{}", IdVerifyError::AzpMismatch),
            "M69: azp does not match expected client_id"
        );
    }

    /// Compile-time guard: an `Arc<dyn IdTokenVerifier<S>>` is the
    /// runtime shape consumer middleware injects. If the trait's
    /// object-safety regresses, this won't compile.
    #[allow(dead_code)]
    fn dyn_object_safety<S: ScopeSet>() {
        fn _accept<S: ScopeSet>(_: std::sync::Arc<dyn IdTokenVerifier<S>>) {}
    }

    /// Compile-time check: every published scope marker has a
    /// [`ScopePiiReader`] impl. If a future scope is added to the
    /// re-exports without an impl, this fails to compile.
    #[allow(dead_code)]
    fn scope_pii_reader_impls_exist() {
        fn _accept<S: ScopePiiReader>() {}
        _accept::<Openid>();
        _accept::<Email>();
        _accept::<Profile>();
        _accept::<EmailProfile>();
        _accept::<EmailProfilePhone>();
        _accept::<EmailProfilePhoneAddress>();
    }
}
