//! γ port-and-adapter boundary tests for [`IdTokenVerifier`].
//!
//! Phase 10.11.E — sibling of `bearer_verifier_boundary.rs` for OIDC
//! id_tokens. The tests live at the *port* boundary, not the engine
//! internals. Engine-level mechanics (M66 nonce binding, M67/M68
//! at_hash/c_hash, M69 azp, M70 auth_time, M71 acr, M72 per-scope PII,
//! M74 cat="id" mirror) are tested in `ppoppo-token/tests/` —
//! duplicating those at the SDK boundary would be the "layer, don't
//! replace" anti-pattern that REFERENCE.md §Testing Strategy
//! explicitly warns against.
//!
//! What we test here:
//!
//! 1. **`IdAssertion<S>` typed accessors at multiple scopes** —
//!    `Openid` (base only), `Email` (base + email), maximal
//!    `EmailProfilePhoneAddress` (full PII). The SDK-shaped surface
//!    that consumers actually consume.
//! 2. **`MemoryIdTokenVerifier<S>` contract** — insert + verify
//!    round-trip, miss → `SignatureInvalid`, fail_with override.
//!    The in-memory adapter is the `IdTokenVerifier` port's reference
//!    implementation; if it drifts from `PasIdTokenVerifier`'s
//!    externally observable behavior, consumer integration tests give
//!    false confidence.
//! 3. **`PasIdTokenVerifier::from_jwks_url` error path** — bad URL
//!    surfaces `IdVerifyError::KeysetUnavailable` not `Other(...)`.
//! 4. **AuditSink integration** — `verify` failure emits an
//!    [`AuditEvent`] with the correct `kind`, decoded hints (azp / aud
//!    / kid), and 3-tuple `source_id` per δ2. Composes with
//!    `RateLimitedAuditSink` for M49 throttling.
//! 5. **Drift detector** — every `IdVerifyError` variant has a
//!    `From<&IdVerifyError> for VerifyErrorKind` mapping.

use std::str::FromStr;
use std::sync::Arc;

use pas_external::oidc::{
    Address, EmailProfilePhoneAddress, IdAssertion, IdTokenVerifier, IdVerifyError,
    MemoryIdTokenVerifier, Nonce, ScopePiiReader,
};
use pas_external::test_support::PasIdTokenVerifier;
use pas_external::{
    AuditSink, Expectations, IdTokenFailureKind, MemoryAuditSink, MemoryRateLimiter,
    PpnumId, RateLimitedAuditSink, VerifyErrorKind, compose_id_token_source_id,
};
use ppoppo_token::id_token::scopes;
use time::OffsetDateTime;
use ulid::Ulid;

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

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

fn fixture_assertion<S: ScopePiiReader>() -> IdAssertion<S> {
    let now = OffsetDateTime::now_utc();
    IdAssertion::for_test(
        "accounts.ppoppo.com",
        fixture_sub(),
        vec!["rp-client".to_owned()],
        now + time::Duration::hours(1),
        now,
        "n-0S6_WzA2Mj",
    )
}

fn fixture_expectations() -> Expectations {
    Expectations::new("accounts.ppoppo.com", "rp-client-id")
}

// ── 1. IdAssertion accessor catalog at multiple scopes ──────────────────

#[test]
fn id_assertion_exposes_typed_accessors_at_openid_scope() {
    let assertion: IdAssertion<scopes::Openid> = fixture_assertion();
    assert_eq!(assertion.iss(), "accounts.ppoppo.com");
    assert_eq!(assertion.sub().0.to_string(), "01HK0000000000000000000001");
    assert_eq!(assertion.aud(), &["rp-client".to_owned()]);
    assert!(assertion.exp() > OffsetDateTime::now_utc());
    assert!(assertion.iat() <= OffsetDateTime::now_utc());
    assert_eq!(assertion.nonce(), "n-0S6_WzA2Mj");
    // IdP-asserted claims default None at the openid base scope.
    assert!(assertion.azp().is_none());
    assert!(assertion.auth_time().is_none());
    assert!(assertion.acr().is_none());
    assert!(assertion.amr().is_none());
}

