//! Phase 3.5 authorization end-to-end tests.
//!
//! Covers the 11 scenarios from `lay-foundations-for-snazzy-truffle.md`
//! step 9:
//!
//!  1. Bootstrap admin promotes to owner.
//!  2. Owner access matrix on a sample app.
//!  3. Admin access matrix.
//!  4. Member access matrix.
//!  5. Bearer (pic_) + cookie produce the same Principal.
//!  6. Scope intersection: a script:read-only key cannot write.
//!  7. Bound key cannot escape its app.
//!  8. Member listing isolation (apps + scripts).
//!  9. Deactivation revokes API keys.
//! 10. Mint rejects bound key with `instance:*` scope.
//! 11. `list_active_owners` returns the expected set under the seed
//!     that the startup warning is built from (we don't capture the
//!     log line itself — the data source is the testable surface).
//!
//! Same harness as `tests/api.rs`: `#[sqlx::test]` against a real
//! Postgres, `TestServer` over the in-process app. We do NOT bake a
//! token into the default headers here — each test wires its own
//! credential per request to exercise the cookie / Bearer split.

#![allow(clippy::needless_pass_by_value)]

use std::sync::Arc;

use axum_test::TestServer;
use picloud_manager_core::{
    auth::hash_password, AdminUserRepository, ApiKeyRepository, AppMembersRepository,
    PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppMembersRepository,
};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use serde_json::{json, Value};
use sqlx::PgPool;

// ----------------------------------------------------------------------------
// Harness
// ----------------------------------------------------------------------------

struct Seeded {
    server: TestServer,
    pool: PgPool,
    /// Bootstrap admin — Owner, password "owner-pw".
    owner: AdminUserId,
    /// Default app id, slug "default" (seeded by 0005 migration).
    default_app: AppId,
}

async fn boot(pool: PgPool) -> Seeded {
    let auth = picloud::AuthDeps::from_pool(pool.clone());
    let hash = hash_password("owner-pw").expect("hash");
    let owner = auth
        .users
        .create("owner", &hash, InstanceRole::Owner, None)
        .await
        .expect("seed owner");

    let app = picloud::build_app(
        pool.clone(),
        auth,
        picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
    )
    .await
    .expect("build_app");
    let server = TestServer::new(app).expect("TestServer");

    // Default app id (seeded by migration 0005).
    let resp = server
        .post("/api/v1/admin/auth/login")
        .json(&json!({ "username": "owner", "password": "owner-pw" }))
        .await;
    resp.assert_status_ok();
    let token = resp.json::<Value>()["token"]
        .as_str()
        .expect("login token")
        .to_string();

    let app_resp = server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {token}"))
        .await;
    app_resp.assert_status_ok();
    let app_id: uuid::Uuid = app_resp.json::<Value>()["id"]
        .as_str()
        .expect("app id")
        .parse()
        .expect("uuid");

    Seeded {
        server,
        pool,
        owner: owner.id,
        default_app: app_id.into(),
    }
}

/// Mint a session for an existing admin via the login endpoint and
/// return the raw token. Lets tests build a per-role credential
/// without baking it into the default headers.
async fn login_token(server: &TestServer, username: &str, password: &str) -> String {
    let r = server
        .post("/api/v1/admin/auth/login")
        .json(&json!({ "username": username, "password": password }))
        .await;
    r.assert_status_ok();
    r.json::<Value>()["token"]
        .as_str()
        .expect("token in login response")
        .to_string()
}

/// Direct-DB seed (bypassing the API) for users we want to construct
/// at arbitrary roles. The API enforces "owners only create owners"
/// which is correct production behavior but inconvenient for test
/// fixtures.
async fn seed_user(
    pool: &PgPool,
    username: &str,
    password: &str,
    role: InstanceRole,
) -> AdminUserId {
    let repo = PostgresAdminUserRepository::new(pool.clone());
    let hash = hash_password(password).expect("hash");
    repo.create(username, &hash, role, None)
        .await
        .expect("seed user")
        .id
}

async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) {
    let repo = PostgresAppMembersRepository::new(pool.clone());
    repo.upsert(app, user, role)
        .await
        .expect("grant membership");
}

