//! PR 2 (feat/admin-users-api) integration tests.
//!
//! Exercises list / delete / promote-demote on /api/v1/admin/users:
//! pagination + search, the RequireAdmin gate, self-protection,
//! last-admin invariant (including the parallel-demote race that
//! `pg_advisory_xact_lock` + recount-inside-tx guards against), and
//! that audit rows land in `admin_audit` only on successful commit.
//!
//! Note on the last-admin invariant: the *serial* path via HTTP is
//! structurally unreachable — the only configuration that would hit the
//! "would orphan admins" branch requires the actor to be the lone admin
//! demoting themselves, which the self-guard fires on first. So the
//! last-admin checks below call the repo directly to exercise the
//! invariant; the HTTP race scenario is covered by
//! `parallel_demotes_cannot_orphan_admins`.

mod common;

use axum::http::StatusCode;
use axum::Router;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;

use mangalord::error::AppError;
use mangalord::repo;

/// Register a user via the public API and immediately promote them via
/// the repo. Returns (username, session cookie, user_id) — the common
/// "I need a logged-in admin" prelude.
async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String, Uuid) {
    let (username, cookie) = common::register_user(app).await;
    let u = repo::user::find_by_username(pool, &username)
        .await
        .unwrap()
        .unwrap();
    repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap();
    (username, cookie, u.id)
}

// ---- RequireAdmin gate -----------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn list_requires_admin(pool: PgPool) {
    let h = common::harness(pool);
    let (_username, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::get_with_cookie("/api/v1/admin/users", &cookie))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[sqlx::test(migrations = "./migrations")]
