//! OIDC RP state-machine port + value types.
//!
//! ── Why a port ──────────────────────────────────────────────────────────
//!
//! State storage is the load-bearing CSRF / state-replay defense in the
//! OAuth + OIDC RP flow. The substrate must support atomic single-use
//! semantics (TOCTOU-free `put` + `take`). Production substrates: Redis
//! `EVAL` GET+DEL script (or Redis 6.2+ `GETDEL`), Postgres
//! `DELETE … RETURNING`, KVRocks `GETDEL`. Test substrate: in-memory
//! `tokio::sync::Mutex<HashMap>` held across both ops.
//!
//! Single port for OIDC because atomic single-use + TTL is OIDC-specific.
//! Other RP collaborators (`oauth::AuthClient`,
//! [`super::PasIdTokenVerifier`], discovery, JWKS) are in-process
//! composition hidden inside [`super::RelyingParty<S>`].

use std::time::Duration;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use url::Url;

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

// ────────────────────────────────────────────────────────────────────────
// Config
// ────────────────────────────────────────────────────────────────────────

/// PAS OAuth client + RP configuration.
///
/// Construction input to [`super::RelyingParty::new`]. Mirrors the
/// "public OAuth client" pattern (RCW / CTW precedent — no
/// client_secret; PKCE S256 mandatory). TTL knobs for state-store
/// entries are bundled here so a consumer that picks non-default
/// lifetimes does so once at boot, not at every `start`.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Config {
    pub client_id: String,
    pub redirect_uri: Url,
    pub issuer: Url,
    /// State entry TTL in the substrate. Default 10 minutes
    /// (RFC 9700 §4.1.2 guidance).
    pub state_ttl: Duration,
}

impl Config {
    pub fn new(client_id: impl Into<String>, redirect_uri: Url, issuer: Url) -> Self {
        Self {
            client_id: client_id.into(),
            redirect_uri,
            issuer,
            state_ttl: Duration::from_secs(600),
        }
    }

    #[must_use]
    pub fn with_state_ttl(mut self, ttl: Duration) -> Self {
        self.state_ttl = ttl;
        self
    }
}

// ────────────────────────────────────────────────────────────────────────
// State + RelativePath
// ────────────────────────────────────────────────────────────────────────

/// OAuth `state` parameter — random, opaque, single-use.
///
/// Generated fresh in [`super::RelyingParty::start`]; round-tripped
/// through PAS as a query parameter; matched at callback against the
/// stored [`PendingAuthRequest`] via atomic [`StateStore::take`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct State(String);

impl State {
    /// SDK constructor — used by [`super::RelyingParty::start`] for
    /// random generation, and by callback handlers for parsing the
    /// inbound query-string state. The value is treated as opaque
    /// bytes; no character-set validation here (substrate adapters
    /// must accept whatever the SDK generates).
    pub fn from_string(s: String) -> Self {
        Self(s)
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Post-login redirect target.
///
/// Newtype-enforced relative path — rejects any string parsing as an
/// absolute URL (scheme present), a protocol-relative URL (leading
/// `//`), or a non-rooted path (must start with `/`). Open-redirect
/// defense at the SDK boundary per RFC 9700 §4.1.5: the consumer's
/// `start_handler` constructs a `RelativePath` from inbound user data,
/// and the type system prevents the consumer from passing through an
/// adversary-controlled absolute URL by accident.
///
/// ```compile_fail,E0277
/// use pas_external::oidc::RelativePath;
///
/// // From `&str` is fallible — direct assignment requires `?` or `unwrap`.
/// fn _compile_fail(_p: &RelativePath) {}
/// let _: RelativePath = "https://evil.com".into();
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RelativePath(String);

impl RelativePath {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl Default for RelativePath {
    fn default() -> Self {
        Self("/".to_owned())
    }
}

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum RelativePathError {
    #[error("relative path must not be protocol-relative (e.g., '//host/path')")]
    ProtocolRelative,
    #[error("relative path must start with '/'")]
    NotRooted,
    #[error("relative path must not contain a scheme (e.g., 'https://...', 'javascript:')")]
    SchemePresent,
    #[error("relative path must not contain control characters")]
    ControlCharacters,
}

impl<'a> TryFrom<&'a str> for RelativePath {
    type Error = RelativePathError;

    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
        if value.starts_with("//") {
            return Err(RelativePathError::ProtocolRelative);
        }
        if !value.starts_with('/') {
            return Err(RelativePathError::NotRooted);
        }
        // Scheme defense: in a relative path, `:` cannot appear in the
        // path component (only in fragment / query). Inspect just the
        // path component (split off `?` / `#`) and reject any colon.
        let path_only = value.split(['?', '#']).next().unwrap_or(value);
        if path_only.contains(':') {
            return Err(RelativePathError::SchemePresent);
        }
        if value.chars().any(char::is_control) {
            return Err(RelativePathError::ControlCharacters);
        }
        Ok(Self(value.to_owned()))
    }
}

impl TryFrom<String> for RelativePath {
    type Error = RelativePathError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::try_from(value.as_str())
    }
}

// Custom Deserialize that runs `try_from` so a substrate that
// round-trips a `PendingAuthRequest` cannot smuggle an absolute URL
// through the deserialization edge. The Serialize derive above
// produces a plain string; this Deserialize re-validates on the way
// back in.
impl<'de> Deserialize<'de> for RelativePath {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        RelativePath::try_from(s).map_err(serde::de::Error::custom)
    }
}