async fn create_script_via_api(
    server: &TestServer,
    token: &str,
    app_id: AppId,
    name: &str,
) -> Value {
    let r = server
        .post("/api/v1/admin/scripts")
        .add_header("authorization", format!("Bearer {token}"))
        .json(&json!({
            "app_id": app_id.to_string(),
            "name": name,
            "source": "fn main() { #{ statusCode: 200 } }",
        }))
        .await;
    r.assert_status(axum::http::StatusCode::CREATED);
    r.json()
}

/// Mint an API key for the caller — wraps POST /api-keys.
async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_test::TestResponse {
    server
        .post("/api/v1/admin/api-keys")
        .add_header("authorization", format!("Bearer {cred_token}"))
        .json(&body)
        .await
}

// --- app members helpers ----------------------------------------------------

async fn list_members(
    server: &TestServer,
    token: &str,
    app_ident: &str,
) -> axum_test::TestResponse {
    server
        .get(&format!("/api/v1/admin/apps/{app_ident}/members"))
        .add_header("authorization", format!("Bearer {token}"))
        .await
}

async fn add_member(
    server: &TestServer,
    token: &str,
    app_ident: &str,
    user_id: AdminUserId,
    role: AppRole,
) -> axum_test::TestResponse {
    server
        .post(&format!("/api/v1/admin/apps/{app_ident}/members"))
        .add_header("authorization", format!("Bearer {token}"))
        .json(&json!({ "user_id": user_id, "role": role.as_str() }))
        .await
}