async fn delete_requires_admin(pool: PgPool) {
    let h = common::harness(pool);
    let (_username, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/admin/users/{}", Uuid::new_v4()),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[sqlx::test(migrations = "./migrations")]
async fn patch_requires_admin(pool: PgPool) {
    let h = common::harness(pool);
    let (_username, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/admin/users/{}", Uuid::new_v4()),
            json!({ "is_admin": true }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ---- list with search and pagination ---------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn list_returns_paginated_users(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await;
    let _u1 = common::register_user(&h.app).await;
    let _u2 = common::register_user(&h.app).await;
    let _u3 = common::register_user(&h.app).await;

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/users?limit=2&offset=0",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().expect("items array");
    assert_eq!(items.len(), 2, "limit=2 should cap the page");
    assert_eq!(body["page"]["limit"], 2);
    assert_eq!(body["page"]["offset"], 0);
    assert_eq!(body["page"]["total"], 4);
    assert!(items[0].get("is_admin").is_some());
    assert!(
        items[0].get("password_hash").is_none(),
        "password_hash must never leak even to other admins"
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn list_filters_by_substring_search(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json(
            "/api/v1/auth/register",
            json!({ "username": "zzzfindme01", "password": "hunter2hunter2" }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::get_with_cookie(
            "/api/v1/admin/users?search=zzzfindme",
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = common::body_json(resp).await;
    let items = body["items"].as_array().unwrap();
    assert_eq!(items.len(), 1, "search must narrow to the one match");
    assert_eq!(items[0]["username"], "zzzfindme01");
    assert_eq!(body["page"]["total"], 1);
}

// ---- self-protection -------------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn cannot_self_delete(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_username, cookie, actor_id) = seed_admin(&pool, &h.app).await;
    // Second admin so the last-admin guard isn't what triggers the conflict.
    let (_other, _, _) = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/admin/users/{actor_id}"),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "conflict");
    assert!(
        body["error"]["message"]
            .as_str()
            .unwrap()
            .contains("yourself"),
        "message must call out the self-action; got {:?}",
        body["error"]["message"]
    );
}

#[sqlx::test(migrations = "./migrations")]
async fn cannot_self_demote(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_username, cookie, actor_id) = seed_admin(&pool, &h.app).await;
    let (_other, _, _) = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/admin/users/{actor_id}"),
            json!({ "is_admin": false }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
    let body = common::body_json(resp).await;
    assert!(body["error"]["message"]
        .as_str()
        .unwrap()
        .contains("yourself"));
}

// ---- last-admin invariant (repo layer, see file header) --------------------

#[sqlx::test(migrations = "./migrations")]
async fn last_admin_demote_refused_at_repo(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a, _, a_id) = seed_admin(&pool, &h.app).await;
    let (_b, _, b_id) = seed_admin(&pool, &h.app).await;

    // admins = {A, B}. Demote A via B (count 2 → 1) — allowed.
    let r = repo::user::admin_safe_set_is_admin(&pool, b_id, a_id, false)
        .await
        .expect("first demote succeeds");
    assert!(!r.is_admin);

    // admins = {B}. Try to demote B via A (actor doesn't matter to the
    // repo — that's the HTTP gate's job). Last-admin guard kicks in.
    let err = repo::user::admin_safe_set_is_admin(&pool, a_id, b_id, false)
        .await
        .expect_err("second demote must be refused");
    match err {
        AppError::Conflict(m) => assert!(
            m.contains("last admin"),
            "expected last-admin conflict; got {m:?}"
        ),
        other => panic!("expected Conflict, got {other:?}"),
    }
}

#[sqlx::test(migrations = "./migrations")]
async fn last_admin_delete_refused_at_repo(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a, _, a_id) = seed_admin(&pool, &h.app).await;
    let (_b, _, b_id) = seed_admin(&pool, &h.app).await;

    // admins = {A, B}. Delete A via B (count 2 → 1) — allowed.
    repo::user::admin_safe_delete(&pool, b_id, a_id)
        .await
        .expect("first delete succeeds");

    // admins = {B}. Try to delete B via a fresh non-admin actor. Last-
    // admin guard kicks in.
    let (_c, _, c_id) = {
        let (cn, _ck) = common::register_user(&h.app).await;
        let c = repo::user::find_by_username(&pool, &cn).await.unwrap().unwrap();
        (cn, _ck, c.id)
    };
    let err = repo::user::admin_safe_delete(&pool, c_id, b_id)
        .await
        .expect_err("second delete must be refused");
    match err {
        AppError::Conflict(m) => assert!(
            m.contains("last admin"),
            "expected last-admin conflict; got {m:?}"
        ),
        other => panic!("expected Conflict, got {other:?}"),
    }
}

#[sqlx::test(migrations = "./migrations")]
async fn parallel_demotes_cannot_orphan_admins(pool: PgPool) {
    // The race the advisory lock + recount exists to close: two parallel
    // demotes of two DIFFERENT admins, each reading `count = 2` and
    // committing, would land at zero admins. With the lock the second
    // demote sees count = 1 inside the tx and refuses.
    let h = common::harness(pool.clone());
    let (_a, _, a_id) = seed_admin(&pool, &h.app).await;
    let (_b, _, b_id) = seed_admin(&pool, &h.app).await;

    let pool_x = pool.clone();
    let pool_y = pool.clone();
    let task_x = tokio::spawn(async move {
        repo::user::admin_safe_set_is_admin(&pool_x, a_id, b_id, false).await
    });
    let task_y = tokio::spawn(async move {
        repo::user::admin_safe_set_is_admin(&pool_y, b_id, a_id, false).await
    });
    let r_x = task_x.await.unwrap();
    let r_y = task_y.await.unwrap();

    let outcomes = (r_x.is_ok(), r_y.is_ok());
    assert!(
        outcomes == (true, false) || outcomes == (false, true),
        "exactly one of the two parallel demotes must succeed; got {outcomes:?}"
    );

    let (count,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM users WHERE is_admin = true")
            .fetch_one(&pool)
            .await
            .unwrap();
    assert_eq!(count, 1, "at least one admin must remain");
}

// ---- audit log -------------------------------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn promote_writes_audit_row(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await;
    let (b_name, _b_cookie) = common::register_user(&h.app).await;
    let b = repo::user::find_by_username(&pool, &b_name)
        .await
        .unwrap()
        .unwrap();

    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/admin/users/{}", b.id),
            json!({ "is_admin": true }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let rows: Vec<(Option<Uuid>, String, String, Option<Uuid>)> = sqlx::query_as(
        "SELECT actor_user_id, action, target_kind, target_id FROM admin_audit",
    )
    .fetch_all(&pool)
    .await
    .unwrap();
    assert_eq!(rows.len(), 1);
    let (actor, action, kind, target) = rows.into_iter().next().unwrap();
    assert_eq!(actor, Some(a_id));
    assert_eq!(action, "promote_user");
    assert_eq!(kind, "user");
    assert_eq!(target, Some(b.id));
}

#[sqlx::test(migrations = "./migrations")]
async fn redundant_promote_does_not_write_audit_row(pool: PgPool) {
    // Regression: PATCH {is_admin: true} on someone already admin used
    // to UPDATE (no-op) and still INSERT a misleading "promote_user"
    // audit row. Should short-circuit without touching admin_audit.
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, _a_id) = seed_admin(&pool, &h.app).await;
    let (b_name, _b_cookie, _b_id) = seed_admin(&pool, &h.app).await; // already admin

    let b = repo::user::find_by_username(&pool, &b_name)
        .await
        .unwrap()
        .unwrap();
    let resp = h
        .app
        .oneshot(common::patch_json_with_cookie(
            &format!("/api/v1/admin/users/{}", b.id),
            json!({ "is_admin": true }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM admin_audit")
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(count, 0, "no-op promote must not write audit row");
}

#[sqlx::test(migrations = "./migrations")]
async fn delete_writes_audit_row(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await;
    let (b_name, _b_cookie) = common::register_user(&h.app).await;
    let b = repo::user::find_by_username(&pool, &b_name)
        .await
        .unwrap()
        .unwrap();

    let resp = h
        .app
        .oneshot(common::delete_with_cookie(
            &format!("/api/v1/admin/users/{}", b.id),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);

    // (actor_user_id, action, target_kind, target_id, payload)
    type AuditRow = (Option<Uuid>, String, String, Option<Uuid>, serde_json::Value);
    let rows: Vec<AuditRow> = sqlx::query_as(
        "SELECT actor_user_id, action, target_kind, target_id, payload FROM admin_audit",
    )
        .fetch_all(&pool)
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
    let (actor, action, kind, target, payload) = rows.into_iter().next().unwrap();
    assert_eq!(actor, Some(a_id));
    assert_eq!(action, "delete_user");
    assert_eq!(kind, "user");
    assert_eq!(target, Some(b.id));
    assert_eq!(payload["username"], b_name);
    assert_eq!(payload["was_admin"], false);
}

// ---- POST /admin/users (admin-create) --------------------------------------

#[sqlx::test(migrations = "./migrations")]
async fn create_user_requires_admin(pool: PgPool) {
    let h = common::harness(pool);
    let (_username, cookie) = common::register_user(&h.app).await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({ "username": "newbie", "password": "hunter2hunter2" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_unauthenticated_is_rejected(pool: PgPool) {
    let h = common::harness(pool);
    let resp = h
        .app
        .oneshot(common::post_json(
            "/api/v1/admin/users",
            json!({ "username": "newbie", "password": "hunter2hunter2" }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_happy_path_creates_user_and_audit(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await;

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({ "username": "invited01", "password": "freshpass1234" }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let body = common::body_json(resp).await;
    assert_eq!(body["username"], "invited01");
    assert_eq!(body["is_admin"], false);
    assert!(body["id"].as_str().is_some());
    assert!(
        body.get("password_hash").is_none(),
        "password_hash must never appear in admin-create response"
    );

    let target_id =
        Uuid::parse_str(body["id"].as_str().unwrap()).unwrap();
    let (actor, action, kind, target, payload): (
        Option<Uuid>,
        String,
        String,
        Option<Uuid>,
        serde_json::Value,
    ) = sqlx::query_as(
        "SELECT actor_user_id, action, target_kind, target_id, payload \
           FROM admin_audit ORDER BY at DESC LIMIT 1",
    )
    .fetch_one(&pool)
    .await
    .unwrap();
    assert_eq!(actor, Some(a_id));
    assert_eq!(action, "create_user");
    assert_eq!(kind, "user");
    assert_eq!(target, Some(target_id));
    assert_eq!(payload["username"], "invited01");
    assert_eq!(payload["is_admin"], false);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_can_mint_an_admin_in_one_call(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({
                "username": "newadmin",
                "password": "freshpass1234",
                "is_admin": true
            }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);
    let body = common::body_json(resp).await;
    assert_eq!(body["is_admin"], true);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_returns_409_on_duplicate(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
    // Seed an existing user via the public register path.
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json(
            "/api/v1/auth/register",
            json!({ "username": "taken", "password": "hunter2hunter2" }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({ "username": "Taken", "password": "freshpass1234" }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::CONFLICT,
        "case-insensitive collision via the lower(username) index"
    );
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "conflict");
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_rejects_weak_password(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({ "username": "okayname", "password": "short" }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    let body = common::body_json(resp).await;
    assert_eq!(body["error"]["code"], "invalid_input");
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_rejects_invalid_username(pool: PgPool) {
    let h = common::harness(pool.clone());
    let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({ "username": "bad name!", "password": "freshpass1234" }),
            &a_cookie,
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[sqlx::test(migrations = "./migrations")]
async fn create_user_works_even_when_self_register_disabled(pool: PgPool) {
    // The admin-create path must NOT be gated by ALLOW_SELF_REGISTER —
    // that's the entire point of having an admin-create endpoint.
    let h = common::harness_with_self_register_disabled(pool.clone());
    // Bootstrap an admin out-of-band since self-register would refuse.
    repo::user::bootstrap_admin(&pool, "root", "hunter2hunter2")
        .await
        .unwrap();
    let resp = h
        .app
        .clone()
        .oneshot(common::post_json(
            "/api/v1/auth/login",
            json!({ "username": "root", "password": "hunter2hunter2" }),
        ))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let cookie = common::extract_session_cookie(&resp).unwrap();

    let resp = h
        .app
        .oneshot(common::post_json_with_cookie(
            "/api/v1/admin/users",
            json!({ "username": "invited01", "password": "freshpass1234" }),
            &cookie,
        ))
        .await
        .unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::CREATED,
        "admin must be able to mint users even with self-register off"
    );
}
