//! §7 multi-repo ownership — the claim, end to end via `pic`.
//!
//! A `[project]` claims a group node on first apply; the SAME project re-applies
//! freely; a DIFFERENT project applying to that node is refused (409) unless it
//! `--takeover`s (which requires group-admin). An app inherits ownership from
//! its nearest claimed ancestor group. `pic groups ls` shows the owning
//! project's slug. The pure claim policy is unit-tested in manager-core; this
//! journey pins the wire + CLI + authz interaction.

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

use tempfile::TempDir;

use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};

fn manifest_dir() -> TempDir {
    let dir = TempDir::new().expect("tempdir");
    fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
    fs::write(
        dir.path().join("scripts/shared.rhai"),
        r#"log::info("shared"); "ok""#,
    )
    .unwrap();
    dir
}

/// A `[project]` + `[group]` manifest (with one group script) in `dir`.
fn write_group_manifest(dir: &Path, project: &str, group: &str) {
    let m = format!(
        "[project]\nslug = \"{project}\"\nname = \"Proj\"\n\n\
         [group]\nslug = \"{group}\"\nname = \"Grp\"\n\n\
         [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
    );
    fs::write(dir.join("picloud.toml"), m).unwrap();
}

fn group_script_id(env: &common::TestEnv, group: &str) -> String {
    let ls = common::pic_as(env)
        .args(["scripts", "ls", "--group", group])
        .output()
        .expect("scripts ls");
    let table = String::from_utf8(ls.stdout).unwrap();
    table
        .lines()
        .map(common::cells)
        .find(|c| c.get(2) == Some(&"shared"))
        .and_then(|c| c.first().map(|s| (*s).to_string()))
        .unwrap_or_else(|| panic!("group script not found:\n{table}"))
}

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

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn claim_conflict_takeover_and_app_inheritance() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("own-grp");
    let proj_a = common::unique_slug("plat");
    let proj_b = common::unique_slug("teamb");

    // The group must pre-exist (groups pre-exist; the manifest owns content).
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    // (1) First apply by project A CLAIMS the group.
    let dir_a = manifest_dir();
    write_group_manifest(dir_a.path(), &proj_a, &group);
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(dir_a.path().join("picloud.toml"))
        .assert()
        .success();
    let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
    assert_eq!(
        owner_cell(&env, &group),
        proj_a,
        "the owner column must show the claiming project"
    );

    // (2) The SAME project re-applies with no conflict (idempotent).
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(dir_a.path().join("picloud.toml"))
        .assert()
        .success();

    // (3) A DIFFERENT project applying to the owned group is refused (409),
    // naming the current owner.
    let dir_b = manifest_dir();
    write_group_manifest(dir_b.path(), &proj_b, &group);
    let out = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(dir_b.path().join("picloud.toml"))
        .output()
        .expect("apply B");
    assert!(!out.status.success(), "a second project must be refused");
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("owned by project") && err.contains(&proj_a),
        "the conflict must name the owner `{proj_a}`:\n{err}"
    );

    // (4) --takeover (admin holds group-admin implicitly) reassigns to B.
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(dir_b.path().join("picloud.toml"))
        .arg("--takeover")
        .assert()
        .success();
    assert_eq!(
        owner_cell(&env, &group),
        proj_b,
        "takeover must reassign the owner"
    );

    // (5) An app under the claimed group INHERITS ownership: the owning project
    // applies fine; a foreign or absent project is refused.
    let app = common::unique_slug("own-app");
    let _a = AppGuard::new(&env.url, &env.token, &app);
    common::pic_as(&env)
        .args(["apps", "create", &app, "--group", &group])
        .assert()
        .success();
    let app_dir = manifest_dir();
    let app_manifest = |proj: Option<&str>| -> String {
        let head = proj
            .map(|p| format!("[project]\nslug = \"{p}\"\n\n"))
            .unwrap_or_default();
        format!("{head}[app]\nslug = \"{app}\"\nname = \"App\"\n")
    };
    // Project B owns the group → ok.
    fs::write(
        app_dir.path().join("picloud.toml"),
        app_manifest(Some(&proj_b)),
    )
    .unwrap();
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(app_dir.path().join("picloud.toml"))
        .assert()
        .success();
    // Project A no longer owns the group → refused.
    fs::write(
        app_dir.path().join("picloud.toml"),
        app_manifest(Some(&proj_a)),
    )
    .unwrap();
    let out = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(app_dir.path().join("picloud.toml"))
        .output()
        .expect("apply app A");
    assert!(
        !out.status.success(),
        "an app under a claimed group must match its owner"
    );
    // No [project] under a claimed subtree → refused.
    fs::write(app_dir.path().join("picloud.toml"), app_manifest(None)).unwrap();
    let out = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(app_dir.path().join("picloud.toml"))
        .output()
        .expect("apply app none");
    assert!(
        !out.status.success(),
        "a no-project app under a claimed subtree is refused"
    );

    // (6) --takeover is capability-gated: a member with only EDITOR on the group
    // can reconcile it but must NOT be able to take it over (needs group-admin).
    let mem = common::member::member_user(fx, &common::unique_slug("own-mem"));
    common::member::grant_group_membership(fx, &group, &mem.id, "editor");
    let menv = common::custom_env(&env.url, &mem.token);
    common::seed_credentials(&menv, &mem.username);
    let proj_c = common::unique_slug("teamc");
    let dir_c = manifest_dir();
    write_group_manifest(dir_c.path(), &proj_c, &group);
    let out = common::pic_as(&menv)
        .args(["apply", "--file"])
        .arg(dir_c.path().join("picloud.toml"))
        .arg("--takeover")
        .output()
        .expect("member takeover");
    assert!(
        !out.status.success(),
        "a member without group-admin must not be able to --takeover"
    );
    // The owner is unchanged — the failed takeover rolled back.
    assert_eq!(
        owner_cell(&env, &group),
        proj_b,
        "a refused takeover must not change ownership"
    );
}