async fn patch_member_role(
    server: &TestServer,
    token: &str,
    app_ident: &str,
    user_id: AdminUserId,
    role: AppRole,
) -> axum_test::TestResponse {
    server
        .patch(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
        .add_header("authorization", format!("Bearer {token}"))
        .json(&json!({ "role": role.as_str() }))
        .await
}

async fn remove_member(
    server: &TestServer,
    token: &str,
    app_ident: &str,
    user_id: AdminUserId,
) -> axum_test::TestResponse {
    server
        .delete(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
        .add_header("authorization", format!("Bearer {token}"))
        .await
}

/// Direct-DB inactive-user seed — the create-then-deactivate dance
/// through the API is more ceremony than the test needs.
async fn seed_inactive_user(pool: &PgPool, username: &str, password: &str) -> AdminUserId {
    let repo = PostgresAdminUserRepository::new(pool.clone());
    let hash = hash_password(password).expect("hash");
    let row = repo
        .create(username, &hash, InstanceRole::Member, None)
        .await
        .expect("seed user");
    repo.set_active(row.id, false).await.expect("deactivate");
    row.id
}

// ----------------------------------------------------------------------------
// 1. Bootstrap admin → owner
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bootstrap_admin_is_owner(pool: PgPool) {
    let s = boot(pool).await;
    let token = login_token(&s.server, "owner", "owner-pw").await;
    let me = s
        .server
        .get("/api/v1/admin/auth/me")
        .add_header("authorization", format!("Bearer {token}"))
        .await;
    me.assert_status_ok();
    let listing = s
        .server
        .get("/api/v1/admin/admins")
        .add_header("authorization", format!("Bearer {token}"))
        .await;
    listing.assert_status_ok();
    let arr: Value = listing.json();
    let row = arr
        .as_array()
        .and_then(|v| v.iter().find(|u| u["username"] == "owner"))
        .expect("owner row");
    assert_eq!(row["instance_role"], "owner");
}

// ----------------------------------------------------------------------------
// 2 / 3 / 4. Role access matrices on a sample app
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn owner_access_matrix(pool: PgPool) {
    let s = boot(pool.clone()).await;
    let token = login_token(&s.server, "owner", "owner-pw").await;

    // Read apps / scripts.
    s.server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();

    // Create a script — AppWriteScript.
    let script = create_script_via_api(&s.server, &token, s.default_app, "owner-test").await;
    let sid = script["id"].as_str().unwrap();

    // Read it back — AppRead.
    s.server
        .get(&format!("/api/v1/admin/scripts/{sid}"))
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();

    // Manage users — InstanceManageUsers.
    s.server
        .get("/api/v1/admin/admins")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
    let s = boot(pool.clone()).await;
    seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
    let token = login_token(&s.server, "alice", "alice-pw").await;

    // Allowed: list admins (InstanceManageUsers).
    s.server
        .get("/api/v1/admin/admins")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();

    // Allowed: read default app — admin is implicit app_admin
    // everywhere (per blueprint §11.6).
    s.server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();

    // Allowed: write scripts.
    let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
    assert!(script["id"].is_string());

    // Allowed: list app members (AppAdmin gate). Pre-3.5.x this
    // 403'd; now it's the same allow as the owner sees.
    s.server
        .get("/api/v1/admin/apps/default/members")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();

    // Allowed: delete the default app (AppAdmin). ?force=true because
    // the script we created above pushes us past the soft no-cascade
    // guard — this test is about the capability, not the cascade.
    s.server
        .delete("/api/v1/admin/apps/default?force=true")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status(axum::http::StatusCode::NO_CONTENT);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn member_can_only_touch_apps_they_belong_to(pool: PgPool) {
    let s = boot(pool.clone()).await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
    let token = login_token(&s.server, "bob", "bob-pw").await;

    // Allowed: read + write inside the default app.
    s.server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();
    let script = create_script_via_api(&s.server, &token, s.default_app, "member-write").await;
    let sid = script["id"].as_str().unwrap();
    s.server
        .get(&format!("/api/v1/admin/scripts/{sid}"))
        .add_header("authorization", format!("Bearer {token}"))
        .await
        .assert_status_ok();

    // Denied: create a *new* app (member cannot InstanceCreateApp).
    let denied = s
        .server
        .post("/api/v1/admin/apps")
        .add_header("authorization", format!("Bearer {token}"))
        .json(&json!({ "slug": "other", "name": "Other" }))
        .await;
    assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);

    // Denied: manage admins.
    let denied = s
        .server
        .get("/api/v1/admin/admins")
        .add_header("authorization", format!("Bearer {token}"))
        .await;
    assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}

// ----------------------------------------------------------------------------
// 5. Session-token bearer + pic_ API-key bearer produce the same Principal
//    (cookie auth was removed in the audit 2026-06-11 C-1 fix; both paths
//    now travel through the Authorization header).
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn session_token_and_api_key_produce_same_principal(pool: PgPool) {
    let s = boot(pool).await;
    let session_token = login_token(&s.server, "owner", "owner-pw").await;

    // Mint a no-binding owner key covering script:read.
    let mint = mint_key(
        &s.server,
        &session_token,
        json!({
            "name": "owner-readonly",
            "scopes": ["script:read"],
        }),
    )
    .await;
    mint.assert_status(axum::http::StatusCode::CREATED);
    let raw_token = mint.json::<Value>()["raw_token"]
        .as_str()
        .expect("raw token")
        .to_string();
    assert!(raw_token.starts_with("pic_"));

    // /me through the cookie/session path.
    let via_session = s
        .server
        .get("/api/v1/admin/auth/me")
        .add_header("authorization", format!("Bearer {session_token}"))
        .await;
    via_session.assert_status_ok();

    // /me through the pic_ path — same user_id.
    let via_key = s
        .server
        .get("/api/v1/admin/auth/me")
        .add_header("authorization", format!("Bearer {raw_token}"))
        .await;
    via_key.assert_status_ok();

    assert_eq!(
        via_session.json::<Value>()["id"],
        via_key.json::<Value>()["id"]
    );
    assert_eq!(
        via_session.json::<Value>()["username"],
        via_key.json::<Value>()["username"]
    );
}

// ----------------------------------------------------------------------------
// 6. Scope intersection — read-only key cannot write
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn read_only_key_cannot_write_scripts(pool: PgPool) {
    let s = boot(pool).await;
    let session_token = login_token(&s.server, "owner", "owner-pw").await;
    let mint = mint_key(
        &s.server,
        &session_token,
        json!({ "name": "ro", "scopes": ["script:read"] }),
    )
    .await;
    mint.assert_status(axum::http::StatusCode::CREATED);
    let raw = mint.json::<Value>()["raw_token"]
        .as_str()
        .unwrap()
        .to_string();

    let denied = s
        .server
        .post("/api/v1/admin/scripts")
        .add_header("authorization", format!("Bearer {raw}"))
        .json(&json!({
            "app_id": s.default_app.to_string(),
            "name": "would-write",
            "source": "fn main() { #{ statusCode: 200 } }",
        }))
        .await;
    assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}

// ----------------------------------------------------------------------------
// 7. Bound key cannot escape its app
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bound_key_cannot_escape_its_app(pool: PgPool) {
    let s = boot(pool.clone()).await;
    let session_token = login_token(&s.server, "owner", "owner-pw").await;

    // Create a second app via the API (owner can InstanceCreateApp).
    let other = s
        .server
        .post("/api/v1/admin/apps")
        .add_header("authorization", format!("Bearer {session_token}"))
        .json(&json!({ "slug": "other", "name": "Other" }))
        .await;
    other.assert_status(axum::http::StatusCode::CREATED);
    let other_id = other.json::<Value>()["id"].as_str().unwrap().to_string();

    // Mint a key bound to the default app with script:write.
    let mint = mint_key(
        &s.server,
        &session_token,
        json!({
            "name": "default-only",
            "scopes": ["script:write"],
            "app_id": s.default_app.to_string(),
        }),
    )
    .await;
    mint.assert_status(axum::http::StatusCode::CREATED);
    let raw = mint.json::<Value>()["raw_token"]
        .as_str()
        .unwrap()
        .to_string();

    // Writing into the bound app: allowed.
    let ok = s
        .server
        .post("/api/v1/admin/scripts")
        .add_header("authorization", format!("Bearer {raw}"))
        .json(&json!({
            "app_id": s.default_app.to_string(),
            "name": "bound-ok",
            "source": "fn main() { #{ statusCode: 200 } }",
        }))
        .await;
    ok.assert_status(axum::http::StatusCode::CREATED);

    // Writing into the *other* app: forbidden.
    let denied = s
        .server
        .post("/api/v1/admin/scripts")
        .add_header("authorization", format!("Bearer {raw}"))
        .json(&json!({
            "app_id": other_id,
            "name": "escape-attempt",
            "source": "fn main() { #{ statusCode: 200 } }",
        }))
        .await;
    assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}

// ----------------------------------------------------------------------------
// 8. Member listing isolation
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn member_list_endpoints_filter_at_sql(pool: PgPool) {
    let s = boot(pool.clone()).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;

    // Owner creates a second app + script-in-that-app.
    let other = s
        .server
        .post("/api/v1/admin/apps")
        .add_header("authorization", format!("Bearer {owner_token}"))
        .json(&json!({ "slug": "secret", "name": "Secret" }))
        .await;
    other.assert_status(axum::http::StatusCode::CREATED);
    let other_id: uuid::Uuid = other.json::<Value>()["id"]
        .as_str()
        .unwrap()
        .parse()
        .unwrap();
    let other_app: AppId = other_id.into();
    create_script_via_api(&s.server, &owner_token, other_app, "secret-script").await;
    create_script_via_api(&s.server, &owner_token, s.default_app, "default-script").await;

    // Carol is a member of the default app only.
    let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, carol, s.default_app, AppRole::Viewer).await;
    let carol_token = login_token(&s.server, "carol", "carol-pw").await;

    let apps = s
        .server
        .get("/api/v1/admin/apps")
        .add_header("authorization", format!("Bearer {carol_token}"))
        .await;
    apps.assert_status_ok();
    let apps_body: Value = apps.json();
    let app_slugs: Vec<String> = apps_body
        .as_array()
        .unwrap()
        .iter()
        .map(|a| a["slug"].as_str().unwrap().to_string())
        .collect();
    assert_eq!(
        app_slugs,
        vec!["default"],
        "member must see only their apps"
    );

    let scripts = s
        .server
        .get("/api/v1/admin/scripts")
        .add_header("authorization", format!("Bearer {carol_token}"))
        .await;
    scripts.assert_status_ok();
    let scripts_body: Value = scripts.json();
    let names: Vec<String> = scripts_body
        .as_array()
        .unwrap()
        .iter()
        .map(|s| s["name"].as_str().unwrap().to_string())
        .collect();
    assert!(
        names.iter().any(|n| n == "default-script") && !names.iter().any(|n| n == "secret-script"),
        "member listing leaked another app's script: {names:?}"
    );
}

// ----------------------------------------------------------------------------
// 9. Deactivation revokes API keys
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn deactivating_user_revokes_their_api_keys(pool: PgPool) {
    let s = boot(pool.clone()).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;

    // A second user — admin so they can mint a key for themselves.
    let dave_id = seed_user(&s.pool, "dave", "dave-pw", InstanceRole::Admin).await;
    let dave_token = login_token(&s.server, "dave", "dave-pw").await;
    let mint = mint_key(
        &s.server,
        &dave_token,
        json!({ "name": "dave-key", "scopes": ["script:read"] }),
    )
    .await;
    mint.assert_status(axum::http::StatusCode::CREATED);
    let raw = mint.json::<Value>()["raw_token"]
        .as_str()
        .unwrap()
        .to_string();

    // Key works.
    let before = s
        .server
        .get("/api/v1/admin/auth/me")
        .add_header("authorization", format!("Bearer {raw}"))
        .await;
    before.assert_status_ok();

    // Owner deactivates Dave.
    let patch = s
        .server
        .patch(&format!("/api/v1/admin/admins/{dave_id}"))
        .add_header("authorization", format!("Bearer {owner_token}"))
        .json(&json!({ "is_active": false }))
        .await;
    patch.assert_status_ok();

    // Key now rejects with 401.
    let after = s
        .server
        .get("/api/v1/admin/auth/me")
        .add_header("authorization", format!("Bearer {raw}"))
        .await;
    assert_eq!(after.status_code(), axum::http::StatusCode::UNAUTHORIZED);

    // Cross-check via the repo: the row's expires_at is set in the past.
    let repo = PostgresApiKeyRepository::new(s.pool.clone());
    let rows = repo.list_for_user(dave_id).await.expect("list keys");
    assert!(
        rows.iter().all(|r| r.expires_at.is_some()),
        "every key must have an expiry after deactivation"
    );
    assert!(rows
        .iter()
        .all(|r| r.expires_at.unwrap() <= chrono::Utc::now()));
}

// ----------------------------------------------------------------------------
// 10. Mint rejects bound key + instance scope
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) {
    let s = boot(pool).await;
    let token = login_token(&s.server, "owner", "owner-pw").await;
    let r = s
        .server
        .post("/api/v1/admin/api-keys")
        .add_header("authorization", format!("Bearer {token}"))
        .json(&json!({
            "name": "irreconcilable",
            "scopes": ["instance:admin"],
            "app_id": s.default_app.to_string(),
        }))
        .await;
    assert_eq!(
        r.status_code(),
        axum::http::StatusCode::UNPROCESSABLE_ENTITY
    );
    let body: Value = r.json();
    assert!(
        body["error"].as_str().unwrap().contains("bound"),
        "error body should explain the conflict, got {body}"
    );
}

