//! OIDC RP composition root.
//!
//! [`RelyingParty<S>`] is the deep-module entry point for OAuth + OIDC
//! integration. It hides ALL SDK-internal collaborators behind 3
//! lifecycle methods:
//!
//! - [`new`](RelyingParty::new) — discovery + JWKS bootstrap +
//!   internal `oauth::AuthClient` + [`PasIdTokenVerifier<S>`]
//!   composition. One call at consumer boot.
//! - [`start`](RelyingParty::start) — generate state + nonce + PKCE,
//!   persist in the [`StateStore`], return [`AuthorizationRedirect`].
//! - [`complete`](RelyingParty::complete) — atomic state-take + token
//!   exchange + id_token verify, return [`Completion<S>`].
//!
//! The consumer never directly imports `oauth::AuthClient` or
//! [`PasIdTokenVerifier<S>`]; the only port the consumer implements
//! is [`StateStore`] (the OIDC-specific atomic single-use invariant).
//! Discovery + JWKS + PKCE + nonce + URL building are all in-process
//! composition that earned its place inside this struct because the
//! caller cannot meaningfully customize them.
//!
//! ── 3 methods, 4 hidden collaborators ───────────────────────────────────
//!
//! | Hidden inside `RelyingParty<S>` | Surfaced to consumer |
//! |---------------------------------|----------------------|
//! | [`super::discovery::fetch_discovery`] (boot)         | none |
//! | [`PasIdTokenVerifier`] (boot + complete)             | none |
//! | `oauth::AuthClient` (boot + complete)                | none |
//! | [`crate::pkce`] (every `start`)                      | none |
//! | nonce generation (every `start`)                     | none |
//! | state generation (every `start`)                     | [`State`] (round-trip key only) |
//!
//! ── Scope contract ──────────────────────────────────────────────────────
//!
//! The marker `S` parameter does triple duty:
//!
//! 1. Determines the requested-scope string sent to PAS at `start`
//!    (via [`RequestedScope::SCOPE`]).
//! 2. Carries through to the verifier, narrowing the post-verify
//!    [`super::IdAssertion<S>`] PII surface (Phase 10's marker traits
//!    `HasEmail` / `HasProfile` / `HasPhone` / `HasAddress`).
//! 3. Propagates into [`Completion<S>`] so the consumer's typed
//!    handler signature mirrors the requested scope without runtime
//!    re-derivation.
//!
//! Asking for a scope at construction time and being unable to read
//! claims outside that scope is a single architectural decision
//! enforced at compile time.

use std::sync::Arc;

use ppoppo_token::id_token::scopes::{
    Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
};
use ppoppo_token::id_token::Nonce;
use time::OffsetDateTime;
use url::Url;

use super::{
    discovery::{fetch_discovery, Discovery, DiscoveryError},
    port::{IdTokenVerifier, IdVerifyError, ScopePiiReader},
    refresh_outcome::RefreshOutcome,
    state_store::{
        AuthorizationRedirect, CallbackParams, Completion, Config, PendingAuthRequest,
        RelativePath, State, StateStore, StateStoreError,
    },
    verifier::PasIdTokenVerifier,
};
use crate::oauth::{AuthClient, OAuthConfig};
use crate::pkce;
use crate::token::Expectations;

// ────────────────────────────────────────────────────────────────────────
// RequestedScope — S → scope-string mapping
// ────────────────────────────────────────────────────────────────────────

/// Scope marker → scope-parameter mapping for the OIDC `scope` query
/// parameter sent to PAS at `start`.
///
/// Phase 10's [`ScopePiiReader`] gates which PII fields the verifier
/// hydrates (and therefore which accessors compile on the resulting
/// [`super::IdAssertion<S>`]). [`RequestedScope`] is the *strictly
/// stronger* trait the RP requires: every scope marker also has a
/// canonical string sent on the wire. The two ends meet —
/// `S = scopes::Email` causes both `scope=openid email` to be sent
/// AND `assertion.email()` to compile, with the same single type
/// parameter wiring the contract end-to-end.
///
/// **Adding a scope**: implement [`RequestedScope`] for the engine's
/// new marker type with the exact wire string. The RP does not
/// validate the string against any allowlist — PAS rejects unknown
/// scopes at the authorize endpoint.
pub trait RequestedScope: ScopePiiReader {
    /// The exact `scope` parameter value sent to PAS, space-separated
    /// per RFC 6749 §3.3.
    const SCOPE: &'static str;
}