#[test]
fn id_assertion_exposes_email_accessor_at_email_scope() {
    let assertion: IdAssertion<scopes::Email> =
        fixture_assertion::<scopes::Email>().with_email("user@example.com", Some(true));
    // Base accessors still work.
    assert_eq!(assertion.iss(), "accounts.ppoppo.com");
    assert_eq!(assertion.sub().0.to_string(), "01HK0000000000000000000001");
    // HasEmail-bounded accessors now reachable.
    assert_eq!(assertion.email(), "user@example.com");
    assert_eq!(assertion.email_verified(), Some(true));
}

#[test]
fn id_assertion_exposes_full_pii_at_maximal_scope() {
    let address = Address {
        formatted: Some("123 Test St, Seoul, KR".to_owned()),
        country: Some("KR".to_owned()),
        ..Default::default()
    };
    let assertion: IdAssertion<EmailProfilePhoneAddress> =
        fixture_assertion::<EmailProfilePhoneAddress>()
            .with_azp("rp-client")
            .with_email("user@example.com", Some(true))
            .with_name("Test User")
            .with_phone_number("+8210123456", Some(false))
            .with_address(address.clone());

    // Base scope
    assert_eq!(assertion.azp(), Some("rp-client"));
    // HasEmail
    assert_eq!(assertion.email(), "user@example.com");
    assert_eq!(assertion.email_verified(), Some(true));
    // HasProfile
    assert_eq!(assertion.name(), Some("Test User"));
    // HasPhone
    assert_eq!(assertion.phone_number(), Some("+8210123456"));
    assert_eq!(assertion.phone_number_verified(), Some(false));
    // HasAddress
    let returned = assertion.address().expect("address present");
    assert_eq!(returned.formatted.as_deref(), Some("123 Test St, Seoul, KR"));
    assert_eq!(returned.country.as_deref(), Some("KR"));
}

// ── 2. MemoryIdTokenVerifier contract ──────────────────────────────────

#[tokio::test]
async fn memory_verifier_insert_then_verify_succeeds() {
    let mut verifier = MemoryIdTokenVerifier::<scopes::Openid>::new();
    let assertion = fixture_assertion::<scopes::Openid>();
    verifier.insert("token-123", assertion.clone());
    let nonce = Nonce::new("n-0S6_WzA2Mj").expect("nonce non-empty");

    let result = verifier
        .verify("token-123", &nonce)
        .await
        .expect("inserted token must verify");
    assert_eq!(result.sub().0, assertion.sub().0);
    assert_eq!(result.iss(), assertion.iss());
}

#[tokio::test]
async fn memory_verifier_unknown_token_yields_signature_invalid() {
    let verifier = MemoryIdTokenVerifier::<scopes::Openid>::new();
    let nonce = Nonce::new("nonce").expect("nonce non-empty");
    let err = verifier
        .verify("unregistered-token", &nonce)
        .await
        .expect_err("unknown token must fail");
    assert_eq!(err, IdVerifyError::SignatureInvalid);
}

#[tokio::test]
async fn memory_verifier_fail_with_overrides_lookup() {
    let mut verifier = MemoryIdTokenVerifier::<scopes::Openid>::new();
    verifier.insert("would-pass", fixture_assertion::<scopes::Openid>());
    verifier.fail_with(IdVerifyError::NonceMismatch);
    let nonce = Nonce::new("nonce").expect("nonce non-empty");
    let err = verifier
        .verify("would-pass", &nonce)
        .await
        .expect_err("fail_with must override the success path");
    assert_eq!(err, IdVerifyError::NonceMismatch);
}

#[tokio::test]
async fn memory_verifier_with_expectations_does_not_break_construction() {
    // Symmetric construction with PasIdTokenVerifier: even though the
    // in-memory adapter doesn't currently consult Expectations, the
    // builder must accept it so test code reads identically to the
    // production wiring.
    let mut verifier =
        MemoryIdTokenVerifier::<scopes::Openid>::new().with_expectations(fixture_expectations());
    verifier.insert("t", fixture_assertion::<scopes::Openid>());
    let nonce = Nonce::new("nonce").expect("nonce non-empty");
    assert!(verifier.verify("t", &nonce).await.is_ok());
}

// ── 3. PasIdTokenVerifier construction error path ──────────────────────

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

