//! γ port-and-adapter boundary tests for [`RelyingParty<S>`].
//!
//! Phase 11.Y — rewritten on top of [`pas_external::test_support::FakePasServer`]
//! after the deletion of `RelyingParty::for_test_with_parts`. The
//! tests now go through the same `RelyingParty::new(config, store)`
//! boot path that production uses; the only difference is that PAS
//! discovery + JWKS + token endpoints are served by an in-process
//! wiremock fake instead of the real Authorization Server.
//!
//! Coverage shape:
//!
//! - **Construction** — discovery fetch / JWKS fetch / issuer-match
//!   defense (#1, #2, #3).
//! - **Start** — state-store put failure, non-determinism, URL shape,
//!   pending round-trip (#4–#7).
//! - **Complete** — state-not-found, **state-already-consumed (LOAD
//!   BEARING — atomic single-use replay defense)**, take failure,
//!   token-exchange failure (#8–#11).
//! - **Refresh** — Rejected vs Transient classification (#12, #13).

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use pas_external::oidc::{
    AuthorizationRedirect, CallbackError, CallbackParams, Completion, Config, InMemoryStateStore,
    PendingAuthRequest, RefreshError, RelativePath, RelyingParty, RelyingPartyInitError,
    RequestedScope, State, StateStore, StateStoreError,
};
use pas_external::test_support::{FakePasServer, TokenExchangeBody};
use ppoppo_token::id_token::scopes;
use serde_json::json;
use std::time::Duration;
use url::Url;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

// ── Fixture helpers ──────────────────────────────────────────────────────

fn fixture_config(issuer: Url) -> Config {
    Config::new(
        "rp-client-id",
        "https://app.example.com/callback".parse().expect("redirect"),
        issuer,
    )
}

async fn rp_against_fake<S: RequestedScope>(
    fake: &FakePasServer,
) -> RelyingParty<S> {
    let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());
    RelyingParty::<S>::new(fixture_config(fake.issuer_url()), store)
        .await
        .expect("RelyingParty boots against FakePasServer")
}

// ── 1. Construction — discovery fetch failure ───────────────────────────

#[tokio::test]
async fn new_discovery_fetch_failed_yields_typed_error() {
    // .invalid is RFC 6761-reserved as guaranteed-unresolvable; the
    // discovery GET must fail at the transport layer.
    let issuer: Url = "http://nonexistent.invalid/".parse().expect("url");
    let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());

    let err = RelyingParty::<scopes::Email>::new(fixture_config(issuer), store)
        .await
        .expect_err("bad issuer must fail discovery");
    assert!(
        matches!(err, RelyingPartyInitError::Discovery(_)),
        "expected Discovery error, got {err:?}"
    );
}

// ── 2. Construction — JWKS fetch failure (discovery succeeds) ───────────

#[tokio::test]
async fn new_jwks_fetch_failed_yields_typed_error() {
    // Manual MockServer (not FakePasServer): we want discovery to
    // succeed but `jwks_uri` to point at an unresolvable URL so the
    // JWKS fetch is the failure point.
    let server = MockServer::start().await;
    let issuer = format!("{}/", server.uri());
    Mock::given(method("GET"))
        .and(path("/.well-known/openid-configuration"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "issuer": issuer.trim_end_matches('/'),
            "authorization_endpoint": format!("{issuer}oauth/authorize"),
            "token_endpoint": format!("{issuer}oauth/token"),
            "jwks_uri": "http://nonexistent.invalid/.well-known/jwks.json",
            "response_types_supported": ["code"],
            "subject_types_supported": ["public"],
            "id_token_signing_alg_values_supported": ["EdDSA"],
        })))
        .mount(&server)
        .await;

    let issuer_url: Url = issuer.parse().expect("url");
    let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());

    let err = RelyingParty::<scopes::Email>::new(fixture_config(issuer_url), store)
        .await
        .expect_err("bad jwks_uri must fail construction");
    assert!(
        matches!(err, RelyingPartyInitError::Jwks(_)),
        "expected Jwks error, got {err:?}"
    );
}

// ── 3. Construction — issuer-mismatch defense (RFC 8414 §3.3) ───────────

