//! Integration tests for `solitaire_server`.
//!
//! Every test uses an in-memory SQLite database and [`build_test_router`]
//! (rate limiting disabled) — no real TCP listener is started.  Requests are dispatched via
//! [`tower::ServiceExt::oneshot`].
//!
//! # JWT secret
//!
//! [`build_test_router`] injects a fixed test secret into [`AppState`] so
//! tests do not need to set `JWT_SECRET` in the environment.  The constant
//! [`TEST_SECRET`] must match the value used by [`build_test_router`] so that
//! test-side token decoding works correctly.

use axum::{
    body::Body,
    http::{Request, StatusCode},
    response::Response,
};
use chrono::Utc;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::Deserialize;
use serde_json::Value;
use solitaire_server::build_test_router;
use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload};
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use tower::ServiceExt;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// JWT secret used by [`build_test_router`] and by test-side token decoding.
///
/// Must match the value hardcoded in [`solitaire_server::build_test_router`].
const TEST_SECRET: &str = "test_secret_32_chars_minimum_ok!";

// ---------------------------------------------------------------------------
// Test infrastructure helpers
// ---------------------------------------------------------------------------

/// Create an in-memory SQLite pool and run all pending migrations.
///
/// `max_connections(1)` is required for SQLite in-memory databases: each
/// connection to `sqlite::memory:` is a *separate* database, so if the pool
/// opens a second connection the handler sees an empty schema and fails.
async fn test_pool() -> SqlitePool {
    let pool = SqlitePoolOptions::new()
        .max_connections(1)
        .connect("sqlite::memory:")
        .await
        .expect("failed to connect to in-memory SQLite database");
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("failed to run database migrations");
    pool
}

/// Fake client IP injected by all test requests so `tower_governor`'s
/// `SmartIpKeyExtractor` can extract a key without a real peer address.
const TEST_CLIENT_IP: &str = "127.0.0.1";

/// Send a `POST` request with a JSON body and return the raw response.
async fn post_json(app: axum::Router, path: &str, body: Value) -> Response {
    let req = Request::builder()
        .method("POST")
        .uri(path)
        .header("content-type", "application/json")
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::from(
            serde_json::to_vec(&body).expect("failed to serialise request body"),
        ))
        .expect("failed to build POST request");
    app.oneshot(req).await.expect("oneshot failed")
}

/// Send an authenticated `GET` request and return the raw response.
async fn get_authed(app: axum::Router, path: &str, token: &str) -> Response {
    let req = Request::builder()
        .method("GET")
        .uri(path)
        .header("Authorization", format!("Bearer {token}"))
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::empty())
        .expect("failed to build GET request");
    app.oneshot(req).await.expect("oneshot failed")
}

/// Send an authenticated `POST` request with a JSON body and return the raw response.
async fn post_authed(app: axum::Router, path: &str, token: &str, body: Value) -> Response {
    let req = Request::builder()
        .method("POST")
        .uri(path)
        .header("content-type", "application/json")
        .header("Authorization", format!("Bearer {token}"))
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::from(
            serde_json::to_vec(&body).expect("failed to serialise request body"),
        ))
        .expect("failed to build authenticated POST request");
    app.oneshot(req).await.expect("oneshot failed")
}

/// Send an authenticated `DELETE` request and return the raw response.
async fn delete_authed(app: axum::Router, path: &str, token: &str) -> Response {
    let req = Request::builder()
        .method("DELETE")
        .uri(path)
        .header("Authorization", format!("Bearer {token}"))
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::empty())
        .expect("failed to build DELETE request");
    app.oneshot(req).await.expect("oneshot failed")
}

/// Collect the response body bytes and deserialise them as JSON.
async fn body_json(resp: Response) -> Value {
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .expect("failed to read response body");
    serde_json::from_slice(&bytes).expect("response body is not valid JSON")
}

// ---------------------------------------------------------------------------
// JWT helpers (test-side only)
// ---------------------------------------------------------------------------

/// Minimal JWT claims used only for decoding in test assertions.
#[derive(Deserialize)]
struct TestClaims {
    sub: String,
}

/// Decode an access token and return the `sub` (user UUID) claim.
///
/// Uses `validate_exp = false` so tests never fail due to clock skew between
/// token issuance and assertion.
fn decode_sub(token: &str) -> String {
    let mut v = Validation::default();
    v.validate_exp = false;
    let data = decode::<TestClaims>(token, &DecodingKey::from_secret(TEST_SECRET.as_bytes()), &v)
        .expect("failed to decode access token");
    data.claims.sub
}

/// Register a new user and return `(access_token, refresh_token)`.
async fn register_user(app: axum::Router, username: &str, password: &str) -> (String, String) {
    let resp = post_json(
        app,
        "/api/auth/register",
        serde_json::json!({ "username": username, "password": password }),
    )
    .await;
    assert_eq!(resp.status(), StatusCode::OK, "register should return 200");
    let body = body_json(resp).await;
    let access = body["access_token"]
        .as_str()
        .expect("access_token missing from register response")
        .to_string();
    let refresh = body["refresh_token"]
        .as_str()
        .expect("refresh_token missing from register response")
        .to_string();
    (access, refresh)
}

/// Build a [`SyncPayload`] for `user_id_str` with `games_played` set to the
/// given value and all other fields set to defaults.
fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
    SyncPayload {
        user_id: uuid::Uuid::parse_str(user_id_str)
            .expect("user_id_str from JWT sub must be a valid UUID"),
        stats: StatsSnapshot {
            games_played,
            games_won: 3,
            ..StatsSnapshot::default()
        },
        achievements: vec![],
        progress: PlayerProgress::default(),
        last_modified: Utc::now(),
    }
}

// ---------------------------------------------------------------------------
// Auth flow tests
// ---------------------------------------------------------------------------

/// `POST /api/auth/register` must return 200 with both tokens.
#[tokio::test]
async fn register_creates_account_and_returns_tokens() {
    let app = build_test_router(test_pool().await);

    let resp = post_json(
        app,
        "/api/auth/register",
        serde_json::json!({ "username": "alice", "password": "hunter2!" }),
    )
    .await;

    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_json(resp).await;
    assert!(
        body["access_token"].is_string(),
        "access_token must be present"
    );
    assert!(
        body["refresh_token"].is_string(),
        "refresh_token must be present"
    );
}

