//! γ port-and-adapter boundary tests for [`BearerVerifier`].
//!
//! Phase 6.1.D — D-04 = γ. The tests live at the *port* boundary, not
//! the engine internals. Engine-level mechanics (RFC 9068 + RFC 8725
//! mitigations M01–M45, JWKS rotation, revocation port wiring) are
//! tested in `ppoppo-token/tests/` (round_trip.rs, jwt_negative.rs,
//! keyset_jwks.rs, session_revocation.rs, …) — 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. **`AuthSession` typed accessors** — `ppnum_id`, `ppnum`,
//!    `session_id`, `expires_at` — the SDK-shaped surface that
//!    consumers actually consume. A regression here means a Rust
//!    type swap that breaks consumer code.
//! 2. **`MemoryBearerVerifier` contract** — insert + verify round-trip,
//!    miss → SignatureInvalid, fail_with override. The in-memory
//!    adapter is the BearerVerifier port's reference implementation;
//!    if it drifts from PasJwtVerifier's externally observable
//!    behavior, consumer integration tests give false confidence.
//! 3. **`PasJwtVerifier::from_jwks_url` error path** — bad URL surfaces
//!    `VerifyError::KeysetUnavailable` not `Other(...)`. The typed
//!    error variant is the audit-log signal that distinguishes
//!    "couldn't fetch keys" from "keys say no."
//!
//! What we DON'T test here (engine-tested instead):
//!
//! - Cryptographic signature verification (`ppoppo-token/tests/round_trip.rs`)
//! - Algorithm whitelist (`ppoppo-token/tests/jwt_negative.rs`)
//! - JWKS parsing + key rotation (`ppoppo-token/tests/keyset_jwks.rs`)
//! - kid revocation semantics (`ppoppo-token` controls whether a kid
//!   resolves; the SDK only sees the engine's `KidUnknown` rejection
//!   which collapses to `VerifyError::SignatureInvalid` per the
//!   boundary contract)

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

use pas_external::{
    AuthSession, BearerVerifier, Expectations, MemoryAuditSink, MemoryBearerVerifier,
    PasJwtVerifier, Ppnum, PpnumId, SessionId, VerifyError, VerifyErrorKind,
};
use time::OffsetDateTime;
use ulid::Ulid;

fn fixture_session() -> AuthSession {
    let ulid = Ulid::from_str("01HK0000000000000000000001").expect("test ulid");
    let ppnum_id = PpnumId(ulid);
    let ppnum = "12312345678".parse::<Ppnum>().expect("test ppnum");
    let session_id = SessionId::from("test-session-id".to_string());
    let expires_at = OffsetDateTime::now_utc() + time::Duration::hours(1);
    AuthSession::for_test(ppnum_id, Some(ppnum), Some(session_id), expires_at)
}

#[test]
fn auth_session_exposes_typed_accessors() {
    let session = fixture_session();
    assert_eq!(session.ppnum_id().0.to_string(), "01HK0000000000000000000001");
    assert_eq!(session.ppnum().expect("ppnum present").as_str(), "12312345678");
    assert_eq!(
        session.session_id().expect("session_id present").to_string(),
        "test-session-id"
    );
    assert!(session.expires_at() > OffsetDateTime::now_utc());
    // Phase 11.Z (RFC §3 Row 1): `AuthSession.session_version()` accessor
    // deleted. Engine HIDDEN sv invariant honored — token's sv is never
    // surfaced; consumers wire `EpochRevocation` instead.
}

#[tokio::test]
async fn memory_verifier_insert_then_verify_succeeds() {
    let mut verifier = MemoryBearerVerifier::new();
    let session = fixture_session();
    verifier.insert("token-123", session.clone());
    let result = verifier
        .verify("token-123")
        .await
        .expect("inserted token must verify");
    assert_eq!(result.ppnum_id().0, session.ppnum_id().0);
}

#[tokio::test]
async fn memory_verifier_unknown_token_yields_signature_invalid() {
    let verifier = MemoryBearerVerifier::new();
    let err = verifier
        .verify("unregistered-token")
        .await
        .expect_err("unknown token must fail");
    assert_eq!(err, VerifyError::SignatureInvalid);
}

#[tokio::test]
async fn memory_verifier_fail_with_overrides_lookup() {
    let mut verifier = MemoryBearerVerifier::new();
    verifier.insert("would-pass", fixture_session());
    verifier.fail_with(VerifyError::Expired);
    let err = verifier
        .verify("would-pass")
        .await
        .expect_err("fail_with must override the success path");
    assert_eq!(err, VerifyError::Expired);
}