#[tokio::test]
async fn new_issuer_mismatch_yields_typed_error() {
    let server = MockServer::start().await;
    let bound_issuer = format!("{}/", server.uri());
    // Discovery doc returns an issuer claim that does NOT match the
    // bound URL — RFC 8414 §3.3 says the RP MUST reject this.
    Mock::given(method("GET"))
        .and(path("/.well-known/openid-configuration"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "issuer": "https://attacker.example.com",
            "authorization_endpoint": format!("{bound_issuer}oauth/authorize"),
            "token_endpoint": format!("{bound_issuer}oauth/token"),
            "jwks_uri": format!("{bound_issuer}.well-known/jwks.json"),
            "response_types_supported": ["code"],
            "subject_types_supported": ["public"],
            "id_token_signing_alg_values_supported": ["EdDSA"],
        })))
        .mount(&server)
        .await;

    let issuer_url: Url = bound_issuer.parse().expect("url");
    let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());

    let err = RelyingParty::<scopes::Email>::new(fixture_config(issuer_url), store)
        .await
        .expect_err("issuer mismatch must fail construction");
    assert!(
        matches!(err, RelyingPartyInitError::Discovery(_)),
        "issuer-mismatch surfaces as Discovery error variant, got {err:?}"
    );
}

// ── 4. Start — state-store put failure ──────────────────────────────────

/// In-memory state store that fails every `put` call. Used to verify
/// state-store error propagation through `RelyingParty::start`.
#[derive(Default)]
struct PutFailingStore;

#[async_trait]
impl StateStore for PutFailingStore {
    async fn put(
        &self,
        _state: &State,
        _pending: PendingAuthRequest,
        _ttl: Duration,
    ) -> Result<(), StateStoreError> {
        Err(StateStoreError::Substrate("simulated put failure".to_owned()))
    }

    async fn take(
        &self,
        _state: &State,
    ) -> Result<Option<PendingAuthRequest>, StateStoreError> {
        Ok(None)
    }
}

#[tokio::test]
async fn start_state_store_put_failure_propagates() {
    let fake = FakePasServer::start().await;
    let store: Arc<dyn StateStore> = Arc::new(PutFailingStore);
    let rp = RelyingParty::<scopes::Email>::new(fixture_config(fake.issuer_url()), store)
        .await
        .expect("boot");

    let err = rp
        .start(RelativePath::default())
        .await
        .expect_err("put failure must surface");
    assert!(
        matches!(err, pas_external::oidc::StartError::StateStore(_)),
        "expected StateStore variant, got {err:?}"
    );
}

// ── 5. Start — non-determinism (fresh state per call) ───────────────────

#[tokio::test]
async fn start_returns_distinct_state_per_call() {
    let fake = FakePasServer::start().await;
    let rp = rp_against_fake::<scopes::Email>(&fake).await;

    let r1: AuthorizationRedirect = rp.start(RelativePath::default()).await.expect("start1");
    let r2: AuthorizationRedirect = rp.start(RelativePath::default()).await.expect("start2");

    assert_ne!(
        r1.state.as_str(),
        r2.state.as_str(),
        "successive starts must produce distinct states"
    );
}

// ── 6. Start — authorize URL contains required OAuth + OIDC params ─────

#[tokio::test]
async fn start_authorize_url_contains_required_params() {
    let fake = FakePasServer::start().await;
    let rp = rp_against_fake::<scopes::Email>(&fake).await;

    let r = rp.start(RelativePath::default()).await.expect("start");
    let url = r.url.as_str();

    assert!(url.contains("response_type=code"), "response_type missing in {url}");
    assert!(url.contains("client_id=rp-client-id"), "client_id missing in {url}");
    assert!(url.contains("code_challenge="), "code_challenge missing in {url}");
    assert!(url.contains("code_challenge_method=S256"), "S256 missing in {url}");
    assert!(url.contains("nonce="), "nonce missing in {url}");
    assert!(url.contains(&format!("state={}", r.state.as_str())), "state mismatch in {url}");
    assert!(url.contains("scope=openid"), "openid scope missing in {url}");
    assert!(url.contains("scope=openid+email"), "scope=openid+email missing in {url}");
}

