//! Boundary tests for `pas_external::oidc::axum::BearerAuthLayer`.
//!
//! Lift of the 9-test suite that chat-auth + RCW + CTW carry as
//! near-identical `#[cfg(test)] mod tests` blocks (see RFC §11.5 —
//! "Replace, don't layer"). Once Slices 2/4/5 retire those consumer-
//! side test modules, this file is the single boundary-test SSOT for
//! the perimeter mechanism.
//!
//! Tests exercise the Layer in-process via tower's `Service` trait —
//! no axum app, no HTTP server, no DB or substrate. Just raw
//! `Service::call`. Assertions land on the Layer's contract:
//! - extension injection (Bearer source resolves → typed session in
//!   `req.extensions()`)
//! - status mapping (200 / 401 / 503)
//! - cookie clearance (Set-Cookie present iff dead-session 401)
//! - validator invocation (called exactly once, or zero times when no
//!   token is present)

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

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

use axum::body::Body;
use axum::http::header::{AUTHORIZATION, COOKIE, SET_COOKIE};
use axum::http::{Request, Response};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use pas_external::oidc::axum::{
    AuthProvider, BearerAuthConfig, BearerAuthLayer, MemoryAuthProvider, VerifyError,
};
use tower::{Layer, Service, ServiceExt};

// ─────────────────────────────────────────────────────────────────────
// Test fixtures
// ─────────────────────────────────────────────────────────────────────

/// Local perimeter-session type for the test. Echoes the bearer string
/// back so assertions can verify "the validator saw exactly this token
/// and the session reached the inner service" in one shot.
#[derive(Clone, Debug, PartialEq, Eq)]
struct TestSession {
    token_echo: String,
}

const TEST_AT_COOKIE: &str = "__Host-test_at";
const TEST_RT_COOKIE: &str = "__Host-test_rt";
const UNRELATED_COOKIE: &str = "__Host-test_state";

/// Test clearance closure — adds add-based removal markers for both
/// AT and RT cookies. Mirrors the production `clear_session_cookies`
/// shape; the SDK Layer only ever calls this on dead-session 401s.
fn test_clear_cookies(jar: CookieJar) -> CookieJar {
    let mut at = Cookie::new(TEST_AT_COOKIE, "");
    at.set_path("/");
    at.set_max_age(time::Duration::seconds(0));
    let mut rt = Cookie::new(TEST_RT_COOKIE, "");
    rt.set_path("/");
    rt.set_max_age(time::Duration::seconds(0));
    jar.add(at).add(rt)
}

fn test_config() -> BearerAuthConfig {
    BearerAuthConfig::new(TEST_AT_COOKIE, Arc::new(test_clear_cookies))
}

fn good_session(token: &str) -> TestSession {
    TestSession {
        token_echo: token.to_owned(),
    }
}

/// Build a tower service: BearerAuthLayer wrapping a leaf that
/// reflects the perimeter session back through the response body so
/// tests can assert on what reached the inner.
fn build_service(
    provider: Arc<dyn AuthProvider<TestSession>>,
) -> impl Service<Request<Body>, Response = Response<Body>, Error = std::convert::Infallible> + Clone
{
    let inner = tower::service_fn(|req: Request<Body>| async move {
        let body = match req.extensions().get::<TestSession>().cloned() {
            Some(s) => format!("OK echo={}", s.token_echo),
            None => "OK no-auth".to_owned(),
        };
        Ok::<_, std::convert::Infallible>(
            Response::builder()
                .status(200)
                .body(Body::from(body))
                .unwrap(),
        )
    });
    BearerAuthLayer::<TestSession, dyn AuthProvider<TestSession>>::new(provider, test_config())
        .layer(inner)
}

async fn body_string(resp: Response<Body>) -> String {
    let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
        .await
        .unwrap();
    String::from_utf8(bytes.to_vec()).unwrap()
}

/// MemoryAuthProvider doesn't expose a built-in call counter, so wrap
/// the verdict closure with an `AtomicUsize` capture for tests that
/// need to assert "validator was/wasn't invoked".
fn counting_provider(
    counter: Arc<AtomicUsize>,
    outcome: impl Fn(&str) -> Result<TestSession, VerifyError> + Send + Sync + 'static,
) -> Arc<dyn AuthProvider<TestSession>> {
    Arc::new(MemoryAuthProvider::new(move |t: &str| {
        counter.fetch_add(1, Ordering::SeqCst);
        outcome(t)
    }))
}

