//! Attack-confirming tests for CSRF protection on /web/ state-changing
//! endpoints.
//!
//! Each test crafts a request that simulates a real attack shape
//! (cross-site POST with valid cookie, wrong token, empty token, etc.)
//! and asserts the expected defense. The defense is implemented by
//! the `CsrfForm<T>` extractor in `server/src/csrf.rs`, validated
//! against `WebSession::csrf_token`.

mod common;

use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use doctate_common::BulkAction;
use tower::util::ServiceExt;

use common::{
    TestConfig, csrf_form_post, form_post, get_with_cookie, login, login_with_csrf, paths,
    seed_case, test_admin, test_user, test_user_with_password,
};

// ---------- session hygiene (green today) ----------

/// Session-fixation: attacker sets a known `session=...` cookie on the
/// victim's browser. If the server reused that value after login, the
/// attacker would be logged in as the victim. Defense: every successful
/// login mints a fresh token; the pre-set value must be overwritten.
#[tokio::test]
async fn session_fixation_login_rotates_cookie() {
    let cfg = TestConfig::new()
        .with_user(test_user_with_password("dr_a", "secret"))
        .build();
    let app = doctate_server::create_router(cfg);

    let attacker_fixed_value = "session=attacker-fixed-token-value-aaaaaaaaaaaaaaaa";
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(paths::LOGIN)
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
                .header(header::COOKIE, attacker_fixed_value)
                .body(Body::from("slug=dr_a&password=secret"))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
    let minted = common::extract_session_cookie(&resp).expect("no Set-Cookie on login");
    assert!(
        minted != attacker_fixed_value,
        "login did not rotate the cookie — session fixation possible"
    );
    assert!(
        minted.starts_with("session="),
        "Set-Cookie shape unexpected: {minted}"
    );
    let minted_token = minted.trim_start_matches("session=");
    assert!(
        minted_token.len() >= 40,
        "minted token looks truncated: {minted_token:?}"
    );
}

// ---------- CSRF defense — missing token ----------

#[tokio::test]
async fn bulk_without_csrf_token_forbidden() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    let body = format!(
        "action={}&case_id=00000000-0000-0000-0000-000000000000",
        BulkAction::Close
    );
    let resp = app
        .oneshot(form_post(paths::BULK, &cookie, body))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "bulk POST without csrf_token should be 403 — got {}",
        resp.status()
    );
}

