//! §6 M1 — declarative group CREATE, end to end via `pic apply --dir`.
//!
//! A repo declares its group subtree by directory nesting: a `[group]` node's
//! parent is the nearest ancestor directory that also holds a `[group]`, and the
//! topmost group binds to the repo's `[project] parent_group` (attach point).
//! The tree apply creates the missing groups (parents-first) in one transaction,
//! claims them for the project, and is a no-op on re-apply. Creating a group is
//! capability-gated (a member without group-admin is refused).

use std::fs;
use std::path::Path;

use tempfile::TempDir;

use crate::common;
use crate::common::cleanup::GroupGuard;

/// A two-level repo on disk: root `[group] {top}` (claimed by `{project}`,
/// attached under `{attach}`) with a nested `sub/` `[group] {sub}`.
fn nested_repo(dir: &Path, project: &str, attach: &str, top: &str, sub: &str) {
    fs::write(
        dir.join("picloud.toml"),
        format!(
            "[project]\nslug = \"{project}\"\nparent_group = \"{attach}\"\n\n\
             [group]\nslug = \"{top}\"\nname = \"Top\"\n"
        ),
    )
    .unwrap();
    let subdir = dir.join("sub");
    fs::create_dir_all(&subdir).unwrap();
    fs::write(
        subdir.join("picloud.toml"),
        format!("[group]\nslug = \"{sub}\"\nname = \"Sub\"\n"),
    )
    .unwrap();
}