// ─────────────────────────────────────────────────────────────────────
// 1. Header path — Authorization: Bearer X → session injected
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn header_path_attaches_session_and_reaches_inner() {
    let calls = Arc::new(AtomicUsize::new(0));
    let provider = counting_provider(calls.clone(), |t| Ok(good_session(t)));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(AUTHORIZATION, "Bearer JWT.HEADER.SIG")
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(body_string(resp).await, "OK echo=JWT.HEADER.SIG");
    assert_eq!(calls.load(Ordering::SeqCst), 1);
}

// ─────────────────────────────────────────────────────────────────────
// 2. Cookie path — __Host-*_at fallback when no Authorization header
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn cookie_path_attaches_session_and_reaches_inner() {
    let calls = Arc::new(AtomicUsize::new(0));
    let provider = counting_provider(calls.clone(), |t| Ok(good_session(t)));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(COOKIE, format!("{TEST_AT_COOKIE}=JWT.COOKIE.SIG; other=x"))
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(body_string(resp).await, "OK echo=JWT.COOKIE.SIG");
    assert_eq!(calls.load(Ordering::SeqCst), 1);
}

// ─────────────────────────────────────────────────────────────────────
// 3. Header wins precedence — both header and cookie present
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn header_wins_over_cookie_when_both_present() {
    // Authorization carries one token, cookie carries another. The
    // Layer must choose Authorization; the cookie must be ignored
    // entirely (validator sees only the header value).
    let provider: Arc<dyn AuthProvider<TestSession>> =
        Arc::new(MemoryAuthProvider::new(|t: &str| {
            assert_eq!(t, "FROM-HEADER", "header must win over cookie");
            Ok(good_session(t))
        }));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(AUTHORIZATION, "Bearer FROM-HEADER")
        .header(COOKIE, format!("{TEST_AT_COOKIE}=FROM-COOKIE"))
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(body_string(resp).await, "OK echo=FROM-HEADER");
}

// ─────────────────────────────────────────────────────────────────────
// 4. Missing credentials — 401 without cookie clearance
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn missing_credentials_returns_401_without_clear() {
    // No Authorization, no cookie — the Layer rejects with 401 but
    // does NOT emit cookie-removal Set-Cookie headers (there's no
    // session to clear; the browser was simply unauthenticated).
    // Validator must NOT be invoked.
    let calls = Arc::new(AtomicUsize::new(0));
    let provider = counting_provider(calls.clone(), |_| Ok(good_session("unused")));
    let svc = build_service(provider);
    let req = Request::builder().body(Body::empty()).unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 401);
    assert_eq!(
        resp.headers().get_all(SET_COOKIE).iter().count(),
        0,
        "no Set-Cookie on missing-credentials 401"
    );
    assert_eq!(
        calls.load(Ordering::SeqCst),
        0,
        "validator must not be invoked when no token is present"
    );
}

// ─────────────────────────────────────────────────────────────────────
// 5. Rejected token — 401 with cookie clearance
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn rejected_token_returns_401_with_cookie_clearance() {
    // Token is present (cookie source) but validator rejects. The
    // Layer emits 401 PLUS Set-Cookie removals via the consumer's
    // `on_clear` closure so the browser stops replaying the dead
    // session.
    let provider: Arc<dyn AuthProvider<TestSession>> = Arc::new(MemoryAuthProvider::new(
        |_: &str| Err(VerifyError::Rejected("expired".into())),
    ));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(COOKIE, format!("{TEST_AT_COOKIE}=DEAD.JWT"))
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 401);
    let set_cookies: Vec<String> = resp
        .headers()
        .get_all(SET_COOKIE)
        .iter()
        .map(|v| v.to_str().unwrap().to_owned())
        .collect();
    assert!(
        set_cookies
            .iter()
            .any(|c| c.contains(TEST_AT_COOKIE) && c.contains("Max-Age=0")),
        "AT removal marker present: {set_cookies:?}"
    );
    assert!(
        set_cookies
            .iter()
            .any(|c| c.contains(TEST_RT_COOKIE) && c.contains("Max-Age=0")),
        "RT removal marker present: {set_cookies:?}"
    );
}