/// Registering the same username twice must return 409 Conflict on the second attempt.
#[tokio::test]
async fn register_duplicate_username_returns_conflict() {
    let app = build_test_router(test_pool().await);
    let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });

    // First registration succeeds.
    let first = post_json(app.clone(), "/api/auth/register", creds.clone()).await;
    assert_eq!(
        first.status(),
        StatusCode::OK,
        "first register must succeed"
    );

    // Second registration with the same username is rejected.
    let second = post_json(app, "/api/auth/register", creds).await;
    assert_eq!(
        second.status(),
        StatusCode::CONFLICT,
        "duplicate username must return 409"
    );
}

/// Short username (< 3 chars) is rejected with 400.
#[tokio::test]
async fn register_rejects_short_username() {
    let app = build_test_router(test_pool().await);
    let resp = post_json(
        app,
        "/api/auth/register",
        serde_json::json!({ "username": "ab", "password": "validpassword" }),
    )
    .await;
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// Username with disallowed characters is rejected with 400.
#[tokio::test]
async fn register_rejects_invalid_username_chars() {
    let app = build_test_router(test_pool().await);
    let resp = post_json(
        app,
        "/api/auth/register",
        serde_json::json!({ "username": "bad name!", "password": "validpassword" }),
    )
    .await;
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// Password shorter than 8 characters is rejected with 400.
#[tokio::test]
async fn register_rejects_short_password() {
    let app = build_test_router(test_pool().await);
    let resp = post_json(
        app,
        "/api/auth/register",
        serde_json::json!({ "username": "validuser", "password": "short" }),
    )
    .await;
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
#[tokio::test]
async fn login_with_correct_credentials_returns_tokens() {
    let app = build_test_router(test_pool().await);

    // Register first.
    let _ = register_user(app.clone(), "charlie", "p4ssw0rd").await;

    // Then login.
    let resp = post_json(
        app,
        "/api/auth/login",
        serde_json::json!({ "username": "charlie", "password": "p4ssw0rd" }),
    )
    .await;

    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_json(resp).await;
    assert!(
        body["access_token"].is_string(),
        "access_token must be present"
    );
    assert!(
        body["refresh_token"].is_string(),
        "refresh_token must be present"
    );
}

/// `POST /api/auth/login` with a wrong password must return 401.
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
    let app = build_test_router(test_pool().await);

    // Register a user.
    let _ = register_user(app.clone(), "dave", "correct_horse").await;

    // Attempt to log in with the wrong password.
    let resp = post_json(
        app,
        "/api/auth/login",
        serde_json::json!({ "username": "dave", "password": "wrong_password" }),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "wrong password must return 401"
    );
}

/// `POST /api/auth/login` for a username that does not exist must return 401.
#[tokio::test]
async fn login_with_unknown_username_returns_401() {
    let app = build_test_router(test_pool().await);

    let resp = post_json(
        app,
        "/api/auth/login",
        serde_json::json!({ "username": "nobody", "password": "whatever" }),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "unknown username must return 401"
    );
}

/// `POST /api/auth/refresh` with a valid refresh token returns 200 with both
/// a new access token and a rotated refresh token.
#[tokio::test]
async fn refresh_returns_new_access_and_refresh_tokens() {
    let app = build_test_router(test_pool().await);

    let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;

    let resp = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": refresh }),
    )
    .await;

    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_json(resp).await;
    assert!(
        body["access_token"].is_string(),
        "refresh must return a new access_token"
    );
    assert!(
        body["refresh_token"].is_string(),
        "refresh must return a rotated refresh_token"
    );
    let rotated = body["refresh_token"].as_str().unwrap();
    assert_ne!(
        rotated, refresh,
        "rotated refresh token must differ from the original"
    );
}

/// After a successful rotation, the old refresh token must be rejected (consumed).
#[tokio::test]
async fn consumed_refresh_token_is_rejected() {
    let app = build_test_router(test_pool().await);

    let (_access, original_refresh) =
        register_user(app.clone(), "grace_rot", "rotation_pass").await;

    // First refresh — consumes original_refresh, returns a new one.
    let resp1 = post_json(
        app.clone(),
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": original_refresh }),
    )
    .await;
    assert_eq!(
        resp1.status(),
        StatusCode::OK,
        "first rotation must succeed"
    );

    // Second attempt with the now-consumed original token must fail.
    let resp2 = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": original_refresh }),
    )
    .await;
    assert_eq!(
        resp2.status(),
        StatusCode::UNAUTHORIZED,
        "consumed refresh token must return 401"
    );
}

/// The rotated refresh token must be usable for a subsequent refresh.
#[tokio::test]
async fn rotated_refresh_token_can_be_used_again() {
    let app = build_test_router(test_pool().await);

    let (_access, refresh) = register_user(app.clone(), "helen_rot", "pass_word_1").await;

    // First rotation.
    let resp1 = post_json(
        app.clone(),
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": refresh }),
    )
    .await;
    assert_eq!(resp1.status(), StatusCode::OK);
    let rotated = body_json(resp1).await;
    let second_refresh = rotated["refresh_token"].as_str().unwrap().to_string();

    // Second rotation using the first rotated token.
    let resp2 = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": second_refresh }),
    )
    .await;
    assert_eq!(
        resp2.status(),
        StatusCode::OK,
        "rotated token must work for a second rotation"
    );
    let body2 = body_json(resp2).await;
    assert!(body2["access_token"].is_string());
}

/// Supplying an access token to `POST /api/auth/refresh` must be rejected because
/// the `kind` claim will be `"access"`, not `"refresh"`.
#[tokio::test]
async fn refresh_with_access_token_returns_401() {
    let app = build_test_router(test_pool().await);

    let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;

    // Send the access token as if it were a refresh token.
    let resp = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": access }),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "using an access token as a refresh token must return 401"
    );
}

// ---------------------------------------------------------------------------
// Sync roundtrip tests
// ---------------------------------------------------------------------------