/// The `name` cell (index 1: slug, NAME, owned_groups, created_at) for `project`
/// in `pic projects ls`.
fn project_name_cell(env: &common::TestEnv, project: &str) -> String {
    let ls = common::pic_as(env)
        .args(["projects", "ls"])
        .output()
        .expect("projects ls");
    let table = String::from_utf8(ls.stdout).unwrap();
    table
        .lines()
        .map(common::cells)
        .find(|c| c.first() == Some(&project))
        .and_then(|c| c.get(1).map(|s| (*s).to_string()))
        .unwrap_or_else(|| panic!("project `{project}` not in projects ls:\n{table}"))
}

/// A name-less `[project]` re-apply must PRESERVE the display name set on the
/// first apply — an omitted optional field never clobbers the stored name back
/// to the slug (regression for the `ON CONFLICT DO UPDATE SET name` path).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn reapply_without_name_preserves_project_name() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("name-grp");
    let proj = common::unique_slug("name-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let dir = manifest_dir();
    let apply = |m: &str| {
        fs::write(dir.path().join("picloud.toml"), m).unwrap();
        common::pic_as(&env)
            .args(["apply", "--file"])
            .arg(dir.path().join("picloud.toml"))
            .assert()
            .success();
    };

    // First apply declares a display name.
    apply(&format!(
        "[project]\nslug = \"{proj}\"\nname = \"Acme Platform\"\n\n\
         [group]\nslug = \"{group}\"\nname = \"Grp\"\n"
    ));
    assert_eq!(
        project_name_cell(&env, &proj),
        "Acme Platform",
        "the first apply records the declared name"
    );

    // A re-apply that OMITS `name` must not overwrite it with the slug.
    apply(&format!(
        "[project]\nslug = \"{proj}\"\n\n\
         [group]\nslug = \"{group}\"\nname = \"Grp\"\n"
    ));
    assert_eq!(
        project_name_cell(&env, &proj),
        "Acme Platform",
        "a name-less re-apply must preserve the stored name, not clobber it to the slug"
    );
}

/// §6/§7 M2 — `[project] parent_group` is the ceiling: applies are refused for
/// any node not strictly within the attach point's subtree.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn attach_point_ceiling_bounds_the_subtree() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    // acme (root) → team (child); plus a sibling `outsider` root.
    let acme = common::unique_slug("acme");
    let team = common::unique_slug("team");
    let outsider = common::unique_slug("outsider");
    let _a = GroupGuard::new(&env.url, &env.token, &acme);
    let _t = GroupGuard::new(&env.url, &env.token, &team);
    let _o = GroupGuard::new(&env.url, &env.token, &outsider);
    common::pic_as(&env)
        .args(["groups", "create", &acme])
        .assert()
        .success();
    common::pic_as(&env)
        .args(["groups", "create", &team, "--parent", &acme])
        .assert()
        .success();
    common::pic_as(&env)
        .args(["groups", "create", &outsider])
        .assert()
        .success();

    let proj = common::unique_slug("attach-p");
    let dir = manifest_dir();
    let apply = |m: &str| -> std::process::Output {
        fs::write(dir.path().join("picloud.toml"), m).unwrap();
        common::pic_as(&env)
            .args(["apply", "--file"])
            .arg(dir.path().join("picloud.toml"))
            .output()
            .expect("apply")
    };

    // A group node strictly BELOW the attach point → ok.
    let out = apply(&format!(
        "[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
         [group]\nslug = \"{team}\"\nname = \"Team\"\n"
    ));
    assert!(
        out.status.success(),
        "a node below the attach point applies: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // The attach point ITSELF → refused (you can't apply above your local root).
    let out = apply(&format!(
        "[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
         [group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
    ));
    assert!(
        !out.status.success(),
        "applying the attach point itself must be refused"
    );
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("attach point"),
        "the refusal must mention the attach point:\n{err}"
    );

    // A SIBLING subtree (not under acme) → refused.
    let out = apply(&format!(
        "[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
         [group]\nslug = \"{outsider}\"\nname = \"Out\"\n"
    ));
    assert!(
        !out.status.success(),
        "a sibling subtree is outside the attach point"
    );
}