/// The `parent` (index 2) and `owner` (index 3) cells for `group` in
/// `pic groups ls` — columns: slug, name, parent, owner, created_at.
fn parent_and_owner(env: &common::TestEnv, group: &str) -> (String, String) {
    let ls = common::pic_as(env)
        .args(["groups", "ls"])
        .output()
        .expect("groups ls");
    let table = String::from_utf8(ls.stdout).unwrap();
    let row = table
        .lines()
        .map(common::cells)
        .find(|c| c.first() == Some(&group))
        .unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}"));
    (
        row.get(2).copied().unwrap_or_default().to_string(),
        row.get(3).copied().unwrap_or_default().to_string(),
    )
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_creates_nested_groups_under_the_attach_point() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let base = common::unique_slug("gc-base");
    let top = common::unique_slug("gc-top");
    let sub = common::unique_slug("gc-sub");
    let project = common::unique_slug("gc-proj");

    // The attach point pre-exists; `top` and `sub` do NOT — the tree creates them.
    let _b = GroupGuard::new(&env.url, &env.token, &base);
    common::pic_as(&env)
        .args(["groups", "create", &base])
        .assert()
        .success();
    // Teardown order (reverse of declaration): sub → top → base.
    let _gtop = GroupGuard::new(&env.url, &env.token, &top);
    let _gsub = GroupGuard::new(&env.url, &env.token, &sub);

    let dir = TempDir::new().unwrap();
    nested_repo(dir.path(), &project, &base, &top, &sub);

    // Plan previews both group CREATES (and, with a [project], a claim).
    let out = common::pic_as(&env)
        .args(["plan", "--dir"])
        .arg(dir.path())
        .output()
        .unwrap();
    let plan = String::from_utf8(out.stdout).unwrap();
    let plan_err = String::from_utf8_lossy(&out.stderr);
    assert!(
        plan.contains(&top) && plan.contains(&sub),
        "plan should mention both to-create groups:\nSTDOUT:\n{plan}\nSTDERR:\n{plan_err}"
    );

    // Apply creates `top` under `base`, then `sub` under `top`, in one tx.
    common::pic_as(&env)
        .args(["apply", "--dir"])
        .arg(dir.path())
        .assert()
        .success();

    let (top_parent, top_owner) = parent_and_owner(&env, &top);
    assert_eq!(top_parent, base, "top's parent is the attach point");
    assert_eq!(
        top_owner, project,
        "a created group is claimed by the project"
    );
    let (sub_parent, sub_owner) = parent_and_owner(&env, &sub);
    assert_eq!(
        sub_parent, top,
        "sub's parent is its same-tree ancestor group"
    );
    assert_eq!(
        sub_owner, project,
        "the nested created group is also claimed"
    );

    // Re-apply is a clean no-op (both groups already exist, parents match).
    let replan = String::from_utf8(
        common::pic_as(&env)
            .args(["plan", "--dir"])
            .arg(dir.path())
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    assert!(
        !replan.contains("create") && !replan.contains("update"),
        "re-plan of an already-created tree must be a no-op:\n{replan}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn creating_a_group_requires_group_admin_on_the_parent() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let base = common::unique_slug("gcm-base");
    let top = common::unique_slug("gcm-top");
    let project = common::unique_slug("gcm-proj");

    let _b = GroupGuard::new(&env.url, &env.token, &base);
    common::pic_as(&env)
        .args(["groups", "create", &base])
        .assert()
        .success();

    // A member with only EDITOR on `base` may reconcile content but must not be
    // able to CREATE a subgroup under it (that needs group-admin).
    let mem = common::member::member_user(fx, &common::unique_slug("gcm-mem"));
    common::member::grant_group_membership(fx, &base, &mem.id, "editor");
    let menv = common::custom_env(&env.url, &mem.token);
    common::seed_credentials(&menv, &mem.username);

    let dir = TempDir::new().unwrap();
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
             [group]\nslug = \"{top}\"\nname = \"Top\"\n"
        ),
    )
    .unwrap();

    let out = common::pic_as(&menv)
        .args(["apply", "--dir"])
        .arg(dir.path())
        .output()
        .expect("member apply");
    assert!(
        !out.status.success(),
        "an editor without group-admin must not create a group"
    );

    // The group was NOT created (the tx rolled back).
    let ls = String::from_utf8(
        common::pic_as(&env)
            .args(["groups", "ls"])
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    assert!(
        !ls.lines()
            .map(common::cells)
            .any(|c| c.first() == Some(&top.as_str())),
        "a denied create must leave no group behind:\n{ls}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_denies_group_content_write_without_the_cap() {
    // A tree apply that writes content (here a `[vars]` entry) into a
    // PRE-EXISTING group requires the caller hold that group's write cap. This
    // pins the invariant the in-tx content-write re-check hardens: a group that
    // isn't structurally created by this apply must pass content authz. (The
    // race the re-check closes — a group appearing between the API-layer check
    // and reconcile — isn't reproducible at the journey level; end to end a
    // member without the cap is refused and the var is not written.)
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let base = common::unique_slug("gcw-base");
    let grp = common::unique_slug("gcw-grp");
    let project = common::unique_slug("gcw-proj");
    let _b = GroupGuard::new(&env.url, &env.token, &base);
    let _g = GroupGuard::new(&env.url, &env.token, &grp);

    // `base` is the attach point; `grp` pre-exists directly under it.
    common::pic_as(&env)
        .args(["groups", "create", &base])
        .assert()
        .success();
    common::pic_as(&env)
        .args(["groups", "create", &grp, "--parent", &base])
        .assert()
        .success();

    // A member with NO membership on `grp` cannot write its vars.
    let mem = common::member::member_user(fx, &common::unique_slug("gcw-mem"));
    let menv = common::custom_env(&env.url, &mem.token);
    common::seed_credentials(&menv, &mem.username);

    // A repo that declares the pre-existing `grp` (attached under `base`) with a
    // var — a group content write.
    let dir = TempDir::new().unwrap();
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
             [group]\nslug = \"{grp}\"\nname = \"Grp\"\n\n[vars]\nregion = \"eu\"\n"
        ),
    )
    .unwrap();

    let out = common::pic_as(&menv)
        .args(["apply", "--dir"])
        .arg(dir.path())
        .output()
        .expect("member apply");
    assert!(
        !out.status.success(),
        "a member without the group's write cap must be refused a content write"
    );

    // The var was NOT written (the tx rolled back / never ran).
    let vars = String::from_utf8(
        common::pic_as(&env)
            .args(["vars", "ls", "--group", &grp])
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    assert!(
        !vars.contains("region"),
        "a denied content write must leave no var behind:\n{vars}"
    );
}