// ── 4. AuditSink integration ────────────────────────────────────────────

#[tokio::test]
async fn verify_failure_silently_rejects_when_no_sink_wired() {
    let verifier =
        PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(fixture_expectations());
    let nonce = Nonce::new("nonce").expect("nonce non-empty");

    let err = verifier.verify("", &nonce).await.expect_err("empty rejects");
    assert_eq!(err, IdVerifyError::InvalidFormat);
    // No sink to inspect — compile-time correctness suffices.
}

#[tokio::test]
async fn verify_failure_emits_audit_event_for_invalid_format() {
    let memory = Arc::new(MemoryAuditSink::new());
    let sink: Arc<dyn AuditSink> = memory.clone();
    let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
        fixture_expectations(),
    )
    .with_audit(sink);
    let nonce = Nonce::new("nonce").expect("nonce non-empty");

    let err = verifier.verify("", &nonce).await.expect_err("empty rejects");
    assert_eq!(err, IdVerifyError::InvalidFormat);

    let events = memory.events();
    assert_eq!(events.len(), 1, "exactly one event per Err path");
    assert_eq!(events[0].kind, VerifyErrorKind::InvalidFormat);
    // Empty token has no decodable hints — collapses to canonical
    // anonymous bucket via δ2's 3-tuple compose.
    assert_eq!(events[0].source_id, "anon::noaud::nokid");
    assert!(events[0].client_id_hint.is_none());
    assert!(events[0].kid_hint.is_none());
}

#[tokio::test]
async fn verify_failure_emits_audit_event_with_decoded_hints() {
    use base64::Engine as _;
    let memory = Arc::new(MemoryAuditSink::new());
    let sink: Arc<dyn AuditSink> = memory.clone();
    let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
        fixture_expectations(),
    )
    .with_audit(sink);

    // Forge a structurally-valid id_token shape with decodable
    // azp / aud / kid hints. Empty test keyset → engine `KidUnknown`
    // (post check_algorithm which doesn't consult keyset) →
    // `IdVerifyError::SignatureInvalid` per `map_auth_error`.
    let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
        serde_json::to_vec(&serde_json::json!({
            "alg": "EdDSA", "typ": "JWT", "kid": "test-kid-abc"
        }))
        .unwrap_or_default(),
    );
    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
        serde_json::to_vec(&serde_json::json!({
            "cat": "id",
            "azp": "rp-client-id",
            "aud": ["rp-client-id"],
            "sub": "01HSAB00000000000000000000",
            "iss": "accounts.ppoppo.com",
            "exp": 9_999_999_999u64,
            "iat": 1_700_000_000u64,
            "nonce": "n-0S6_WzA2Mj",
        }))
        .unwrap_or_default(),
    );
    let token = format!("{header}.{payload}.<sig>");
    let nonce = Nonce::new("n-0S6_WzA2Mj").expect("nonce non-empty");

    let err = verifier
        .verify(&token, &nonce)
        .await
        .expect_err("kid unknown must reject");
    // Engine collapses any pre-keyset reject (alg / header) into
    // SignatureInvalid via map_auth_error; the precise M-row is
    // preserved via `IdVerifyError::Other(_)` fallback for variants
    // outside the explicit JOSE family — but here `KidUnknown` IS in
    // the SignatureInvalid family.
    assert_eq!(err, IdVerifyError::SignatureInvalid);

    let events = memory.events();
    assert_eq!(events.len(), 1);
    let e = &events[0];
    assert_eq!(e.kind, VerifyErrorKind::SignatureInvalid);
    assert_eq!(e.client_id_hint.as_deref(), Some("rp-client-id"));
    assert_eq!(e.kid_hint.as_deref(), Some("test-kid-abc"));
    // δ2 — `azp ‖ aud ‖ kid` 3-tuple source key.
    assert_eq!(
        e.source_id,
        compose_id_token_source_id(Some("rp-client-id"), Some("rp-client-id"), Some("test-kid-abc"))
    );
    assert_eq!(e.source_id, "rp-client-id::rp-client-id::test-kid-abc");
    // aud_hint pushed into metadata for dashboard pivot.
    assert_eq!(
        e.metadata.get("aud_hint").and_then(|v| v.as_str()),
        Some("rp-client-id")
    );
}