/// Push a payload, then pull — the pulled data must reflect the pushed values.
#[tokio::test]
async fn push_then_pull_returns_pushed_data() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
    let user_id = decode_sub(&access);

    let payload = make_payload(&user_id, 7);

    // Push the payload to the server.
    let push_resp = post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(&payload).expect("SyncPayload must serialise"),
    )
    .await;
    assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");

    // Pull and verify the stats were persisted.
    let pull_resp = get_authed(app, "/api/sync/pull", &access).await;
    assert_eq!(pull_resp.status(), StatusCode::OK, "pull must return 200");

    let pull_body = body_json(pull_resp).await;
    let games_played = pull_body["merged"]["stats"]["games_played"]
        .as_u64()
        .expect("games_played must be a number");
    assert_eq!(
        games_played, 7,
        "pulled games_played must match pushed value"
    );
}

/// Full register → login → push → pull integration roundtrip.
///
/// This test drives every auth and sync endpoint in sequence to verify that
/// the complete happy-path flow works end-to-end with a fresh in-memory
/// database:
///   1. Register a new user — extracts the access token from the response.
///   2. Login with the same credentials — obtains a fresh access token from
///      the login endpoint (not reusing the registration token).
///   3. Push a `SyncPayload` with known stats via `POST /api/sync/push`.
///   4. Pull via `GET /api/sync/pull` and assert the pulled payload reflects
///      the pushed values.
#[tokio::test]
async fn register_login_push_pull_full_roundtrip() {
    let app = build_test_router(test_pool().await);

    // --- Step 1: Register ---
    let reg_resp = post_json(
        app.clone(),
        "/api/auth/register",
        serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
    )
    .await;
    assert_eq!(
        reg_resp.status(),
        StatusCode::OK,
        "registration must return 200"
    );
    let reg_body = body_json(reg_resp).await;
    assert!(
        reg_body["access_token"].is_string(),
        "register must return an access_token"
    );

    // --- Step 2: Login (explicit — do not reuse the registration token) ---
    let login_resp = post_json(
        app.clone(),
        "/api/auth/login",
        serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
    )
    .await;
    assert_eq!(login_resp.status(), StatusCode::OK, "login must return 200");
    let login_body = body_json(login_resp).await;
    let access_token = login_body["access_token"]
        .as_str()
        .expect("login must return access_token")
        .to_string();

    // Decode the user UUID from the login JWT so we can construct the payload.
    let user_id = decode_sub(&access_token);

    // --- Step 3: Push a payload with known values ---
    let payload = SyncPayload {
        user_id: uuid::Uuid::parse_str(&user_id).expect("JWT sub must be a valid UUID"),
        stats: StatsSnapshot {
            games_played: 42,
            games_won: 17,
            best_single_score: 4_200,
            fastest_win_seconds: 95,
            ..StatsSnapshot::default()
        },
        achievements: vec![],
        progress: PlayerProgress::default(),
        last_modified: Utc::now(),
    };

    let push_resp = post_authed(
        app.clone(),
        "/api/sync/push",
        &access_token,
        serde_json::to_value(&payload).expect("SyncPayload must serialise"),
    )
    .await;
    assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");

    // --- Step 4: Pull and verify the stored data matches what was pushed ---
    let pull_resp = get_authed(app, "/api/sync/pull", &access_token).await;
    assert_eq!(pull_resp.status(), StatusCode::OK, "pull must return 200");

    let pull_body = body_json(pull_resp).await;
    let merged = &pull_body["merged"];

    assert_eq!(
        merged["stats"]["games_played"].as_u64(),
        Some(42),
        "pulled games_played must match the pushed value"
    );
    assert_eq!(
        merged["stats"]["games_won"].as_u64(),
        Some(17),
        "pulled games_won must match the pushed value"
    );
    assert_eq!(
        merged["stats"]["best_single_score"].as_u64(),
        Some(4_200),
        "pulled best_single_score must match the pushed value"
    );
    assert_eq!(
        merged["stats"]["fastest_win_seconds"].as_u64(),
        Some(95),
        "pulled fastest_win_seconds must match the pushed value"
    );
}

/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
#[tokio::test]
async fn push_with_wrong_user_id_returns_400() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;

    // Build a payload with a random UUID that won't match the JWT sub.
    let wrong_uuid = uuid::Uuid::new_v4();
    let payload = SyncPayload {
        user_id: wrong_uuid,
        stats: StatsSnapshot::default(),
        achievements: vec![],
        progress: PlayerProgress::default(),
        last_modified: Utc::now(),
    };

    let resp = post_authed(
        app,
        "/api/sync/push",
        &access,
        serde_json::to_value(&payload).expect("SyncPayload must serialise"),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "mismatched user_id must return 400"
    );
}

/// A pull before any push returns a default empty payload (200, not 404).
#[tokio::test]
async fn pull_before_push_returns_default_payload() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;

    let resp = get_authed(app, "/api/sync/pull", &access).await;
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "pull with no data must return 200"
    );

    let body = body_json(resp).await;
    let games_played = body["merged"]["stats"]["games_played"]
        .as_u64()
        .expect("games_played must be present");
    assert_eq!(
        games_played, 0,
        "default payload must have games_played = 0"
    );
}

/// Accessing `/api/sync/pull` without a token must return 401.
#[tokio::test]
async fn pull_without_token_returns_401() {
    let app = build_test_router(test_pool().await);

    let req = Request::builder()
        .method("GET")
        .uri("/api/sync/pull")
        .body(Body::empty())
        .expect("failed to build unauthenticated GET request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "missing token must return 401"
    );
}

// ---------------------------------------------------------------------------
// Account deletion tests
// ---------------------------------------------------------------------------

/// After `DELETE /api/account`, the user row (and sync data via CASCADE) is gone.
/// A subsequent pull attempt should fail — either 401 (JWT rejected before DB
/// lookup) or the row is simply absent.  Either way, the deletion itself must
/// return 200.
#[tokio::test]
async fn delete_account_succeeds_and_data_is_gone() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
    let user_id = decode_sub(&access);

    // First push some data.
    let payload = make_payload(&user_id, 5);
    let push_resp = post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(&payload).expect("SyncPayload must serialise"),
    )
    .await;
    assert_eq!(
        push_resp.status(),
        StatusCode::OK,
        "setup push must succeed"
    );

    // Delete the account.
    let del_resp = delete_authed(app.clone(), "/api/account", &access).await;
    assert_eq!(
        del_resp.status(),
        StatusCode::OK,
        "DELETE /api/account must return 200"
    );
    let del_body = body_json(del_resp).await;
    assert_eq!(
        del_body["ok"], true,
        "delete response must contain ok: true"
    );

    // Subsequent pull with the same token: the JWT is still cryptographically
    // valid (the server has no token revocation list), but the user row no
    // longer exists in the database.  The pull handler will return a default
    // empty payload rather than a 404.  The important assertion is that delete
    // returned 200 above; we just confirm the server doesn't panic.
    let pull_resp = get_authed(app, "/api/sync/pull", &access).await;
    // 200 (default payload) or 404/500 depending on implementation; we only
    // assert that the server responds at all (no panic / connection drop).
    let status = pull_resp.status();
    assert!(
        status.is_success() || status.is_client_error() || status.is_server_error(),
        "server must respond after account deletion"
    );
}