/// §7 M3 — `pic plan` previews the ownership outcome (claim / conflict) and the
/// cross-repo blast radius (descendant apps owned by other projects) BEFORE any
/// apply.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn plan_previews_ownership_and_blast_radius() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    // acme (root) → team_a, team_b; an app under each, each team claimed by a
    // DIFFERENT project.
    let acme = common::unique_slug("br-acme");
    let team_a = common::unique_slug("br-a");
    let team_b = common::unique_slug("br-b");
    let app_a = common::unique_slug("br-app-a");
    let app_b = common::unique_slug("br-app-b");
    let plat = common::unique_slug("plat");
    let teamb = common::unique_slug("teamb");
    let platform = common::unique_slug("platform");
    let _g0 = GroupGuard::new(&env.url, &env.token, &acme);
    let _g1 = GroupGuard::new(&env.url, &env.token, &team_a);
    let _g2 = GroupGuard::new(&env.url, &env.token, &team_b);
    let _a1 = AppGuard::new(&env.url, &env.token, &app_a);
    let _a2 = AppGuard::new(&env.url, &env.token, &app_b);
    for (slug, parent) in [
        (&acme, None),
        (&team_a, Some(&acme)),
        (&team_b, Some(&acme)),
    ] {
        let mut c = common::pic_as(&env);
        c.args(["groups", "create", slug]);
        if let Some(p) = parent {
            c.args(["--parent", p]);
        }
        c.assert().success();
    }
    common::pic_as(&env)
        .args(["apps", "create", &app_a, "--group", &team_a])
        .assert()
        .success();
    common::pic_as(&env)
        .args(["apps", "create", &app_b, "--group", &team_b])
        .assert()
        .success();

    // Claim team_a by `plat`, team_b by `teamb` (empty group applies).
    let dir = manifest_dir();
    let claim = |project: &str, group: &str| {
        let m = format!(
            "[project]\nslug = \"{project}\"\n\n[group]\nslug = \"{group}\"\nname = \"G\"\n"
        );
        fs::write(dir.path().join("picloud.toml"), m).unwrap();
        common::pic_as(&env)
            .args(["apply", "--file"])
            .arg(dir.path().join("picloud.toml"))
            .assert()
            .success();
    };
    claim(&plat, &team_a);
    claim(&teamb, &team_b);

    // `pic projects ls` lists both registered projects with their owned-group
    // counts (plat owns exactly team_a).
    let projects = String::from_utf8(
        common::pic_as(&env)
            .args(["projects", "ls"])
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    let plat_row = projects
        .lines()
        .map(common::cells)
        .find(|c| c.first() == Some(&plat.as_str()))
        .unwrap_or_else(|| panic!("plat not in projects ls:\n{projects}"));
    assert_eq!(
        plat_row.get(2).copied(),
        Some("1"),
        "plat owns exactly one group:\n{projects}"
    );
    assert!(
        projects.contains(teamb.as_str()),
        "teamb must be listed:\n{projects}"
    );

    // Plan acme with project `platform`: acme is unclaimed → action `claim`; the
    // blast radius lists the OTHER projects' descendant apps (plat + teamb).
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
        ),
    )
    .unwrap();
    let out = common::pic_as(&env)
        .args(["plan", "--file"])
        .arg(dir.path().join("picloud.toml"))
        .output()
        .expect("plan acme");
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("claim"),
        "acme is unclaimed → the preview must show `claim`:\n{stdout}"
    );
    assert!(
        stdout.contains("blast_radius") && stdout.contains(&plat) && stdout.contains(&teamb),
        "the blast radius must list the other projects' apps (plat + teamb):\n{stdout}"
    );

    // Plan team_a with `platform`: it is owned by `plat` → action `conflict`.
    fs::write(
        dir.path().join("picloud.toml"),
        format!("[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{team_a}\"\nname = \"A\"\n"),
    )
    .unwrap();
    let out = common::pic_as(&env)
        .args(["plan", "--file"])
        .arg(dir.path().join("picloud.toml"))
        .output()
        .expect("plan team_a");
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("conflict") && stdout.contains(&plat),
        "planning a foreign-owned group must preview a conflict naming the owner:\n{stdout}"
    );
}