// ----------------------------------------------------------------------------
// 11. Multi-owner detection — data-source for the startup warning
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
    let s = boot(pool.clone()).await;

    // Seed a second owner directly so we exercise the
    // multi-owner condition.
    seed_user(&s.pool, "owner2", "pw", InstanceRole::Owner).await;
    seed_user(&s.pool, "admin1", "pw", InstanceRole::Admin).await;

    let users = Arc::new(PostgresAdminUserRepository::new(s.pool.clone()));
    let owners = users.list_active_owners().await.expect("list owners");
    let names: Vec<&str> = owners.iter().map(|o| o.username.as_str()).collect();
    assert!(names.contains(&"owner"));
    assert!(names.contains(&"owner2"));
    assert!(!names.contains(&"admin1"));
    assert_eq!(
        owners.len(),
        2,
        "list_active_owners must filter strictly by instance_role"
    );

    // count_other_active_owners powers the last-owner guard.
    let remaining = users
        .count_other_active_owners(s.owner)
        .await
        .expect("count");
    assert_eq!(remaining, 1, "one other owner should remain (owner2)");
}

// ----------------------------------------------------------------------------
// 12. `my_role` on GET /apps/{id_or_slug} reflects the caller's effective role
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn my_role_field_matches_caller_role(pool: PgPool) {
    let s = boot(pool).await;

    // Owner → implicit app_admin everywhere.
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let r = s
        .server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {owner_token}"))
        .await;
    r.assert_status_ok();
    assert_eq!(
        r.json::<Value>()["my_role"].as_str(),
        Some("app_admin"),
        "owner reports app_admin"
    );

    // Admin → implicit app_admin everywhere (post-§11.6 update).
    seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
    let admin_token = login_token(&s.server, "alice", "alice-pw").await;
    let r = s
        .server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {admin_token}"))
        .await;
    r.assert_status_ok();
    assert_eq!(
        r.json::<Value>()["my_role"].as_str(),
        Some("app_admin"),
        "admin reports app_admin"
    );

    // Member with explicit `viewer` membership → viewer.
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
    let bob_token = login_token(&s.server, "bob", "bob-pw").await;
    let r = s
        .server
        .get("/api/v1/admin/apps/default")
        .add_header("authorization", format!("Bearer {bob_token}"))
        .await;
    r.assert_status_ok();
    assert_eq!(
        r.json::<Value>()["my_role"].as_str(),
        Some("viewer"),
        "member with viewer row reports viewer"
    );
}