// ---------------------------------------------------------------------------
// Health endpoint tests
// ---------------------------------------------------------------------------

/// `GET /health` must return 200 with `status: "ok"` — no auth required.
#[tokio::test]
async fn health_returns_ok() {
    // No JWT needed; set it anyway for consistency.

    let app = build_test_router(test_pool().await);

    let req = Request::builder()
        .method("GET")
        .uri("/health")
        .body(Body::empty())
        .expect("failed to build health request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(resp.status(), StatusCode::OK, "health must return 200");

    let body = body_json(resp).await;
    assert_eq!(body["status"], "ok", "health body must contain status: ok");
}

// ---------------------------------------------------------------------------
// Daily challenge tests
// ---------------------------------------------------------------------------

/// `GET /api/daily-challenge` must return 200 with today's UTC date.
#[tokio::test]
async fn daily_challenge_returns_goal_for_today() {
    let app = build_test_router(test_pool().await);

    let today = Utc::now().format("%Y-%m-%d").to_string();

    let req = Request::builder()
        .method("GET")
        .uri("/api/daily-challenge")
        .body(Body::empty())
        .expect("failed to build daily-challenge request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "daily challenge must return 200"
    );

    let body = body_json(resp).await;
    assert_eq!(
        body["date"], today,
        "challenge date must match today's UTC date"
    );
    assert!(
        body["seed"].is_number(),
        "challenge must include a numeric seed"
    );
    assert!(
        body["description"].is_string(),
        "challenge must include a description"
    );
}

/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
#[tokio::test]
async fn daily_challenge_is_deterministic() {
    // Use the same pool so the second call hits the stored row.
    let pool = test_pool().await;

    let make_req = || {
        Request::builder()
            .method("GET")
            .uri("/api/daily-challenge")
            .body(Body::empty())
            .expect("failed to build daily-challenge request")
    };

    let resp1 = build_test_router(pool.clone())
        .oneshot(make_req())
        .await
        .expect("first oneshot failed");
    assert_eq!(resp1.status(), StatusCode::OK);
    let body1 = body_json(resp1).await;

    let resp2 = build_test_router(pool)
        .oneshot(make_req())
        .await
        .expect("second oneshot failed");
    assert_eq!(resp2.status(), StatusCode::OK);
    let body2 = body_json(resp2).await;

    assert_eq!(
        body1["seed"], body2["seed"],
        "two calls must return the same seed"
    );
    assert_eq!(
        body1["date"], body2["date"],
        "two calls must return the same date"
    );
}

// ---------------------------------------------------------------------------
// Leaderboard tests
// ---------------------------------------------------------------------------

/// `GET /api/leaderboard` requires authentication — no token returns 401.
#[tokio::test]
async fn leaderboard_without_token_returns_401() {
    let app = build_test_router(test_pool().await);

    let req = Request::builder()
        .method("GET")
        .uri("/api/leaderboard")
        .body(Body::empty())
        .expect("failed to build leaderboard request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "leaderboard without auth must return 401"
    );
}

/// Opting in and then fetching the leaderboard returns the opted-in entry.
#[tokio::test]
async fn opt_in_then_leaderboard_shows_entry() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;

    // Opt in with a display name.
    let opt_resp = post_authed(
        app.clone(),
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": "KarenTheGreat" }),
    )
    .await;
    assert_eq!(opt_resp.status(), StatusCode::OK, "opt-in must return 200");

    // Fetch the leaderboard.
    let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
    assert_eq!(
        lb_resp.status(),
        StatusCode::OK,
        "leaderboard must return 200"
    );

    let body = body_json(lb_resp).await;
    let entries = body.as_array().expect("leaderboard must be a JSON array");
    let found = entries.iter().any(|e| e["display_name"] == "KarenTheGreat");
    assert!(found, "opted-in user must appear in leaderboard");
}

/// Pushing sync data after opting in updates the leaderboard best_score.
#[tokio::test]
async fn push_after_opt_in_updates_leaderboard_score() {
    let pool = test_pool().await;
    let app = build_test_router(pool);

    let (access, _) = register_user(app.clone(), "scorer", "scorepass").await;
    let user_id = decode_sub(&access);

    // Opt in.
    post_authed(
        app.clone(),
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": "Scorer" }),
    )
    .await;

    // Build a payload with a known best_single_score.
    let payload = SyncPayload {
        user_id: uuid::Uuid::parse_str(&user_id).unwrap(),
        stats: StatsSnapshot {
            best_single_score: 3_500,
            fastest_win_seconds: 180,
            games_won: 1,
            games_played: 1,
            ..StatsSnapshot::default()
        },
        achievements: vec![],
        progress: PlayerProgress::default(),
        last_modified: Utc::now(),
    };

    let push_resp = post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(&payload).unwrap(),
    )
    .await;
    assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");

    // Leaderboard should reflect the pushed score.
    let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
    let body = body_json(lb_resp).await;
    let entries = body.as_array().unwrap();
    let entry = entries
        .iter()
        .find(|e| e["display_name"] == "Scorer")
        .expect("entry missing");
    assert_eq!(
        entry["best_score"], 3_500,
        "best_score must be updated from push"
    );
    assert_eq!(
        entry["best_time_secs"], 180,
        "best_time_secs must be updated from push"
    );
}