// ── 7. Start — pending persisted under returned state key ──────────────

#[tokio::test]
async fn start_persists_pending_under_returned_state_key() {
    let fake = FakePasServer::start().await;
    let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());
    let rp = RelyingParty::<scopes::Email>::new(fixture_config(fake.issuer_url()), Arc::clone(&store))
        .await
        .expect("boot");

    let r = rp.start(RelativePath::default()).await.expect("start");

    // The pending entry exists under the returned state key. We probe
    // via `take` (which removes it) — the test asserts presence; the
    // atomic single-use property is verified by #9 below.
    let pending = store
        .take(&r.state)
        .await
        .expect("take ok")
        .expect("pending present under returned state");
    assert!(!pending.code_verifier.is_empty(), "code_verifier persisted");
    assert!(!pending.nonce.is_empty(), "nonce persisted");
}

// ── 8. Complete — state not found in substrate ──────────────────────────

#[tokio::test]
async fn complete_state_not_found_yields_typed_error() {
    let fake = FakePasServer::start().await;
    let rp = rp_against_fake::<scopes::Email>(&fake).await;

    let err = rp
        .complete(CallbackParams {
            code: "any-code".to_owned(),
            state: State::from_string("never-issued".to_owned()),
        })
        .await
        .expect_err("absent state must reject");
    assert!(
        matches!(err, CallbackError::StateNotFoundOrConsumed),
        "expected StateNotFoundOrConsumed, got {err:?}"
    );
}

// ── 9. ⭐ LOAD-BEARING — atomic single-use replay defense ──────────────

/// Calling `complete` twice with the same state key MUST fail the
/// second call with `StateNotFoundOrConsumed`, regardless of whether
/// the first call succeeded or failed late. The substrate's atomic
/// `take` is the load-bearing defense; this test pins the contract.
#[tokio::test]
async fn complete_state_already_consumed_load_bearing() {
    let fake = FakePasServer::start().await;
    // Refuse the token exchange so `complete` fails AFTER state-take —
    // proving that even a *failed* complete consumes the state.
    fake.reject_next_token_exchange(400, "invalid_grant").await;

    let store: Arc<dyn StateStore> = Arc::new(InMemoryStateStore::new());
    let rp = RelyingParty::<scopes::Email>::new(fixture_config(fake.issuer_url()), Arc::clone(&store))
        .await
        .expect("boot");

    let r = rp.start(RelativePath::default()).await.expect("start");
    let state_key = r.state.clone();

    // First complete — fails at token-exchange, but state was consumed.
    let first = rp
        .complete(CallbackParams {
            code: "code-1".to_owned(),
            state: state_key.clone(),
        })
        .await;
    assert!(
        matches!(first, Err(CallbackError::TokenExchange(_))),
        "first complete should fail at token-exchange (state consumed before exchange), got {first:?}"
    );

    // Second complete — same state, MUST hit StateNotFoundOrConsumed.
    let second = rp
        .complete(CallbackParams {
            code: "code-2".to_owned(),
            state: state_key,
        })
        .await;
    assert!(
        matches!(second, Err(CallbackError::StateNotFoundOrConsumed)),
        "replay must fail with StateNotFoundOrConsumed, got {second:?}"
    );
}

// ── 10. Complete — state-store take failure propagates ──────────────────

/// State store that records a successful `put` then fails the matching
/// `take`. Verifies that substrate failures during the atomic take
/// surface as `CallbackError::StateStore`, not collapsed into
/// `StateNotFoundOrConsumed`.
#[derive(Default)]
struct TakeFailingStore {
    take_calls: AtomicUsize,
}

#[async_trait]
impl StateStore for TakeFailingStore {
    async fn put(
        &self,
        _state: &State,
        _pending: PendingAuthRequest,
        _ttl: Duration,
    ) -> Result<(), StateStoreError> {
        Ok(())
    }

    async fn take(
        &self,
        _state: &State,
    ) -> Result<Option<PendingAuthRequest>, StateStoreError> {
        self.take_calls.fetch_add(1, Ordering::SeqCst);
        Err(StateStoreError::Substrate("simulated take failure".to_owned()))
    }
}