// ----------------------------------------------------------------------------
// 13. App members CRUD — `/api/v1/admin/apps/{id_or_slug}/members[/{user_id}]`
// ----------------------------------------------------------------------------

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_members_includes_seeded_member(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;

    let r = list_members(&s.server, &owner_token, "default").await;
    r.assert_status_ok();
    let rows = r.json::<Vec<Value>>();
    let bob_row = rows
        .iter()
        .find(|v| v["username"] == "bob")
        .expect("bob in list");
    assert_eq!(bob_row["role"], "viewer");
    assert_eq!(bob_row["instance_role"], "member");
    assert_eq!(bob_row["is_active"], true);
    assert!(bob_row["created_at"].is_string(), "carries created_at");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_members_requires_app_admin(pool: PgPool) {
    let s = boot(pool).await;

    // Bob has explicit editor on default app — enough to read scripts,
    // not enough to see the member list.
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
    let bob_token = login_token(&s.server, "bob", "bob-pw").await;

    let r = list_members(&s.server, &bob_token, "default").await;
    r.assert_status(axum::http::StatusCode::FORBIDDEN);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_creates_row(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;

    let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
    r.assert_status(axum::http::StatusCode::CREATED);
    let body = r.json::<Value>();
    assert_eq!(body["username"], "bob");
    assert_eq!(body["role"], "viewer");
    assert_eq!(body["instance_role"], "member");

    // Visible on subsequent list.
    let rows = list_members(&s.server, &owner_token, "default")
        .await
        .json::<Vec<Value>>();
    assert!(rows.iter().any(|v| v["username"] == "bob"));
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_duplicate_returns_409(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;

    let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
    r.assert_status(axum::http::StatusCode::CONFLICT);
    let err = r.json::<Value>()["error"]
        .as_str()
        .expect("error message")
        .to_string();
    assert!(err.contains("already a member"), "got: {err}");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_inactive_user_returns_422(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_inactive_user(&s.pool, "bob", "bob-pw").await;

    let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
    r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
    let err = r.json::<Value>()["error"]
        .as_str()
        .expect("error message")
        .to_string();
    assert!(err.contains("deactivated"), "got: {err}");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_admin_target_returns_422(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let alice = seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;

    let r = add_member(&s.server, &owner_token, "default", alice, AppRole::Viewer).await;
    r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
    let err = r.json::<Value>()["error"]
        .as_str()
        .expect("error message")
        .to_string();
    assert!(err.contains("implicit access"), "got: {err}");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_owner_target_returns_422(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let other_owner = seed_user(&s.pool, "owner2", "ow2-pw", InstanceRole::Owner).await;

    let r = add_member(
        &s.server,
        &owner_token,
        "default",
        other_owner,
        AppRole::Viewer,
    )
    .await;
    r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn patch_member_promotes_role(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;

    let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
    r.assert_status_ok();
    assert_eq!(r.json::<Value>()["role"], "editor");

    // Editor can now create a script (capability promotion observable
    // end-to-end, not just via the role string).
    let bob_token = login_token(&s.server, "bob", "bob-pw").await;
    create_script_via_api(&s.server, &bob_token, s.default_app, "bob-script").await;
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn patch_member_without_existing_returns_404(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;

    // No grant yet — PATCH must 404.
    let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
    r.assert_status(axum::http::StatusCode::NOT_FOUND);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn patch_member_same_role_is_idempotent(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;

    let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
    r.assert_status_ok();
    assert_eq!(r.json::<Value>()["role"], "viewer");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_member_removes_row(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;

    let r = remove_member(&s.server, &owner_token, "default", bob).await;
    r.assert_status(axum::http::StatusCode::NO_CONTENT);

    let rows = list_members(&s.server, &owner_token, "default")
        .await
        .json::<Vec<Value>>();
    assert!(rows.iter().all(|v| v["username"] != "bob"));
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_member_missing_returns_204(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    // No grant ever happened — delete is idempotent.

    let r = remove_member(&s.server, &owner_token, "default", bob).await;
    r.assert_status(axum::http::StatusCode::NO_CONTENT);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn mutating_endpoints_require_app_admin(pool: PgPool) {
    let s = boot(pool).await;
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
    let bob_token = login_token(&s.server, "bob", "bob-pw").await;

    let target = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;

    let r = add_member(&s.server, &bob_token, "default", target, AppRole::Viewer).await;
    r.assert_status(axum::http::StatusCode::FORBIDDEN);

    let r = patch_member_role(&s.server, &bob_token, "default", bob, AppRole::Editor).await;
    r.assert_status(axum::http::StatusCode::FORBIDDEN);

    let r = remove_member(&s.server, &bob_token, "default", bob).await;
    r.assert_status(axum::http::StatusCode::FORBIDDEN);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn members_endpoint_resolves_by_id_or_slug(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;

    let by_slug = list_members(&s.server, &owner_token, "default").await;
    by_slug.assert_status_ok();
    let by_id = list_members(&s.server, &owner_token, &s.default_app.to_string()).await;
    by_id.assert_status_ok();
    assert_eq!(
        by_slug.json::<Value>(),
        by_id.json::<Value>(),
        "id and slug return identical bodies",
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn member_app_admin_can_manage_members(pool: PgPool) {
    let s = boot(pool).await;

    // Bob is a member with explicit app_admin role on default.
    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    grant_membership(&s.pool, bob, s.default_app, AppRole::AppAdmin).await;
    let bob_token = login_token(&s.server, "bob", "bob-pw").await;

    // Bob can list members.
    let r = list_members(&s.server, &bob_token, "default").await;
    r.assert_status_ok();

    // Bob can add carol as viewer.
    let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
    let r = add_member(&s.server, &bob_token, "default", carol, AppRole::Viewer).await;
    r.assert_status(axum::http::StatusCode::CREATED);

    // Bob can promote carol to editor.
    let r = patch_member_role(&s.server, &bob_token, "default", carol, AppRole::Editor).await;
    r.assert_status_ok();

    // Bob can remove carol.
    let r = remove_member(&s.server, &bob_token, "default", carol).await;
    r.assert_status(axum::http::StatusCode::NO_CONTENT);

    // And bob can even remove himself — owner's implicit AppAdmin
    // means the app isn't orphaned. This is the load-bearing test for
    // the no-last-app-admin-guard decision.
    let r = remove_member(&s.server, &bob_token, "default", bob).await;
    r.assert_status(axum::http::StatusCode::NO_CONTENT);
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn membership_makes_app_appear_in_members_app_list(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;

    let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
    let bob_token = login_token(&s.server, "bob", "bob-pw").await;

    // Before grant: bob sees no apps.
    let r = s
        .server
        .get("/api/v1/admin/apps")
        .add_header("authorization", format!("Bearer {bob_token}"))
        .await;
    r.assert_status_ok();
    assert!(
        r.json::<Vec<Value>>().is_empty(),
        "bob has no memberships → empty apps list"
    );

    // Grant via the public POST endpoint — exercises the full
    // round-trip the dashboard goes through, not just the repo seam.
    let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
    r.assert_status(axum::http::StatusCode::CREATED);

    // After grant: bob sees the default app.
    let r = s
        .server
        .get("/api/v1/admin/apps")
        .add_header("authorization", format!("Bearer {bob_token}"))
        .await;
    r.assert_status_ok();
    let apps = r.json::<Vec<Value>>();
    assert_eq!(apps.len(), 1);
    assert_eq!(apps[0]["slug"], "default");
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_with_missing_user_id_is_rejected(pool: PgPool) {
    let s = boot(pool).await;
    let owner_token = login_token(&s.server, "owner", "owner-pw").await;

    // Body missing `user_id` — Axum's Json extractor produces a 4xx
    // before our handler runs. Pinning the status to keep the contract
    // honest if anyone ever swaps the extractor.
    let r = s
        .server
        .post("/api/v1/admin/apps/default/members")
        .add_header("authorization", format!("Bearer {owner_token}"))
        .json(&json!({ "role": "viewer" }))
        .await;
    let status = r.status_code().as_u16();
    assert!(
        (400..500).contains(&status),
        "malformed body should produce a 4xx, got {status}"
    );
}