/// Pushing a lower score after a higher one does not overwrite the best.
#[tokio::test]
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
    let pool = test_pool().await;
    let app = build_test_router(pool);

    let (access, _) = register_user(app.clone(), "champ", "champpass").await;
    let user_id = decode_sub(&access);

    post_authed(
        app.clone(),
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": "Champ" }),
    )
    .await;

    let make = |score: u32, time: u64| SyncPayload {
        user_id: uuid::Uuid::parse_str(&user_id).unwrap(),
        stats: StatsSnapshot {
            best_single_score: score,
            fastest_win_seconds: time,
            games_won: 1,
            games_played: 1,
            ..StatsSnapshot::default()
        },
        achievements: vec![],
        progress: PlayerProgress::default(),
        last_modified: Utc::now(),
    };

    // First push: high score.
    post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(make(5_000, 120)).unwrap(),
    )
    .await;

    // Second push: lower score and slower time.
    post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(make(1_000, 600)).unwrap(),
    )
    .await;

    let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
    let body = body_json(lb_resp).await;
    let entries = body.as_array().unwrap();
    let entry = entries
        .iter()
        .find(|e| e["display_name"] == "Champ")
        .unwrap();
    assert_eq!(entry["best_score"], 5_000, "best_score must not regress");
    assert_eq!(
        entry["best_time_secs"], 120,
        "best_time_secs must stay at fastest"
    );
}

/// Opting out hides the player from the leaderboard; opting back in restores them.
#[tokio::test]
async fn opt_out_hides_then_opt_in_restores() {
    let pool = test_pool().await;
    let app = build_test_router(pool);

    let (access, _) = register_user(app.clone(), "visible", "pass1234").await;

    // Opt in.
    let resp = post_authed(
        app.clone(),
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": "Visible" }),
    )
    .await;
    assert_eq!(resp.status(), StatusCode::OK);

    // Verify they appear.
    let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
    let entries = body_json(lb).await;
    assert!(
        entries
            .as_array()
            .unwrap()
            .iter()
            .any(|e| e["display_name"] == "Visible"),
        "opted-in user must appear"
    );

    // Opt out.
    let resp = delete_authed(app.clone(), "/api/leaderboard/opt-in", &access).await;
    assert_eq!(resp.status(), StatusCode::OK);

    // Verify they are hidden.
    let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
    let entries = body_json(lb).await;
    assert!(
        !entries
            .as_array()
            .unwrap()
            .iter()
            .any(|e| e["display_name"] == "Visible"),
        "opted-out user must be hidden"
    );

    // Opt back in — should restore without losing display name.
    post_authed(
        app.clone(),
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": "Visible" }),
    )
    .await;
    let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
    let entries = body_json(lb).await;
    assert!(
        entries
            .as_array()
            .unwrap()
            .iter()
            .any(|e| e["display_name"] == "Visible"),
        "re-opted-in user must appear again"
    );
}

/// Opting in with an empty display name returns 400.
#[tokio::test]
async fn opt_in_empty_display_name_returns_400() {
    let app = build_test_router(test_pool().await);
    let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;

    let resp = post_authed(
        app,
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": "   " }),
    )
    .await;
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "whitespace-only display_name must return 400"
    );
}

/// Opting in with a display name longer than 32 characters returns 400.
#[tokio::test]
async fn opt_in_too_long_display_name_returns_400() {
    let app = build_test_router(test_pool().await);
    let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;

    let long_name = "a".repeat(33);
    let resp = post_authed(
        app,
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": long_name }),
    )
    .await;
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "33-char display_name must return 400"
    );
}

/// Exactly 32 ASCII characters is accepted.
#[tokio::test]
async fn opt_in_exactly_32_char_display_name_succeeds() {
    let app = build_test_router(test_pool().await);
    let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;

    let name = "a".repeat(32);
    let resp = post_authed(
        app,
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": name }),
    )
    .await;
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "32-char display_name must be accepted"
    );
}

/// A display name consisting of 32 Unicode emoji (multi-byte chars) must be
/// accepted — the limit is character count, not byte count.
#[tokio::test]
async fn opt_in_32_unicode_chars_display_name_succeeds() {
    let app = build_test_router(test_pool().await);
    let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;

    // 32 emoji — each is 4 bytes, so 128 bytes total.
    // A byte-length check would incorrectly reject this.
    let name = "🎉".repeat(32);
    let resp = post_authed(
        app,
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": name }),
    )
    .await;
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "32-emoji display_name (32 chars, 128 bytes) must be accepted"
    );
}

/// A display name with 33 Unicode emoji is rejected.
#[tokio::test]
async fn opt_in_33_unicode_chars_display_name_returns_400() {
    let app = build_test_router(test_pool().await);
    let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;

    let name = "🎉".repeat(33);
    let resp = post_authed(
        app,
        "/api/leaderboard/opt-in",
        &access,
        serde_json::json!({ "display_name": name }),
    )
    .await;
    assert_eq!(
        resp.status(),
        StatusCode::BAD_REQUEST,
        "33-emoji display_name must return 400"
    );
}

/// A second push with lower stats must not overwrite the higher stored values —
/// the server merges (max wins) rather than blindly replacing.
#[tokio::test]
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
    let user_id = decode_sub(&access);

    // First push: 20 games_played.
    let high_payload = make_payload(&user_id, 20);
    let r1 = post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(&high_payload).unwrap(),
    )
    .await;
    assert_eq!(r1.status(), StatusCode::OK);

    // Second push: 5 games_played (lower — should be ignored by merge).
    let low_payload = make_payload(&user_id, 5);
    let r2 = post_authed(
        app.clone(),
        "/api/sync/push",
        &access,
        serde_json::to_value(&low_payload).unwrap(),
    )
    .await;
    assert_eq!(r2.status(), StatusCode::OK);

    // Pull and verify the higher value survived.
    let pull_resp = get_authed(app, "/api/sync/pull", &access).await;
    let body = body_json(pull_resp).await;
    let games_played = body["merged"]["stats"]["games_played"]
        .as_u64()
        .expect("games_played must be present");
    assert_eq!(
        games_played, 20,
        "server merge must keep the higher games_played value"
    );
}

/// Login with leading/trailing whitespace in the username still succeeds.
#[tokio::test]
async fn login_trims_whitespace_from_username() {
    let app = build_test_router(test_pool().await);

    let _ = register_user(app.clone(), "trimtest", "password1!").await;

    // Login with surrounding whitespace — should still authenticate.
    let resp = post_json(
        app,
        "/api/auth/login",
        serde_json::json!({ "username": "  trimtest  ", "password": "password1!" }),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "login with whitespace-padded username must succeed"
    );
}

// ---------------------------------------------------------------------------
// Security tests
// ---------------------------------------------------------------------------