// ────────────────────────────────────────────────────────────────────────
// PendingAuthRequest + AuthorizationRedirect + CallbackParams + Completion
// ────────────────────────────────────────────────────────────────────────

/// Stored state for an in-flight OIDC authorization request.
///
/// Created by [`super::RelyingParty::start`] and persisted via
/// [`StateStore::put`] under the [`State`] key. Atomically consumed by
/// [`StateStore::take`] at callback. Holds the data the callback needs
/// to complete: the PKCE verifier (round-trip with PAS), the nonce
/// (matched against id_token), and the post-login redirect target.
///
/// `code_verifier` and `nonce` are stored as plain strings; the engine
/// `Nonce` wrapper is constructed only at verify time. `created_at`
/// timestamps the `put` side; substrate-enforced TTL is the actual
/// expiry (this field is for audit / observability).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingAuthRequest {
    pub code_verifier: String,
    pub nonce: String,
    pub after_login: RelativePath,
    #[serde(with = "time::serde::rfc3339")]
    pub created_at: OffsetDateTime,
}

/// Authorize URL + state for the consumer to round-trip.
///
/// [`super::RelyingParty::start`] returns this; the consumer's
/// `start_handler` typically: (1) sets the state cookie from
/// `redirect.state`, (2) issues a 302 to `redirect.url`.
#[derive(Debug, Clone)]
pub struct AuthorizationRedirect {
    pub url: Url,
    pub state: State,
}

/// Callback query parameters from PAS.
///
/// PAS appends `?code=…&state=…` to the redirect_uri at successful
/// authentication; the consumer's `callback_handler` parses these from
/// the request and passes them to [`super::RelyingParty::complete`].
#[derive(Debug, Clone)]
pub struct CallbackParams {
    pub code: String,
    pub state: State,
}

/// Verified OIDC authentication outcome.
///
/// [`super::RelyingParty::complete`] returns this; the consumer's
/// `callback_handler` typically: (1) issues session cookies from
/// `tokens` (encrypting refresh_token via [`crate::TokenCipher`]),
/// (2) redirects to `redirect_to` (the `after_login` captured at
/// `start` time).
///
/// `id_assertion` is the verified identity (sub, iss, aud, exp, iat,
/// nonce, plus scope-bounded PII gated by `S`). `tokens` is the raw
/// OAuth response (access_token + refresh_token + expires_in).
/// `redirect_to` is the [`RelativePath`] round-tripped from `start`.
///
/// **Scope narrowing carries through to `id_assertion`**: a
/// `Completion<scopes::Openid>` cannot reach `email()` even via the
/// public `id_assertion` field, because [`IdAssertion::email`] itself
/// requires the `HasEmail` bound on `S`.
///
/// ```compile_fail,E0599
/// use pas_external::oidc::{Completion, Openid};
///
/// fn _compile_fail(c: &Completion<Openid>) -> &str {
///     c.id_assertion.email() // ERROR: method `email` requires `HasEmail`
/// }
/// ```
#[derive(Debug)]
pub struct Completion<S: ScopePiiReader> {
    pub id_assertion: IdAssertion<S>,
    pub tokens: crate::oauth::TokenResponse,
    pub redirect_to: RelativePath,
}

// ────────────────────────────────────────────────────────────────────────
// StateStore port
// ────────────────────────────────────────────────────────────────────────

