//! S-L3 boundary tests for `attempt_liveness_refresh`.
//!
//! Replaces the deleted classifier unit tests in `liveness.rs`. Covers
//! every PasFailure variant + the rotated-token path + the "no
//! rotation" path, all against the in-memory port adapter (no HTTP).

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use base64::{Engine, engine::general_purpose::STANDARD};
use pas_external::pas_port::{MemoryPasAuth, PasFailure};
use pas_external::test_support::TokenResponse;
use pas_external::session_liveness::{
    EncryptedRefreshToken, LivenessFailure, LivenessOutcome, RevokeCause, TokenCipher,
    TransientCause, attempt_liveness_refresh,
};

fn ct(cipher: &TokenCipher, plaintext: &str) -> EncryptedRefreshToken {
    cipher.encrypt_to_token(plaintext).unwrap()
}

fn cipher() -> TokenCipher {
    let key_b64 = STANDARD.encode([0u8; 32]);
    TokenCipher::from_base64_key(&key_b64).unwrap()
}

fn token_response(access: &str, refresh: Option<&str>) -> TokenResponse {
    // TokenResponse is non_exhaustive; serde_json round-trip is the
    // safest way to construct one from the public test surface.
    let body = serde_json::json!({
        "access_token": access,
        "token_type": "Bearer",
        "expires_in": 3600,
        "refresh_token": refresh,
    });
    serde_json::from_value(body).unwrap()
}

#[tokio::test]
async fn liveness_pas_5xx_classified_transient_serves_cache() {
    let cipher = cipher();
    let plaintext = "rt_live_xyz";
    let ciphertext = ct(&cipher, plaintext);

    let port = MemoryPasAuth::new().expect_refresh(
        plaintext,
        Err(PasFailure::ServerError { status: 503, detail: "upstream".into() }),
    );

    let outcome = attempt_liveness_refresh(&cipher, &port, &ciphertext).await;
    assert!(matches!(
        outcome,
        LivenessOutcome::Failed(LivenessFailure::Transient {
            cause: TransientCause::PasServerError,
            ..
        })
    ));
}

#[tokio::test]
async fn liveness_pas_4xx_classified_revoked_pas_rejected() {
    let cipher = cipher();
    let plaintext = "rt_dead";
    let ciphertext = ct(&cipher, plaintext);

    let port = MemoryPasAuth::new().expect_refresh(
        plaintext,
        Err(PasFailure::Rejected { status: 400, detail: "invalid_grant".into() }),
    );

    let outcome = attempt_liveness_refresh(&cipher, &port, &ciphertext).await;
    assert!(matches!(
        outcome,
        LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::PasRejected })
    ));
}

#[tokio::test]
async fn liveness_pas_transport_classified_transient() {
    // S-L3 invariant: a transport blip / proxy garbage MUST NOT
    // force-logout users. Maps to Transient.
    let cipher = cipher();
    let plaintext = "rt_live_abc";
    let ciphertext = ct(&cipher, plaintext);

    let port = MemoryPasAuth::new().expect_refresh(
        plaintext,
        Err(PasFailure::Transport { detail: "connection reset".into() }),
    );

    let outcome = attempt_liveness_refresh(&cipher, &port, &ciphertext).await;
    assert!(matches!(
        outcome,
        LivenessOutcome::Failed(LivenessFailure::Transient { .. })
    ));
}

#[tokio::test]
async fn liveness_pas_rotated_token_returned_in_fresh_some() {
    let cipher = cipher();
    let plaintext = "rt_old";
    let ciphertext = ct(&cipher, plaintext);
    let new_rt = "rt_new_after_rotation";

    let port = MemoryPasAuth::new()
        .expect_refresh(plaintext, Ok(token_response("at_new", Some(new_rt))));

    let outcome = attempt_liveness_refresh(&cipher, &port, &ciphertext).await;
    let new_ct = match outcome {
        LivenessOutcome::Fresh { rotated_ciphertext: Some(ct) } => ct,
        other => panic!("expected Fresh{{Some}}, got {other:?}"),
    };
    // The rotated ciphertext must roundtrip back through the same cipher.
    assert_eq!(cipher.decrypt(&new_ct).unwrap(), new_rt);
}

#[tokio::test]
async fn liveness_pas_no_rotation_returned_in_fresh_none() {
    let cipher = cipher();
    let plaintext = "rt_stable";
    let ciphertext = ct(&cipher, plaintext);

    let port = MemoryPasAuth::new()
        .expect_refresh(plaintext, Ok(token_response("at_new", None)));

    let outcome = attempt_liveness_refresh(&cipher, &port, &ciphertext).await;
    assert!(matches!(outcome, LivenessOutcome::Fresh { rotated_ciphertext: None }));
}

#[tokio::test]
async fn liveness_cipher_decrypt_failure_short_circuits_revoked() {
    // S-L1: ciphertext we can't decrypt is unrecoverable. The function
    // must not reach the network call — we register zero expectations,
    // so any call to refresh() would panic. This pins the short-circuit.
    let cipher = cipher();
    let garbage_ct = EncryptedRefreshToken::from_stored(STANDARD.encode([0u8; 64]));
    let port = MemoryPasAuth::new();

    let outcome = attempt_liveness_refresh(&cipher, &port, &garbage_ct).await;
    assert!(matches!(
        outcome,
        LivenessOutcome::Failed(LivenessFailure::Revoked { cause: RevokeCause::CipherFailure })
    ));
}