/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
#[tokio::test]
async fn push_oversized_body_returns_413() {
    let app = build_test_router(test_pool().await);

    let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;

    // 1_100_000-byte string embedded in JSON comfortably exceeds the 1 MB limit.
    let big_string = "x".repeat(1_100_000);
    let body_bytes = serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();

    let req = Request::builder()
        .method("POST")
        .uri("/api/sync/push")
        .header("content-type", "application/json")
        .header("Authorization", format!("Bearer {access}"))
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::from(body_bytes))
        .expect("failed to build oversized request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::PAYLOAD_TOO_LARGE,
        "oversized body must be rejected with 413"
    );
}

/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
#[tokio::test]
async fn expired_access_token_returns_401() {
    let app = build_test_router(test_pool().await);

    // Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
    #[derive(serde::Serialize)]
    struct ExpiredClaims {
        sub: String,
        exp: usize,
        kind: String,
    }
    let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
    let expired_token = encode(
        &Header::default(),
        &ExpiredClaims {
            sub: "00000000-0000-0000-0000-000000000000".into(),
            exp,
            kind: "access".into(),
        },
        &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
    )
    .unwrap();

    let resp = get_authed(app, "/api/sync/pull", &expired_token).await;
    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "expired JWT must be rejected with 401"
    );
}

/// A refresh token must be rejected when used as a Bearer token on protected routes.
#[tokio::test]
async fn refresh_token_rejected_on_protected_routes() {
    let app = build_test_router(test_pool().await);

    let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;

    // Using the refresh token (kind = "refresh") as a Bearer on a protected route
    // must return 401 because the middleware requires kind = "access".
    let resp = get_authed(app, "/api/sync/pull", &refresh).await;
    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "refresh token must be rejected on protected endpoints"
    );
}

// ---------------------------------------------------------------------------
// Additional auth refresh edge-case tests
// ---------------------------------------------------------------------------

/// `POST /api/auth/refresh` with a completely invalid (non-JWT) string must
/// return 401 — the token cannot be decoded at all.
#[tokio::test]
async fn refresh_with_garbage_token_returns_401() {
    let app = build_test_router(test_pool().await);

    let resp = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": "this.is.not.a.jwt" }),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "garbage refresh token must return 401"
    );
}

/// `POST /api/auth/refresh` with an expired (but correctly signed) refresh
/// token must return 401 — `exp` is in the past.
#[tokio::test]
async fn refresh_with_expired_refresh_token_returns_401() {
    let app = build_test_router(test_pool().await);

    // Craft a refresh token that expired 2 hours ago, signed with the same
    // secret that `build_test_router` uses, so the signature is valid but the
    // expiry check must still reject it.
    #[derive(serde::Serialize)]
    struct ExpiredRefreshClaims {
        sub: String,
        exp: usize,
        kind: String,
    }
    let exp = (Utc::now() - chrono::Duration::hours(2)).timestamp() as usize;
    let expired_token = encode(
        &Header::default(),
        &ExpiredRefreshClaims {
            sub: "00000000-0000-0000-0000-000000000000".into(),
            exp,
            kind: "refresh".into(),
        },
        &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
    )
    .unwrap();

    let resp = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": expired_token }),
    )
    .await;

    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "expired refresh token must return 401"
    );
}

// ---------------------------------------------------------------------------
// Additional no-auth / missing-token tests
// ---------------------------------------------------------------------------

/// Accessing `POST /api/sync/push` with no Authorization header must return 401.
#[tokio::test]
async fn push_without_token_returns_401() {
    let app = build_test_router(test_pool().await);

    let req = Request::builder()
        .method("POST")
        .uri("/api/sync/push")
        .header("content-type", "application/json")
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::from(b"{}".as_ref()))
        .expect("failed to build unauthenticated POST request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "missing token on push must return 401"
    );
}

/// Accessing `DELETE /api/account` with no Authorization header must return 401.
#[tokio::test]
async fn delete_account_without_token_returns_401() {
    let app = build_test_router(test_pool().await);

    let req = Request::builder()
        .method("DELETE")
        .uri("/api/account")
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::empty())
        .expect("failed to build unauthenticated DELETE request");

    let resp = app.oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::UNAUTHORIZED,
        "missing token on DELETE /api/account must return 401"
    );
}

// ---------------------------------------------------------------------------
// Leaderboard — authenticated empty-array test
// ---------------------------------------------------------------------------

/// `GET /api/leaderboard` with a valid JWT but no opted-in players returns 200
/// with an empty JSON array.
#[tokio::test]
async fn leaderboard_with_valid_token_returns_empty_array_when_no_opts() {
    let app = build_test_router(test_pool().await);

    // Register a user to get a valid token — do NOT opt in to the leaderboard.
    let (access, _) = register_user(app.clone(), "no_opt_user", "password1!").await;

    let resp = get_authed(app, "/api/leaderboard", &access).await;
    assert_eq!(resp.status(), StatusCode::OK, "leaderboard must return 200");

    let body = body_json(resp).await;
    assert!(
        body.is_array(),
        "leaderboard body must be a JSON array even when empty"
    );
    assert_eq!(
        body.as_array().unwrap().len(),
        0,
        "leaderboard must be empty when no players have opted in"
    );
}

// ---------------------------------------------------------------------------
// Admin password reset tests
// ---------------------------------------------------------------------------

/// `reset_password` updates `password_hash` so the user can log in with the
/// new password and is locked out with the old one.
#[tokio::test]
async fn reset_password_allows_login_with_new_password() {
    let pool = test_pool().await;
    let app = build_test_router(pool.clone());

    // Register an account.
    let resp = post_json(
        app.clone(),
        "/api/auth/register",
        serde_json::json!({ "username": "reset_user_a", "password": "oldpass1!" }),
    )
    .await;
    assert_eq!(resp.status(), StatusCode::OK);

    // Reset password via the admin helper.
    solitaire_server::reset_password(&pool, "reset_user_a", "newpass99!")
        .await
        .expect("reset_password must succeed for an existing user");

    // Login with new password must succeed.
    let new_resp = post_json(
        app.clone(),
        "/api/auth/login",
        serde_json::json!({ "username": "reset_user_a", "password": "newpass99!" }),
    )
    .await;
    assert_eq!(
        new_resp.status(),
        StatusCode::OK,
        "login with new password must succeed"
    );

    // Login with old password must fail.
    let old_resp = post_json(
        app,
        "/api/auth/login",
        serde_json::json!({ "username": "reset_user_a", "password": "oldpass1!" }),
    )
    .await;
    assert_eq!(
        old_resp.status(),
        StatusCode::UNAUTHORIZED,
        "login with old password must be rejected after reset"
    );
}