#[tokio::test]
async fn memory_verifier_with_expectations_does_not_break_construction() {
    // Symmetric construction with PasJwtVerifier: 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 = MemoryBearerVerifier::new()
        .with_expectations(Expectations::new("accounts.ppoppo.com", "test-client-id"));
    verifier.insert("t", fixture_session());
    assert!(verifier.verify("t").await.is_ok());
}

#[tokio::test]
async fn pas_jwt_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 IdP" from "the IdP said no."
    let result = PasJwtVerifier::from_jwks_url(
        "http://nonexistent.invalid/.well-known/jwks.json",
        Expectations::new("accounts.ppoppo.com", "client-id"),
    )
    .await;
    let err = result.expect_err("bad URL must fail construction");
    assert_eq!(err, VerifyError::KeysetUnavailable);
}

/// Compile-time guard: an `Arc<dyn BearerVerifier>` is the runtime shape
/// consumer middleware injects. If the trait's object-safety regresses
/// (e.g. someone adds a generic method), this won't compile.
#[allow(dead_code)]
fn dyn_object_safety() {
    fn _accept(_: Arc<dyn BearerVerifier>) {}
}

// ── Phase 9.D — M48 audit emission integration tests ──────────────────────
//
// These tests verify the wiring between PasJwtVerifier::verify and the
// AuditSink port. The boundary contract: every Err path in the verify
// flow must emit exactly one event with the matching VerifyErrorKind
// when an audit sink is wired; without a sink, no emission happens.

/// Without an audit sink wired, verify rejections happen silently —
/// preserves backward-compat for existing RCW/CTW consumers who
/// haven't yet opted into audit. Structural guarantee: the
/// `let Some(sink) = ... else { return err }` early return in
/// `emit_failure` means no work happens past the emit-or-skip branch.
#[tokio::test]
async fn verify_failure_silently_rejects_when_no_sink_wired() {
    let verifier = PasJwtVerifier::for_test_skip_fetch(Expectations::new(
        "accounts.ppoppo.com",
        "rcw-client-id",
    ));

    let err = verifier
        .verify("")
        .await
        .expect_err("empty token must reject");
    assert_eq!(err, VerifyError::InvalidFormat);
    // No sink to inspect — compile-time correctness suffices.
}

/// Adapter-side `InvalidFormat` rejection (empty token / not 3
/// segments) emits an event with the correct kind. This is the
/// earliest reject path; never reaches the keyset.
#[tokio::test]
async fn verify_failure_emits_audit_event_for_invalid_format() {
    let memory = Arc::new(MemoryAuditSink::new());
    let sink: Arc<dyn pas_external::AuditSink> = memory.clone();
    let verifier = PasJwtVerifier::for_test_skip_fetch(Expectations::new(
        "accounts.ppoppo.com",
        "rcw-client-id",
    ))
    .with_audit(sink);

    let err = verifier.verify("").await.expect_err("empty rejects");
    assert_eq!(err, VerifyError::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 per Phase 9 design call (e).
    assert_eq!(events[0].source_id, "anon::nokid");
    assert!(events[0].client_id_hint.is_none());
    assert!(events[0].kid_hint.is_none());
}

/// M73 `IdTokenAsBearer` rejection emits with hints decoded from the
/// rejected token's header + payload (`client_id`, `kid`). This is
/// the most-informative emission path: even though the token is
/// rejected as untrusted, its claim values are useful for audit
/// grouping (per AuditEvent doc-comment "hints are not authentication").
#[tokio::test]
async fn verify_failure_emits_audit_event_for_id_token_as_bearer_with_hints() {
    use base64::Engine as _;
    let memory = Arc::new(MemoryAuditSink::new());
    let sink: Arc<dyn pas_external::AuditSink> = memory.clone();
    let verifier = PasJwtVerifier::for_test_skip_fetch(Expectations::new(
        "accounts.ppoppo.com",
        "rcw-client-id",
    ))
    .with_audit(sink);

    let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
        serde_json::to_vec(&serde_json::json!({
            "alg": "EdDSA", "typ": "at+jwt", "kid": "k1"
        }))
        .unwrap_or_else(|_| Vec::new()),
    );
    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
        serde_json::to_vec(&serde_json::json!({
            "cat": "id",
            "client_id": "ctw-client-id",
            "sub": "01HSAB00000000000000000000",
        }))
        .unwrap_or_else(|_| Vec::new()),
    );
    let token = format!("{header}.{payload}.<sig>");

    let err = verifier.verify(&token).await.expect_err("M73 rejects id_token");
    assert_eq!(err, VerifyError::IdTokenAsBearer);

    let events = memory.events();
    assert_eq!(events.len(), 1);
    let e = &events[0];
    assert_eq!(e.kind, VerifyErrorKind::IdTokenAsBearer);
    assert_eq!(e.client_id_hint.as_deref(), Some("ctw-client-id"));
    assert_eq!(e.kid_hint.as_deref(), Some("k1"));
    assert_eq!(e.source_id, "ctw-client-id::k1");
}