impl RequestedScope for Openid {
    const SCOPE: &'static str = "openid";
}
impl RequestedScope for Email {
    const SCOPE: &'static str = "openid email";
}
impl RequestedScope for Profile {
    const SCOPE: &'static str = "openid profile";
}
impl RequestedScope for EmailProfile {
    const SCOPE: &'static str = "openid email profile";
}
impl RequestedScope for EmailProfilePhone {
    const SCOPE: &'static str = "openid email profile phone";
}
impl RequestedScope for EmailProfilePhoneAddress {
    const SCOPE: &'static str = "openid email profile phone address";
}

// ────────────────────────────────────────────────────────────────────────
// RelyingParty
// ────────────────────────────────────────────────────────────────────────

/// OAuth + OIDC Relying Party composition root.
///
/// `S: RequestedScope` propagates the scope contract from the
/// requested-scope wire parameter through Phase 10's
/// [`PasIdTokenVerifier<S>`] into the post-verify
/// [`Completion<S>`]. Constructing
/// `RelyingParty::<scopes::Email>` narrows the scope-bounded PII
/// available on the post-login assertion to the email scope claims.
///
/// Wrap in `Arc` for axum state — internals are mostly `Arc`-shared
/// (JWKS cache, reqwest client). The consumer typically constructs
/// once at boot and stores `Arc<RelyingParty<S>>` in app state.
pub struct RelyingParty<S: RequestedScope> {
    config: Config,
    state_store: Arc<dyn StateStore>,
    auth_client: AuthClient,
    verifier: Arc<dyn IdTokenVerifier<S>>,
    discovery: Discovery,
}

// `Arc<dyn StateStore>` / `AuthClient` / `Arc<dyn IdTokenVerifier<S>>`
// don't trivially `Debug`. Manual impl shows only the non-sensitive
// boot-time configuration so consumers can log RP state without
// risking credential exposure.
impl<S: RequestedScope> std::fmt::Debug for RelyingParty<S> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RelyingParty")
            .field("config", &self.config)
            .field("discovery", &self.discovery)
            .finish_non_exhaustive()
    }
}

/// Construction-time failure surface.
#[derive(Debug, thiserror::Error)]
pub enum RelyingPartyInitError {
    #[error("OIDC discovery fetch failed: {0}")]
    Discovery(#[from] DiscoveryError),
    #[error("JWKS fetch failed: {0}")]
    Jwks(IdVerifyError),
    #[error("OAuth client construction failed: {0}")]
    OAuthClient(String),
}

/// `start` failure surface.
#[derive(Debug, thiserror::Error)]
pub enum StartError {
    #[error("state store failure: {0}")]
    StateStore(#[from] StateStoreError),
    #[error("authorize URL construction failed: {0}")]
    UrlBuild(String),
}

/// `refresh` failure surface.
///
/// Mirrors [`CallbackError`]'s split between credential-rejection and
/// substrate-failure: 4xx responses indicate a dead refresh_token (the
/// consumer should clear the session cookies and force re-auth); 5xx
/// or transport failures are transient (the consumer may retry).
#[derive(Debug, thiserror::Error)]
pub enum RefreshError {
    /// PAS rejected the refresh_token (4xx). Dead credential — the
    /// consumer must clear the session and start a new authorization
    /// flow. Token rotation is a no-op when there is no live token to
    /// rotate.
    #[error("refresh_token rejected by PAS: {0}")]
    Rejected(String),

    /// PAS service is degraded (5xx) or transport-level failure
    /// (timeout, TLS, DNS). The session may still be live; the
    /// consumer should fail-soft (e.g., return 503 to the browser
    /// rather than clearing cookies).
    #[error("refresh transient failure: {0}")]
    Transient(String),
}

/// `complete` failure surface.
///
/// `StateNotFoundOrConsumed` is the load-bearing CSRF / state-replay
/// defense: state-store substrate atomicity guarantees that a second
/// `complete` call with the same `state` lands here regardless of
/// whether the first call succeeded or failed late.
#[derive(Debug, thiserror::Error)]
pub enum CallbackError {
    /// State key absent from substrate at callback. Indistinguishable
    /// across "never existed", "already consumed", "TTL-expired" — all
    /// three are CSRF-equivalent and intentionally collapse into one
    /// variant.
    #[error("state not found or already consumed (CSRF defense triggered)")]
    StateNotFoundOrConsumed,