/// `reset_password` deletes all refresh tokens for the user so existing
/// sessions cannot refresh without re-logging in.
#[tokio::test]
async fn reset_password_invalidates_existing_sessions() {
    let pool = test_pool().await;
    let app = build_test_router(pool.clone());

    let (_, refresh_token) = register_user(app.clone(), "reset_user_b", "password1!").await;

    // Confirm the refresh token works before the reset.
    let pre_reset = post_json(
        app.clone(),
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": &refresh_token }),
    )
    .await;
    assert_eq!(
        pre_reset.status(),
        StatusCode::OK,
        "refresh must work before password reset"
    );

    // Reset the password.
    solitaire_server::reset_password(&pool, "reset_user_b", "brandnewpass!")
        .await
        .expect("reset_password must succeed");

    // The original refresh token must now be rejected with 401.
    let post_reset = post_json(
        app,
        "/api/auth/refresh",
        serde_json::json!({ "refresh_token": &refresh_token }),
    )
    .await;
    assert_eq!(
        post_reset.status(),
        StatusCode::UNAUTHORIZED,
        "refresh token from before the reset must be invalidated"
    );
}

/// `reset_password` returns `AppError::NotFound` for a username that does
/// not exist, rather than silently succeeding.
#[tokio::test]
async fn reset_password_returns_not_found_for_unknown_user() {
    let pool = test_pool().await;
    let err = solitaire_server::reset_password(&pool, "no_such_user", "somepassword!")
        .await
        .expect_err("reset_password must fail for an unknown username");
    assert!(
        matches!(err, solitaire_server::error::AppError::NotFound(_)),
        "expected AppError::NotFound, got {err:?}"
    );
}

/// `reset_password` returns `AppError::BadRequest` when the new password is
/// shorter than the minimum length.
#[tokio::test]
async fn reset_password_rejects_short_password() {
    let pool = test_pool().await;
    let app = build_test_router(pool.clone());
    register_user(app, "reset_user_c", "password1!").await;

    let err = solitaire_server::reset_password(&pool, "reset_user_c", "short")
        .await
        .expect_err("reset_password must reject passwords below minimum length");
    assert!(
        matches!(err, solitaire_server::error::AppError::BadRequest(_)),
        "expected AppError::BadRequest, got {err:?}"
    );
}

// ---------------------------------------------------------------------------
// Rate-limiting test (uses the production router with rate limiting enabled)
// ---------------------------------------------------------------------------

/// The 11th request to an auth endpoint within the rate-limit window must
/// return 429 Too Many Requests.
///
/// Uses [`solitaire_server::build_router`] (rate limiting ON) rather than
/// [`build_test_router`] so the GovernorLayer is actually applied.
/// All 11 requests share the same router clone — cloning an Axum Router with
/// GovernorLayer clones the inner `Arc`, so the request counter is shared.
#[tokio::test]
async fn auth_rate_limit_returns_429_on_11th_request() {
    let state = solitaire_server::AppState {
        pool: test_pool().await,
        jwt_secret: TEST_SECRET.to_string(),
    };
    let app = solitaire_server::build_router(state);

    let body_bytes = serde_json::to_vec(&serde_json::json!({
        "username": "ratelimituser",
        "password": "password1!"
    }))
    .unwrap();

    // First 10 requests consume the burst allowance (burst_size = 10).
    // The status may be 200 (first registration) or 400/409 (duplicate username)
    // on retries — what matters is that none of them are 429.
    for i in 0..10 {
        let req = Request::builder()
            .method("POST")
            .uri("/api/auth/register")
            .header("content-type", "application/json")
            .header("x-forwarded-for", TEST_CLIENT_IP)
            .body(Body::from(body_bytes.clone()))
            .expect("failed to build request");
        let resp = app.clone().oneshot(req).await.expect("oneshot failed");
        assert_ne!(
            resp.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "request {} of 10 must not be rate-limited",
            i + 1
        );
    }

    // The 11th request must be rejected by the rate limiter.
    let req = Request::builder()
        .method("POST")
        .uri("/api/auth/register")
        .header("content-type", "application/json")
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::from(body_bytes))
        .expect("failed to build 11th request");
    let resp = app.clone().oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::TOO_MANY_REQUESTS,
        "11th request must be rate-limited with 429"
    );
}

/// The 11th `POST /api/sync/push` from the same authenticated user within the
/// rate-limit window must return 429 Too Many Requests.
///
/// Uses [`solitaire_server::build_router`] (rate limiting ON) so the
/// GovernorLayer is applied. We register a fresh account, then send 10 pushes
/// (consuming the burst allowance), and assert the 11th is throttled.
///
/// Note: the push body deliberately omits valid `SyncPayload` structure —
/// that would return 422, but the rate limiter fires before deserialization,
/// so the response code for the first 10 is 422 and for the 11th is 429.
/// The test only asserts `!= 429` for requests 1–10 and `== 429` for request 11.
#[tokio::test]
async fn sync_push_rate_limit_returns_429_on_11th_request() {
    let state = solitaire_server::AppState {
        pool: test_pool().await,
        jwt_secret: TEST_SECRET.to_string(),
    };
    let app = solitaire_server::build_router(state);

    // Register a user to obtain a valid JWT for the UserIdKeyExtractor.
    let (token, _) = register_user(app.clone(), "sync_ratelimit_user", "p4ssword!").await;

    let stub_body = serde_json::to_vec(&serde_json::json!({})).unwrap();

    // First 10 requests consume the burst allowance (burst_size = 10).
    // The body is intentionally invalid — the rate limiter fires before
    // deserialization, so we get 422 rather than 200. We only assert != 429.
    for i in 0..10 {
        let req = Request::builder()
            .method("POST")
            .uri("/api/sync/push")
            .header("content-type", "application/json")
            .header("Authorization", format!("Bearer {token}"))
            .header("x-forwarded-for", TEST_CLIENT_IP)
            .body(Body::from(stub_body.clone()))
            .expect("failed to build request");
        let resp = app.clone().oneshot(req).await.expect("oneshot failed");
        assert_ne!(
            resp.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "request {} of 10 must not be rate-limited",
            i + 1
        );
    }

    // The 11th request must be throttled.
    let req = Request::builder()
        .method("POST")
        .uri("/api/sync/push")
        .header("content-type", "application/json")
        .header("Authorization", format!("Bearer {token}"))
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::from(stub_body))
        .expect("failed to build 11th request");
    let resp = app.clone().oneshot(req).await.expect("oneshot failed");
    assert_eq!(
        resp.status(),
        StatusCode::TOO_MANY_REQUESTS,
        "11th sync push must be rate-limited with 429"
    );
}