#[tokio::test]
async fn verify_failure_with_rate_limited_sink_drops_over_quota() {
    use std::time::Duration;

    let memory = Arc::new(MemoryAuditSink::new());
    let limited = Arc::new(RateLimitedAuditSink::new(
        memory.clone() as Arc<dyn AuditSink>,
        Arc::new(MemoryRateLimiter::new(2, Duration::from_secs(60))),
    ));
    let dropped_handle = Arc::clone(&limited);
    let sink: Arc<dyn AuditSink> = limited;

    let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
        fixture_expectations(),
    )
    .with_audit(sink);
    let nonce = Nonce::new("nonce").expect("nonce non-empty");

    // Three identical failures (same source = anon::noaud::nokid bucket
    // since empty token has no decodable hints).
    for _ in 0..3 {
        let _ = verifier.verify("", &nonce).await;
    }

    assert_eq!(memory.len(), 2, "first two admitted past the 2-cap");
    assert_eq!(dropped_handle.dropped_total(), 1, "third rate-limited");
}

// ── 5. Drift detector — From<&IdVerifyError> coverage ──────────────────

/// `From<&IdVerifyError> for VerifyErrorKind` covers every variant. A
/// regression here means a new IdVerifyError variant lacks audit
/// classification, silently falling into the catch-all `Other` bucket
/// (or worse, failing to compile in `emit_failure`). Mirroring every
/// variant ensures the audit signal preserves the engine's typed
/// rejection class.
#[test]
fn id_verify_error_kind_from_covers_every_variant() {
    use IdTokenFailureKind as K;
    use IdVerifyError as E;
    use VerifyErrorKind as V;

    let cases: Vec<(IdVerifyError, VerifyErrorKind)> = vec![
        // Adapter-side
        (E::InvalidFormat, V::InvalidFormat),
        // JOSE-layer (shared with access_token)
        (E::SignatureInvalid, V::SignatureInvalid),
        (E::Expired, V::Expired),
        (E::IssuerInvalid, V::IssuerInvalid),
        (E::AudienceInvalid, V::AudienceInvalid),
        (E::MissingClaim("nonce"), V::MissingClaim("nonce".to_owned())),
        (E::MissingClaim("sub"), V::MissingClaim("sub".to_owned())),
        (E::KeysetUnavailable, V::KeysetUnavailable),
        // OIDC-specific (nested under IdToken)
        (E::NonceMissing, V::IdToken(K::NonceMissing)),
        (E::NonceMismatch, V::IdToken(K::NonceMismatch)),
        (E::AtHashMissing, V::IdToken(K::AtHashMissing)),
        (E::AtHashMismatch, V::IdToken(K::AtHashMismatch)),
        (E::CHashMissing, V::IdToken(K::CHashMissing)),
        (E::CHashMismatch, V::IdToken(K::CHashMismatch)),
        (E::AzpMissing, V::IdToken(K::AzpMissing)),
        (E::AzpMismatch, V::IdToken(K::AzpMismatch)),
        (E::AuthTimeMissing, V::IdToken(K::AuthTimeMissing)),
        (E::AuthTimeStale, V::IdToken(K::AuthTimeStale)),
        (E::AcrMissing, V::IdToken(K::AcrMissing)),
        (E::AcrNotAllowed, V::IdToken(K::AcrNotAllowed)),
        (
            E::UnknownClaim("backdoor".to_owned()),
            V::IdToken(K::UnknownClaim("backdoor".to_owned())),
        ),
        (
            E::CatMismatch("access".to_owned()),
            V::IdToken(K::CatMismatch("access".to_owned())),
        ),
        // Catch-all
        (E::Other("engine M-row".to_owned()), V::Other),
    ];
    for (verr, expected_kind) in cases {
        let actual: VerifyErrorKind = (&verr).into();
        assert_eq!(
            actual, expected_kind,
            "IdVerifyError {verr:?} must map to {expected_kind:?}"
        );
    }
}

/// 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() {
    fn _accept<S: ScopePiiReader>(_: Arc<dyn IdTokenVerifier<S>>) {}
}