    #[error("state store failure: {0}")]
    StateStore(#[from] StateStoreError),

    #[error("token exchange failed: {0}")]
    TokenExchange(String),

    #[error("id_token verification failed: {0}")]
    IdToken(#[from] IdVerifyError),
}

impl<S: RequestedScope> RelyingParty<S> {
    /// Construct a fully-composed RP.
    ///
    /// At boot:
    /// 1. Fetch the OIDC discovery document from
    ///    `<config.issuer>/.well-known/openid-configuration`.
    /// 2. Fetch the JWKS from the discovery's `jwks_uri` and seed
    ///    [`PasIdTokenVerifier<S>`] with it.
    /// 3. Construct the internal `oauth::AuthClient` using the
    ///    discovery's `authorization_endpoint` + `token_endpoint`.
    /// 4. Store all components for the lifetime of the RP.
    ///
    /// # Errors
    ///
    /// - [`RelyingPartyInitError::Discovery`] — discovery fetch failed
    /// - [`RelyingPartyInitError::Jwks`] — JWKS fetch failed
    /// - [`RelyingPartyInitError::OAuthClient`] — reqwest client build failed
    pub async fn new(
        config: Config,
        state_store: Arc<dyn StateStore>,
    ) -> Result<Self, RelyingPartyInitError> {
        let discovery = fetch_discovery(&config.issuer).await?;

        let expectations = Expectations::new(
            discovery.issuer.as_str(),
            config.client_id.clone(),
        );
        let verifier_concrete: PasIdTokenVerifier<S> =
            PasIdTokenVerifier::from_jwks_url(discovery.jwks_uri.to_string(), expectations)
                .await
                .map_err(RelyingPartyInitError::Jwks)?;
        let verifier: Arc<dyn IdTokenVerifier<S>> = Arc::new(verifier_concrete);

        let oauth_config = OAuthConfig::new(
            config.client_id.clone(),
            config.redirect_uri.clone(),
        )
        .with_auth_url(discovery.authorization_endpoint.clone())
        .with_token_url(discovery.token_endpoint.clone());
        let auth_client = AuthClient::try_new(oauth_config)
            .map_err(|e| RelyingPartyInitError::OAuthClient(e.to_string()))?;

        Ok(Self {
            config,
            state_store,
            auth_client,
            verifier,
            discovery,
        })
    }

    /// Begin an authorization flow.
    ///
    /// Generates fresh state + nonce + PKCE, persists the
    /// [`PendingAuthRequest`] under the state key (TTL =
    /// `config.state_ttl`), and returns the authorize URL the
    /// consumer should redirect the browser to.
    ///
    /// `after_login` is the post-login redirect target; the
    /// [`RelativePath`] newtype prevents open-redirect (RFC 9700
    /// §4.1.5) at the SDK boundary.
    ///
    /// # Errors
    ///
    /// - [`StartError::StateStore`] — substrate failure during `put`
    /// - [`StartError::UrlBuild`] — authorize URL serialization failed
    pub async fn start(
        &self,
        after_login: RelativePath,
    ) -> Result<AuthorizationRedirect, StartError> {
        let state_str = pkce::generate_state();
        let code_verifier = pkce::generate_code_verifier();
        let code_challenge = pkce::generate_code_challenge(&code_verifier);
        let nonce = pkce::generate_state();

        let state = State::from_string(state_str.clone());
        let pending = PendingAuthRequest {
            code_verifier: code_verifier.clone(),
            nonce: nonce.clone(),
            after_login,
            created_at: OffsetDateTime::now_utc(),
        };
        self.state_store
            .put(&state, pending, self.config.state_ttl)
            .await?;

        let url = build_authorize_url(
            &self.discovery.authorization_endpoint,
            &self.config.client_id,
            &self.config.redirect_uri,
            &state_str,
            &code_challenge,
            S::SCOPE,
            &nonce,
        );

        Ok(AuthorizationRedirect { url, state })
    }

    /// Complete a callback.
    ///
    /// Atomically `take`s the pending state, exchanges the
    /// authorization `code` for tokens, verifies the returned
    /// id_token against the stored nonce, and returns the verified
    /// [`Completion<S>`].
    ///
    /// # Errors
    ///
    /// - [`CallbackError::StateNotFoundOrConsumed`] — CSRF defense
    ///   (state absent or already consumed)
    /// - [`CallbackError::StateStore`] — substrate failure during
    ///   `take`
    /// - [`CallbackError::TokenExchange`] — PAS token endpoint
    ///   rejected
    /// - [`CallbackError::IdToken`] — id_token verification failed
    pub async fn complete(
        &self,
        params: CallbackParams,
    ) -> Result<Completion<S>, CallbackError> {
        // 1. Atomic state-take — single-use enforcement
        let pending = self
            .state_store
            .take(&params.state)
            .await?
            .ok_or(CallbackError::StateNotFoundOrConsumed)?;

        // 2. Token exchange
        let tokens = self
            .auth_client
            .exchange_code(&params.code, &pending.code_verifier)
            .await
            .map_err(|e| CallbackError::TokenExchange(e.to_string()))?;

        // 3. Read id_token from response (OIDC Core §3.1.3.3 — required
        //    when scope includes `openid`, which RequestedScope's
        //    `SCOPE` const always does)
        let id_token = tokens.id_token.as_deref().ok_or_else(|| {
            CallbackError::TokenExchange("token response missing id_token".to_owned())
        })?;

        // 4. Verify id_token against the stored nonce
        let nonce = Nonce::new(pending.nonce.as_str())
            .map_err(|_| CallbackError::IdToken(IdVerifyError::NonceMismatch))?;
        let id_assertion = self.verifier.verify(id_token, &nonce).await?;

        Ok(Completion {
            id_assertion,
            tokens,
            redirect_to: pending.after_login,
        })
    }

    /// Refresh an existing PAS session.
    ///
    /// Exchanges a `refresh_token` for a fresh
    /// [`oauth::TokenResponse`] (new access_token, possibly rotated
    /// refresh_token, possibly new id_token). The consumer typically
    /// invokes this from a dedicated refresh endpoint — it is the
    /// SSOT for the OAuth refresh-grant exchange so that
    /// `chat-auth::rp` (and equivalent consumers) never reach into
    /// `oauth::AuthClient` directly.
    ///
    /// **No id_token verification is performed here.** Refresh
    /// responses MAY include an id_token (OIDC Core §12), but the
    /// consumer treats refresh as a session-extension mechanism, not a
    /// re-authentication event — the original
    /// [`Self::complete`] verified the user identity, and the cookie
    /// flow that calls this method already trusts the refresh_token
    /// it just decrypted.
    ///
    /// # Errors
    ///
    /// - [`RefreshError::Rejected`] — PAS returned 4xx; refresh_token
    ///   is dead, clear session and force re-auth.
    /// - [`RefreshError::Transient`] — 5xx or transport failure;
    ///   session may still be live, fail-soft.
    pub async fn refresh(
        &self,
        refresh_token: &str,
    ) -> Result<RefreshOutcome, RefreshError> {
        use crate::pas_port::{PasAuthPort, PasFailure};
        match self.auth_client.refresh(refresh_token).await {
            Ok(t) => Ok(RefreshOutcome::from(t)),
            Err(PasFailure::Rejected { detail, .. }) => Err(RefreshError::Rejected(detail)),
            Err(PasFailure::ServerError { detail, .. })
            | Err(PasFailure::Transport { detail }) => Err(RefreshError::Transient(detail)),
        }
    }
}

// ────────────────────────────────────────────────────────────────────────
// URL builder — extracted for boundary-test introspection
// ────────────────────────────────────────────────────────────────────────

/// Build the OIDC authorize URL.
///
/// Pulled out as a free function so the URL-shape boundary test can
/// re-derive the expected URL deterministically (given fixed inputs)
/// without round-tripping through `RelyingParty::start` (which
/// generates fresh randomness each call).
///
/// Order of query params is stable to ease test assertions, but
/// PAS does not depend on order (RFC 6749 §3.1).
fn build_authorize_url(
    authorization_endpoint: &Url,
    client_id: &str,
    redirect_uri: &Url,
    state: &str,
    code_challenge: &str,
    scope: &str,
    nonce: &str,
) -> Url {
    let mut url = authorization_endpoint.clone();
    url.query_pairs_mut()
        .append_pair("response_type", "code")
        .append_pair("client_id", client_id)
        .append_pair("redirect_uri", redirect_uri.as_str())
        .append_pair("state", state)
        .append_pair("code_challenge", code_challenge)
        .append_pair("code_challenge_method", "S256")
        .append_pair("scope", scope)
        .append_pair("nonce", nonce);
    url
}