// ─────────────────────────────────────────────────────────────────────
// 6. Substrate transient — 503 with cookies preserved
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn substrate_transient_returns_503_cookies_preserved() {
    // Substrate down → AuthProvider returns SubstrateTransient → Layer
    // returns 503 with no Set-Cookie clearance. The session may still
    // be valid; the browser may retry without re-authenticating.
    let provider: Arc<dyn AuthProvider<TestSession>> =
        Arc::new(MemoryAuthProvider::new(|_: &str| {
            Err(VerifyError::SubstrateTransient("DB down".into()))
        }));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(AUTHORIZATION, "Bearer JWT.X.Y")
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 503);
    assert_eq!(
        resp.headers().get_all(SET_COOKIE).iter().count(),
        0,
        "transient must not clear cookies — session may still be valid"
    );
}

// ─────────────────────────────────────────────────────────────────────
// 7. Cookie value verbatim — no decoding, no trimming
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn cookie_value_is_passed_to_validator_verbatim() {
    // Whatever the browser put in the cookie value reaches the
    // validator verbatim. The cookie's well-formedness is the
    // validator's problem, not the Layer's.
    let provider: Arc<dyn AuthProvider<TestSession>> =
        Arc::new(MemoryAuthProvider::new(|t: &str| {
            assert_eq!(t, "raw.value with.dots-and-dashes_andUS", "verbatim");
            Ok(good_session(t))
        }));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(
            COOKIE,
            format!("a=1; {TEST_AT_COOKIE}=raw.value with.dots-and-dashes_andUS; b=2"),
        )
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 200);
}

// ─────────────────────────────────────────────────────────────────────
// 8. Empty token — treated as missing, validator not invoked
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn empty_token_value_treated_as_missing() {
    // `Authorization: Bearer ` (trailing space, empty token) and
    // `__Host-test_at=` (empty cookie value) must both be treated as
    // "no token" — 401 without cookie clearance, validator never
    // invoked. Otherwise an attacker could cause "clear cookies"
    // behaviour by replaying an empty cookie.
    let provider: Arc<dyn AuthProvider<TestSession>> =
        Arc::new(MemoryAuthProvider::new(|_: &str| {
            panic!("validator must not be invoked on empty token")
        }));
    let svc = build_service(provider);

    // Empty Authorization
    let req = Request::builder()
        .header(AUTHORIZATION, "Bearer ")
        .body(Body::empty())
        .unwrap();
    let resp = svc.clone().oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 401);
    assert_eq!(resp.headers().get_all(SET_COOKIE).iter().count(), 0);

    // Empty cookie value
    let req = Request::builder()
        .header(COOKIE, format!("{TEST_AT_COOKIE}="))
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 401);
    assert_eq!(resp.headers().get_all(SET_COOKIE).iter().count(), 0);
}

// ─────────────────────────────────────────────────────────────────────
// 9. Lowercase Bearer prefix — RFC 7235 §2.1 case-insensitive
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn lowercase_bearer_prefix_accepted() {
    // RFC 7235 §2.1 — "Bearer" auth scheme is case-insensitive in
    // HTTP. Some clients emit lowercase. The Layer accepts both.
    let provider: Arc<dyn AuthProvider<TestSession>> =
        Arc::new(MemoryAuthProvider::new(|t: &str| Ok(good_session(t))));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(AUTHORIZATION, "bearer LOWER.CASE.TOKEN")
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(body_string(resp).await, "OK echo=LOWER.CASE.TOKEN");
}

// ─────────────────────────────────────────────────────────────────────
// 10. Unrelated cookies in jar — extraction finds the right one
// ─────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn unrelated_cookies_in_jar_do_not_confuse_extraction() {
    let provider: Arc<dyn AuthProvider<TestSession>> =
        Arc::new(MemoryAuthProvider::new(|t: &str| {
            assert_eq!(t, "REAL.AT.JWT");
            Ok(good_session(t))
        }));
    let svc = build_service(provider);
    let req = Request::builder()
        .header(
            COOKIE,
            format!(
                "session=foo; {UNRELATED_COOKIE}=ignored; {TEST_AT_COOKIE}=REAL.AT.JWT; theme=dark"
            ),
        )
        .body(Body::empty())
        .unwrap();
    let resp = svc.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), 200);
}