/// Engine-side `SignatureInvalid` (kid unknown — empty test keyset)
/// emits with hints. Proves the post-engine emission path through
/// `map_auth_error` flows through `emit_failure` correctly.
#[tokio::test]
async fn verify_failure_emits_audit_event_for_signature_invalid() {
    use base64::Engine as _;
    let memory = Arc::new(MemoryAuditSink::new());
    let sink: Arc<dyn pas_external::AuditSink> = memory.clone();
    let verifier = PasJwtVerifier::for_test_skip_fetch(Expectations::new(
        "accounts.ppoppo.com",
        "rcw-client-id",
    ))
    .with_audit(sink);

    // Valid-shape access token: typ=at+jwt + cat=access — won't trip
    // M73. Empty test keyset → engine `KidUnknown` →
    // `VerifyError::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": "at+jwt", "kid": "missing-kid"
        }))
        .unwrap_or_else(|_| Vec::new()),
    );
    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
        serde_json::to_vec(&serde_json::json!({
            "cat": "access",
            "client_id": "rcw-client-id",
            "sub": "01HSAB00000000000000000000",
            "iss": "accounts.ppoppo.com",
            "aud": "rcw-client-id",
            "exp": 9_999_999_999u64,
            "iat": 1_700_000_000u64,
            "jti": "j1",
        }))
        .unwrap_or_else(|_| Vec::new()),
    );
    let token = format!("{header}.{payload}.<sig>");

    let err = verifier.verify(&token).await.expect_err("kid unknown rejects");
    assert_eq!(err, VerifyError::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("rcw-client-id"));
    assert_eq!(e.kid_hint.as_deref(), Some("missing-kid"));
    assert_eq!(e.source_id, "rcw-client-id::missing-kid");
}

/// `RateLimitedAuditSink` composes cleanly with `PasJwtVerifier`'s
/// `with_audit` builder — the runtime wiring shape consumers use
/// when both M48 (audit) and M49 (rate-limit) are enabled. Three
/// rapid failures with `capacity=2` admits two and drops one;
/// `dropped_total` is observable via the retained Arc handle.
#[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(pas_external::RateLimitedAuditSink::new(
        memory.clone() as Arc<dyn pas_external::AuditSink>,
        Arc::new(pas_external::MemoryRateLimiter::new(
            2,
            Duration::from_secs(60),
        )),
    ));
    let dropped_handle = Arc::clone(&limited);
    let sink: Arc<dyn pas_external::AuditSink> = limited;

    let verifier = PasJwtVerifier::for_test_skip_fetch(Expectations::new(
        "accounts.ppoppo.com",
        "rcw-client-id",
    ))
    .with_audit(sink);

    // Three identical failures (same source = anon::nokid bucket).
    for _ in 0..3 {
        let _ = verifier.verify("").await;
    }

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

/// `From<&VerifyError> for VerifyErrorKind` covers every variant.
/// A regression here means a new VerifyError 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 verify_error_kind_from_covers_every_variant() {
    use VerifyError as E;
    use VerifyErrorKind as K;

    let cases = [
        (E::InvalidFormat, K::InvalidFormat),
        (E::SignatureInvalid, K::SignatureInvalid),
        (E::Expired, K::Expired),
        (E::IssuerInvalid, K::IssuerInvalid),
        (E::AudienceInvalid, K::AudienceInvalid),
        (E::MissingClaim("aud"), K::MissingClaim("aud".to_owned())),
        (E::MissingClaim("sub"), K::MissingClaim("sub".to_owned())),
        (E::KeysetUnavailable, K::KeysetUnavailable),
        (E::IdTokenAsBearer, K::IdTokenAsBearer),
        (E::SessionVersionStale, K::SessionVersionStale),
        (
            E::SessionVersionLookupUnavailable,
            K::SessionVersionLookupUnavailable,
        ),
        (E::Other("engine M-row".to_owned()), K::Other),
    ];
    for (verr, expected_kind) in cases {
        let actual: VerifyErrorKind = (&verr).into();
        assert_eq!(
            actual, expected_kind,
            "VerifyError {verr:?} must map to {expected_kind:?}"
        );
    }
}