#[tokio::test]
async fn complete_state_store_take_failure_propagates() {
    let fake = FakePasServer::start().await;
    let store_concrete = Arc::new(TakeFailingStore::default());
    let store: Arc<dyn StateStore> = Arc::clone(&store_concrete) as Arc<dyn StateStore>;
    let rp = RelyingParty::<scopes::Email>::new(fixture_config(fake.issuer_url()), store)
        .await
        .expect("boot");

    let err = rp
        .complete(CallbackParams {
            code: "any".to_owned(),
            state: State::from_string("any-state".to_owned()),
        })
        .await
        .expect_err("substrate take failure must surface");
    assert!(
        matches!(err, CallbackError::StateStore(_)),
        "expected StateStore variant (not StateNotFoundOrConsumed), got {err:?}"
    );
    assert_eq!(store_concrete.take_calls.load(Ordering::SeqCst), 1);
}

// ── 11. Complete — token-exchange failure propagates ────────────────────

#[tokio::test]
async fn complete_token_exchange_failure_propagates() {
    let fake = FakePasServer::start().await;
    fake.reject_next_token_exchange(400, "invalid_grant").await;

    let rp = rp_against_fake::<scopes::Email>(&fake).await;
    let r = rp.start(RelativePath::default()).await.expect("start");

    let err = rp
        .complete(CallbackParams {
            code: "bad-code".to_owned(),
            state: r.state,
        })
        .await
        .expect_err("token exchange should fail");
    assert!(
        matches!(err, CallbackError::TokenExchange(_)),
        "expected TokenExchange variant, got {err:?}"
    );
}

// ── 12. Refresh — 4xx → Rejected (dead credential) ─────────────────────

#[tokio::test]
async fn refresh_4xx_yields_rejected() {
    let fake = FakePasServer::start().await;
    fake.reject_next_token_exchange(400, "invalid_grant").await;

    let rp = rp_against_fake::<scopes::Email>(&fake).await;

    let err = rp
        .refresh("dead-rt")
        .await
        .expect_err("4xx must surface as Rejected");
    assert!(
        matches!(err, RefreshError::Rejected(_)),
        "expected Rejected, got {err:?}"
    );
}

// ── 13. Refresh — 5xx → Transient (fail-soft) ──────────────────────────

#[tokio::test]
async fn refresh_5xx_yields_transient() {
    let fake = FakePasServer::start().await;
    fake.reject_next_token_exchange(503, "service degraded").await;

    let rp = rp_against_fake::<scopes::Email>(&fake).await;

    let err = rp
        .refresh("rt")
        .await
        .expect_err("5xx must surface as Transient");
    assert!(
        matches!(err, RefreshError::Transient(_)),
        "expected Transient, got {err:?}"
    );
}

// ── 14. Refresh — happy path produces RefreshOutcome ───────────────────

#[tokio::test]
async fn refresh_success_yields_typed_outcome() {
    let fake = FakePasServer::start().await;
    fake.expect_token_exchange(
        TokenExchangeBody::bearer("new-at")
            .with_refresh_token("rotated-rt")
            .with_expires_in(900),
    )
    .await;

    let rp = rp_against_fake::<scopes::Email>(&fake).await;

    let outcome = rp.refresh("alive-rt").await.expect("refresh succeeds");
    assert_eq!(outcome.access_token, "new-at");
    assert_eq!(outcome.refresh_token.as_deref(), Some("rotated-rt"));
    assert_eq!(outcome.expires_in, Some(Duration::from_secs(900)));
    assert!(outcome.id_token.is_none());
}

// ── Compile-time guards (Phase 10 pattern, Phase 11.Y carryover) ───────

// `Completion<S>::id_assertion: IdAssertion<S>` — the post-verify
// scope-bounded surface. `RefreshOutcome` does NOT carry an
// IdAssertion because refresh-grant responses are session-extension
// events, not re-authentication events; the consumer trusts the prior
// IdAssertion alongside the rotated access_token.
#[allow(dead_code)]
fn completion_carries_scope_bounded_assertion(
    c: Completion<scopes::Email>,
) -> pas_external::oidc::IdAssertion<scopes::Email> {
    c.id_assertion
}