// ---------------------------------------------------------------------------
// Replay endpoints
//
// End-to-end coverage for the upload → fetch → render path that powers
// the web replay viewer. Each test boots the full router against an
// in-memory SQLite, registers a user, and exercises one of the three
// replay endpoints. The schema-correctness tests (storage round-trip,
// version gate, atomic write) live in `solitaire_data::replay`; here we
// only verify the HTTP transport + database layer.
// ---------------------------------------------------------------------------

/// Build a minimal v2 replay JSON the upload endpoint will accept.
///
/// Uses the same field shape `solitaire_data::Replay` produces — kept
/// in sync by hand because the server crate intentionally does not
/// depend on `solitaire_data` (which carries dirs/keyring/reqwest).
fn sample_replay_payload(seed: u64, score: i32) -> Value {
    serde_json::json!({
        "schema_version": 2,
        "seed": seed,
        "draw_mode": "DrawOne",
        "mode": "Classic",
        "time_seconds": 134,
        "final_score": score,
        "recorded_at": "2026-05-02",
        "moves": [
            "StockClick",
            { "Move": { "from": "Waste", "to": { "Tableau": 3 }, "count": 1 } }
        ]
    })
}

/// Round-trip: register → upload → fetch → assert the payload returned
/// by `GET /api/replays/:id` matches what was uploaded byte-for-byte.
/// This is the canonical "the web viewer can play back what the
/// desktop client uploaded" test.
#[tokio::test]
async fn replay_upload_then_fetch_round_trips_payload() {
    let pool = test_pool().await;
    let app = build_test_router(pool);
    let (token, _) = register_user(app.clone(), "replay_round_trip_user", "p4ssword!").await;

    let payload = sample_replay_payload(7654, 4321);
    let resp = post_authed(app.clone(), "/api/replays", &token, payload.clone()).await;
    assert_eq!(resp.status(), StatusCode::OK, "upload must return 200");
    let id = body_json(resp).await["id"]
        .as_str()
        .expect("upload response missing `id`")
        .to_string();
    assert!(uuid::Uuid::parse_str(&id).is_ok(), "id must be a UUID");

    // Fetch is public — no auth required, exercising the path the
    // logged-out web viewer takes.
    let req = Request::builder()
        .method("GET")
        .uri(format!("/api/replays/{id}"))
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::empty())
        .expect("fetch request");
    let resp = app.clone().oneshot(req).await.expect("oneshot");
    assert_eq!(resp.status(), StatusCode::OK, "fetch must return 200");
    let fetched = body_json(resp).await;
    assert_eq!(
        fetched, payload,
        "fetched payload must match what was uploaded byte-for-byte",
    );
}

/// `GET /api/replays/:id` for an id that was never uploaded must
/// return 404 (not 500). Exercises the `AppError::NotFound` mapping
/// added in the server commit.
#[tokio::test]
async fn replay_fetch_unknown_id_returns_404() {
    let pool = test_pool().await;
    let app = build_test_router(pool);
    let req = Request::builder()
        .method("GET")
        .uri("/api/replays/nonexistent-id-1234")
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::empty())
        .expect("fetch request");
    let resp = app.oneshot(req).await.expect("oneshot");
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

/// Two uploads, then `GET /api/replays/recent` — the more recent
/// upload must come first and the response must include the
/// uploader's username (joined from the `users` table).
#[tokio::test]
async fn replay_recent_lists_newest_first_with_username() {
    let pool = test_pool().await;
    let app = build_test_router(pool);
    let (token, _) = register_user(app.clone(), "replay_recent_user", "p4ssword!").await;

    let _ = post_authed(
        app.clone(),
        "/api/replays",
        &token,
        sample_replay_payload(1, 100),
    )
    .await;
    let _ = post_authed(
        app.clone(),
        "/api/replays",
        &token,
        sample_replay_payload(2, 200),
    )
    .await;

    let req = Request::builder()
        .method("GET")
        .uri("/api/replays/recent")
        .header("x-forwarded-for", TEST_CLIENT_IP)
        .body(Body::empty())
        .expect("recent request");
    let resp = app.oneshot(req).await.expect("oneshot");
    assert_eq!(resp.status(), StatusCode::OK);

    let entries = body_json(resp).await;
    let array = entries.as_array().expect("recent should return an array");
    assert!(
        array.len() >= 2,
        "two uploads should yield two list entries"
    );
    // Newer upload (seed = 2) must appear before older one (seed = 1).
    let seeds: Vec<i64> = array
        .iter()
        .map(|e| e["seed"].as_i64().expect("seed should be an integer"))
        .collect();
    assert_eq!(seeds, [2, 1], "received_at DESC: most recent upload first",);
    assert_eq!(
        array[0]["username"].as_str(),
        Some("replay_recent_user"),
        "username must be joined into the response",
    );
}

/// `POST /api/replays` without an `Authorization` header must return
/// 401, not silently insert as an anonymous user.
#[tokio::test]
async fn replay_upload_without_auth_returns_401() {
    let pool = test_pool().await;
    let app = build_test_router(pool);
    let resp = post_json(app, "/api/replays", sample_replay_payload(99, 50)).await;
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

/// `POST /api/replays` with a malformed body (missing fields the
/// header projector needs) must return 400, not 500.
#[tokio::test]
async fn replay_upload_malformed_body_returns_400() {
    let pool = test_pool().await;
    let app = build_test_router(pool);
    let (token, _) = register_user(app.clone(), "replay_bad_body_user", "p4ssword!").await;
    let bad = serde_json::json!({ "schema_version": 2, "missing_required_fields": true });
    let resp = post_authed(app, "/api/replays", &token, bad).await;
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