#[tokio::test]
async fn purge_closed_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    let resp = app
        .oneshot(form_post(paths::PURGE_CLOSED, &cookie, "confirm=yes"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn reset_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    let resp = app
        .oneshot(form_post(
            paths::case_reset("11111111-1111-1111-1111-111111111111"),
            &cookie,
            "",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn close_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_a", "s").await;

    let resp = app
        .oneshot(form_post(
            paths::case_close("11111111-1111-1111-1111-111111111111"),
            &cookie,
            "",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn reopen_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_a", "s").await;

    let resp = app
        .oneshot(form_post(
            paths::case_reopen("11111111-1111-1111-1111-111111111111"),
            &cookie,
            "",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn analyze_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_a", "s").await;

    let resp = app
        .oneshot(form_post(
            paths::case_analyze("11111111-1111-1111-1111-111111111111"),
            &cookie,
            "",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn delete_recording_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_a", "s").await;

    let resp = app
        .oneshot(form_post(
            paths::recording_delete("11111111-1111-1111-1111-111111111111"),
            &cookie,
            "filename=2026-04-14T10-00-00Z.m4a",
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the
/// victim out of their active session (annoyance / phishing setup where
/// victim re-enters password on a lookalike page).
#[tokio::test]
async fn logout_without_csrf_forbidden() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_a", "s").await;

    let resp = app
        .oneshot(form_post(paths::LOGOUT, &cookie, ""))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "forced-logout CSRF not blocked"
    );
}

// ---------- CSRF defense — malformed token values ----------

#[tokio::test]
async fn bulk_with_wrong_csrf_token_forbidden() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    // A 43-char alphanumeric that is structurally valid but does not
    // match the session's token.
    let wrong = "a".repeat(43);
    let body = format!(
        "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}",
        BulkAction::Close
    );
    let resp = app
        .oneshot(form_post(paths::BULK, &cookie, body))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn bulk_with_empty_csrf_token_forbidden() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    let body = format!(
        "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token=",
        BulkAction::Close
    );
    let resp = app
        .oneshot(form_post(paths::BULK, &cookie, body))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "empty csrf_token must not bypass the check"
    );
}

/// Whitespace trick: some frameworks trim tokens. If the server trimmed,
/// an attacker could send `csrf_token=%0A%0A` and hit an early-return
/// `if token.is_empty() { skip }` branch. Defense: compare raw.
#[tokio::test]
async fn bulk_with_whitespace_padded_token_forbidden() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    // Leading+trailing spaces around a value that *would* match post-trim.
    let body = format!(
        "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20",
        BulkAction::Close
    );
    let resp = app
        .oneshot(form_post(paths::BULK, &cookie, body))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

/// Paranoia: token-field value is a SQL-injection-looking string. The
/// server uses a HashMap, so SQL is a non-issue — this test just pins
/// down that the check returns a clean 403 rather than 500/panic on
/// unusual byte content.
#[tokio::test]
async fn bulk_with_injection_payload_returns_403_not_500() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    let app = doctate_server::create_router(cfg);
    let cookie = login(&app, "dr_admin", "s").await;

    // `' OR 1=1--`  URL-encoded.
    let body = format!(
        "action={}&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%27+OR+1%3D1--",
        BulkAction::Close
    );
    let resp = app
        .oneshot(form_post(paths::BULK, &cookie, body))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "malformed token must yield 403, not 500"
    );
}

// ---------- template rendering: the token must reach the browser ----------
//
// The server-side CSRF check is useless if the templates forget to emit
// the hidden field — the legitimate user would get 403 on every button.
// These tests GET each authenticated page, grab the session's real
// csrf_token from the store, and assert it appears verbatim in a
// hidden input in the rendered HTML. If anyone adds a new state-
// changing form without the {% call csrf::field(csrf_token) %} macro,
// the browser flow breaks and one of these fires.

async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String {
    let resp = app
        .clone()
        .oneshot(get_with_cookie(uri, cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
    common::body_string(resp).await
}

fn hidden_field(token: &str) -> String {
    format!(r#"name="csrf_token" value="{token}""#)
}

#[tokio::test]
async fn my_cases_page_renders_csrf_token_hidden_field() {
    let cfg = TestConfig::new().with_user(test_admin("dr_admin")).build();
    // Seed a case so the bulk form is rendered (the template skips it
    // when the list is empty) — otherwise only the logout form shows.
    let case_dir = seed_case(
        &cfg.data_path,
        "dr_admin",
        "11111111-1111-1111-1111-111111111111",
    );
    std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();

    let (app, store) = doctate_server::create_router_and_session_store(cfg);
    let (cookie, csrf) = login_with_csrf(&app, &store, "dr_admin", "s").await;

    let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await;
    let expected = hidden_field(&csrf);

    assert!(
        body.contains(&expected),
        "/web/cases should render hidden csrf_token ({expected}), \
         first 200 chars of body: {}",
        &body[..body.len().min(200)]
    );
    assert!(
        body.contains(r#"action="/web/logout""#)
            && body.matches(r#"name="csrf_token""#).count() >= 2,
        "expected at least 2 csrf_token hidden fields (logout + bulk); \
         got {} occurrences",
        body.matches(r#"name="csrf_token""#).count()
    );
}

#[tokio::test]
async fn case_page_renders_csrf_token_hidden_field() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let case_id = "11111111-1111-1111-1111-111111111111";
    let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
    std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();

    let (app, store) = doctate_server::create_router_and_session_store(cfg);
    let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;

    let body = html_body(&app, &paths::case_detail(case_id), &cookie).await;
    let expected = hidden_field(&csrf);

    assert!(
        body.contains(&expected),
        "case page must render hidden csrf_token for close/analyze forms"
    );
    assert!(
        body.matches(r#"name="csrf_token""#).count() >= 2,
        "case page should have ≥2 csrf_token inputs; got {}",
        body.matches(r#"name="csrf_token""#).count()
    );
}

#[tokio::test]
async fn case_recordings_page_renders_csrf_token_hidden_field() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let case_id = "11111111-1111-1111-1111-111111111111";
    let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
    std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
    common::seed_recording_meta(
        &case_dir,
        "2026-04-14T10-00-00Z",
        common::Transcript::Content { text: "hi".into() },
        None,
    );

    let (app, store) = doctate_server::create_router_and_session_store(cfg);
    let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;

    let body = html_body(&app, &paths::case_recordings(case_id), &cookie).await;
    let expected = hidden_field(&csrf);

    assert!(
        body.contains(&expected),
        "recordings page must render hidden csrf_token for the delete form"
    );
    assert!(
        body.matches(r#"name="csrf_token""#).count() >= 2,
        "recordings page should have ≥2 csrf_token inputs; got {}",
        body.matches(r#"name="csrf_token""#).count()
    );
}

/// Full round-trip: fetch the page, extract the csrf_token from the
/// rendered HTML, POST with that token — expect 303 (success). This is
/// the legitimate-user path; if the server-side check or the template
/// ever drift out of sync, this test catches it. Protects against the
/// subtle regression where the token is rendered but with a value the
/// server won't accept (e.g. accidental trim, base64-encoded, etc.).
#[tokio::test]
async fn happy_path_post_with_rendered_token_accepted() {
    let cfg = TestConfig::new().with_user(test_user("dr_a")).build();
    let case_id = "11111111-1111-1111-1111-111111111111";
    let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
    std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();

    let (app, store) = doctate_server::create_router_and_session_store(cfg);
    let (cookie, session_csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;

    // Render the case page, parse the csrf_token value out of any
    // hidden input — do NOT use the session store directly; this tests
    // the *rendered* token specifically.
    let body = html_body(&app, &paths::case_detail(case_id), &cookie).await;
    let needle = r#"name="csrf_token" value=""#;
    let start = body.find(needle).expect("no csrf_token in HTML") + needle.len();
    let end = body[start..]
        .find('"')
        .expect("unterminated csrf_token value")
        + start;
    let rendered = &body[start..end];

    // Rendered token must be the session's actual token. If these ever
    // diverge we want the test to fail loudly.
    assert_eq!(
        rendered, session_csrf,
        "rendered csrf_token must match session's csrf_token"
    );

    // POST close with the rendered token → must succeed.
    let resp = app
        .oneshot(csrf_form_post(
            paths::case_close(case_id),
            &cookie,
            rendered,
            "",
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::SEE_OTHER,
        "legitimate POST with rendered token must be accepted (303)"
    );
}