/// Atomic single-use state-machine storage.
///
/// `put` writes a fresh [`PendingAuthRequest`] under a [`State`] key
/// with substrate-enforced TTL. `take` atomically reads-and-deletes —
/// a successful `take` MUST guarantee no other caller can also succeed
/// for the same key. This is the load-bearing CSRF / state-replay
/// defense (Phase 11.B audit).
///
/// **Substrate atomicity examples**:
/// - Redis: `EVAL` with a GET+DEL script, or Redis 6.2+ `GETDEL`
/// - Postgres: `DELETE FROM oidc_state WHERE state = $1 RETURNING …`
/// - KVRocks: `GETDEL` (Redis-compatible 6.2+ command)
/// - In-memory test: `tokio::sync::Mutex<HashMap>` held across both ops
#[async_trait]
pub trait StateStore: Send + Sync {
    /// Persist `pending` under `state` with `ttl`. Substrate must
    /// expire the entry server-side on TTL (no stale-state leakage).
    ///
    /// Failure modes: substrate-down, write-rejected, etc. Surfaces as
    /// [`StateStoreError`] in the consumer.
    async fn put(
        &self,
        state: &State,
        pending: PendingAuthRequest,
        ttl: Duration,
    ) -> Result<(), StateStoreError>;

    /// Atomically read-and-delete the entry under `state`. Returns
    /// `None` if the entry never existed, was already consumed, or
    /// expired. The three cases are intentionally indistinguishable
    /// to the caller — they all map to
    /// [`super::CallbackError::StateNotFoundOrConsumed`].
    async fn take(
        &self,
        state: &State,
    ) -> Result<Option<PendingAuthRequest>, StateStoreError>;
}

/// Substrate-level state-store failure.
///
/// Distinct from "state not found" (which is `Ok(None)` from `take`).
/// Indicates the substrate itself is unhealthy (network, auth,
/// serialization, etc.). Surfaces as
/// [`super::StartError::StateStore`] or
/// [`super::CallbackError::StateStore`] depending on which side hit
/// it.
#[derive(Debug, thiserror::Error)]
pub enum StateStoreError {
    #[error("state-store substrate failure: {0}")]
    Substrate(String),
    #[error("state-store serialization failure: {0}")]
    Serialization(String),
}

// ────────────────────────────────────────────────────────────────────────
// Tests — RelativePath rejection (open-redirect defense)
// ────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn relative_path_accepts_root() {
        let p = RelativePath::try_from("/").expect("rooted path accepted");
        assert_eq!(p.as_str(), "/");
    }

    #[test]
    fn relative_path_accepts_nested() {
        let p = RelativePath::try_from("/dashboard/settings").expect("nested accepted");
        assert_eq!(p.as_str(), "/dashboard/settings");
    }

    #[test]
    fn relative_path_accepts_query_and_fragment() {
        // `?` and `#` may carry colons (e.g., `?next=https://x`); this
        // is a relative path with a query string, not an absolute URL.
        let p = RelativePath::try_from("/x?y=1#z").expect("query+fragment accepted");
        assert_eq!(p.as_str(), "/x?y=1#z");
    }

    #[test]
    fn relative_path_rejects_https_scheme() {
        assert_eq!(
            RelativePath::try_from("https://evil.com"),
            Err(RelativePathError::NotRooted),
        );
    }

    #[test]
    fn relative_path_rejects_javascript_scheme() {
        // `javascript:` doesn't start with `/` — caught by the
        // NotRooted check before the colon-detector kicks in.
        assert_eq!(
            RelativePath::try_from("javascript:alert(1)"),
            Err(RelativePathError::NotRooted),
        );
    }

    #[test]
    fn relative_path_rejects_protocol_relative() {
        assert_eq!(
            RelativePath::try_from("//evil.com/path"),
            Err(RelativePathError::ProtocolRelative),
        );
    }

    #[test]
    fn relative_path_rejects_colon_smuggled_after_root() {
        // Adversary tries to construct a rooted path that nonetheless
        // smuggles a scheme: `/https:foo`. The colon inside the path
        // component triggers the SchemePresent rejection.
        assert_eq!(
            RelativePath::try_from("/https://x"),
            Err(RelativePathError::SchemePresent),
        );
    }

    #[test]
    fn relative_path_rejects_control_characters() {
        assert_eq!(
            RelativePath::try_from("/path\rwith\nnewline"),
            Err(RelativePathError::ControlCharacters),
        );
    }

    #[test]
    fn relative_path_serde_roundtrip_validates() {
        let p = RelativePath::try_from("/ok").unwrap();
        let json = serde_json::to_string(&p).unwrap();
        let back: RelativePath = serde_json::from_str(&json).unwrap();
        assert_eq!(back.as_str(), "/ok");
    }

    #[test]
    fn relative_path_deserialize_rejects_smuggled_scheme() {
        // A substrate (or attacker who controls deserialized JSON)
        // cannot bypass try_from by deserializing directly.
        let result: Result<RelativePath, _> = serde_json::from_str(r#""https://evil""#);
        assert!(result.is_err(), "smuggled absolute URL must reject on deserialize");
    }
}
