//! §3 M3 — per-env approval gating (`[project.environments]`).
//!
//! A `[project.environments]` block marks some envs confirm-required. Applying
//! to such an env with `pic apply --env <e>` is refused unless it is explicitly
//! `--approve <e>`d — and a blanket `--yes` does NOT cover it (§4.2, "CI must
//! opt in per environment"). An unlisted or `confirm = false` env applies
//! freely. The CLI gates client-side (fast fail) AND the SERVER enforces it — a
//! confirm-required apply is refused (409) unless approved, and an approved
//! override additionally requires ADMIN on every declared node (a step up from
//! editor write caps) and is audited.
//!
//! §3 M3 HERMETIC gate: the policy is PERSISTED server-side (`project_environments`,
//! 0067) on every apply, and the gate evaluates against `persisted ∪ declared` —
//! so a request that OMITS the policy (or flips a gate to `false`) can no longer
//! bypass a gate a prior apply established. This closes both the patched/older-CLI
//! bypass AND the hand-crafted-request bypass.

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

use tempfile::TempDir;

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

/// A repo whose `[project]` gates `production` (confirm-required) but not
/// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files
/// exist so `--env` can load them.
fn gated_repo(dir: &Path, project: &str, group: &str) {
    fs::write(
        dir.join("picloud.toml"),
        format!(
            "[project]\nslug = \"{project}\"\n\n\
             [project.environments]\nproduction = {{ confirm = true }}\n\
             staging = {{ confirm = false }}\n\n\
             [group]\nslug = \"{group}\"\nname = \"Env Gated\"\n"
        ),
    )
    .unwrap();
    fs::write(
        dir.join("picloud.production.toml"),
        "# production overlay\n",
    )
    .unwrap();
    fs::write(dir.join("picloud.staging.toml"), "# staging overlay\n").unwrap();
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approve() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("ea-grp");
    let project = common::unique_slug("ea-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let dir = TempDir::new().unwrap();
    gated_repo(dir.path(), &project, &group);
    let manifest = dir.path().join("picloud.toml");
    let apply = |args: &[&str]| -> std::process::Output {
        let mut c = common::pic_as(&env);
        c.args(["apply", "--file"]).arg(&manifest).args(args);
        c.output().expect("apply")
    };

    // production is confirm-required → a bare `--env production` is refused,
    // and the message names `--approve`. (Client-side: never hits the server.)
    let out = apply(&["--env", "production"]);
    assert!(!out.status.success(), "production must require approval");
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("approve") && err.contains("production"),
        "the refusal must point at --approve production:\n{err}"
    );

    // A blanket `--yes` does NOT cover a gated env.
    assert!(
        !apply(&["--env", "production", "--yes"]).status.success(),
        "--yes must not bypass a confirm-required env"
    );

    // `--approve production` lets it through.
    assert!(
        apply(&["--env", "production", "--approve", "production"])
            .status
            .success(),
        "an explicit --approve production must apply"
    );

    // staging is `confirm = false` → applies freely, no approval needed.
    assert!(
        apply(&["--env", "staging"]).status.success(),
        "an un-gated env must apply without --approve"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn an_env_listed_with_an_empty_policy_is_a_hard_error() {
    // `production = {}` (no `confirm`) must FAIL to load, not silently parse as
    // un-gated — you can't half-declare a gate. `confirm` is a required field.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("eae-grp");
    let project = common::unique_slug("eae-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);

    let dir = TempDir::new().unwrap();
    let manifest = dir.path().join("picloud.toml");
    fs::write(
        &manifest,
        format!(
            "[project]\nslug = \"{project}\"\n\n\
             [project.environments]\nproduction = {{}}\n\n\
             [group]\nslug = \"{group}\"\nname = \"Empty Policy\"\n"
        ),
    )
    .unwrap();
    fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap();

    let out = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&manifest)
        .args(["--env", "production"])
        .output()
        .expect("apply");
    assert!(
        !out.status.success(),
        "an env with an empty policy must be a load error, not a silent no-gate"
    );
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("confirm"),
        "the error must name the missing `confirm` field:\n{err}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn leaf_file_apply_honors_the_root_env_gate() {
    // `pic apply --file <leaf>` where the leaf carries no `[project]` still
    // honors the gate declared in the repo root (found by walking up).
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let base = common::unique_slug("eal-base");
    let leaf = common::unique_slug("eal-leaf");
    let project = common::unique_slug("eal-proj");
    let _b = GroupGuard::new(&env.url, &env.token, &base);
    let _l = GroupGuard::new(&env.url, &env.token, &leaf);
    common::pic_as(&env)
        .args(["groups", "create", &base])
        .assert()
        .success();
    common::pic_as(&env)
        .args(["groups", "create", &leaf, "--parent", &base])
        .assert()
        .success();

    // Root manifest carries the gating `[project]`; the leaf (in a subdir) has
    // no `[project]` of its own and is applied on its own with `--file`.
    let dir = TempDir::new().unwrap();
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[project]\nslug = \"{project}\"\n\n\
             [project.environments]\nproduction = {{ confirm = true }}\n\n\
             [group]\nslug = \"{base}\"\nname = \"Base\"\n"
        ),
    )
    .unwrap();
    let sub = dir.path().join("sub");
    fs::create_dir_all(&sub).unwrap();
    let leaf_manifest = sub.join("picloud.toml");
    fs::write(
        &leaf_manifest,
        format!("[group]\nslug = \"{leaf}\"\nname = \"Leaf\"\n"),
    )
    .unwrap();
    fs::write(sub.join("picloud.production.toml"), "# overlay\n").unwrap();

    // Without --approve: refused, because the gate is discovered up-tree.
    let out = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&leaf_manifest)
        .args(["--env", "production"])
        .output()
        .expect("apply");
    assert!(
        !out.status.success(),
        "a leaf --file apply must honor the root's env gate"
    );
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("approve") && err.contains("production"),
        "the refusal must point at --approve production:\n{err}"
    );

    // With --approve production: passes the gate and applies (leaf pre-exists).
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&leaf_manifest)
        .args(["--env", "production", "--approve", "production"])
        .assert()
        .success();
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn server_enforces_the_gate_against_a_non_stock_client() {
    // The client-side gate is a convenience; a patched CLI or a raw request can
    // skip it. The server re-derives the policy from the wire and enforces it:
    // a raw tree apply to a confirm-required env WITHOUT the env in
    // `approved_envs` is refused (409), regardless of the client.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("eas-grp");
    let project = common::unique_slug("eas-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let http = reqwest::blocking::Client::new();
    let body = |approved: serde_json::Value| {
        serde_json::json!({
            "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
            "project": {
                "slug": project,
                "environments": { "production": { "confirm": true } }
            },
            "env": "production",
            "approved_envs": approved,
            "prune": false,
        })
    };

    // No approval on the wire → 409 ApprovalRequired (server-enforced, the CLI's
    // client-side gate never ran).
    let resp = http
        .post(format!("{}/api/v1/admin/tree/apply", env.url))
        .bearer_auth(&env.token)
        .json(&body(serde_json::json!([])))
        .send()
        .expect("tree apply");
    assert_eq!(
        resp.status().as_u16(),
        409,
        "a raw gated apply without approval must be refused server-side (got {}: {})",
        resp.status(),
        resp.text().unwrap_or_default()
    );

    // Approved on the wire + an admin token (holds GroupAdmin) → succeeds.
    let ok = http
        .post(format!("{}/api/v1/admin/tree/apply", env.url))
        .bearer_auth(&env.token)
        .json(&body(serde_json::json!(["production"])))
        .send()
        .expect("tree apply");
    assert!(
        ok.status().is_success(),
        "an approved gated apply by an admin must succeed (got {}: {})",
        ok.status(),
        ok.text().unwrap_or_default()
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn approving_a_gated_apply_requires_admin() {
    // §4.2: overriding a gate is ADMIN authority — a step up from the editor
    // write caps an ordinary apply needs. A group `editor` who CAN write the
    // group still cannot approve a gated apply of it.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("eaa-grp");
    let project = common::unique_slug("eaa-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    // A member granted `editor` (write, not admin) on the group.
    let m = member::member_user(fx, &common::unique_username("eaa"));
    common::pic_as(&env)
        .args([
            "groups", "members", "add", &group, &m.id, "--role", "editor",
        ])
        .assert()
        .success();
    let member_env = common::custom_env(&fx.url, &m.token);
    common::seed_credentials(&member_env, &m.username);

    // A gated repo managing that group (with a var, so the editor's write cap is
    // exercised — proving the admin gate is ABOVE it). Applied by the editor.
    let dir = TempDir::new().unwrap();
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[project]\nslug = \"{project}\"\n\n\
             [project.environments]\nproduction = {{ confirm = true }}\n\n\
             [group]\nslug = \"{group}\"\nname = \"Gated\"\n\n\
             [vars]\nregion = \"eu\"\n"
        ),
    )
    .unwrap();
    fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap();
    let manifest = dir.path().join("picloud.toml");

    let out = common::pic_as(&member_env)
        .args(["apply", "--file"])
        .arg(&manifest)
        .args(["--env", "production", "--approve", "production"])
        .output()
        .expect("apply");
    assert!(
        !out.status.success(),
        "a non-admin editor must not be able to approve a gated apply"
    );
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("forbidden") || err.contains("403"),
        "the approval denial should be an authz error:\n{err}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn persisted_policy_gates_a_request_that_omits_it() {
    // §3 M3 HERMETIC: once an apply persists a `confirm = true` env, a LATER raw
    // request that OMITS the policy entirely (empty `environments`) is STILL
    // gated — the server enforces against the persisted policy, not just the
    // wire. This is the hand-crafted-request bypass the earlier CLI-only gate
    // could not close.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("eah-grp");
    let project = common::unique_slug("eah-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let http = reqwest::blocking::Client::new();
    let apply = |environments: serde_json::Value, approved: serde_json::Value| {
        http.post(format!("{}/api/v1/admin/tree/apply", env.url))
            .bearer_auth(&env.token)
            .json(&serde_json::json!({
                "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
                "project": { "slug": project, "environments": environments },
                "env": "production",
                "approved_envs": approved,
                "prune": false,
            }))
            .send()
            .expect("tree apply")
    };

    // First apply DECLARES + APPROVES the gate → persists `production=true`.
    let seed = apply(
        serde_json::json!({ "production": { "confirm": true } }),
        serde_json::json!(["production"]),
    );
    assert!(
        seed.status().is_success(),
        "the seeding apply (declared + approved) must succeed (got {}: {})",
        seed.status(),
        seed.text().unwrap_or_default()
    );

    // Now a request that OMITS the policy and does NOT approve → still 409,
    // because the persisted policy still gates `production`.
    let omit = apply(serde_json::json!({}), serde_json::json!([]));
    assert_eq!(
        omit.status().as_u16(),
        409,
        "a persisted gate must hold even when the request omits the policy (got {}: {})",
        omit.status(),
        omit.text().unwrap_or_default()
    );

    // Approving it clears the persisted gate (admin token holds GroupAdmin).
    let ok = apply(serde_json::json!({}), serde_json::json!(["production"]));
    assert!(
        ok.status().is_success(),
        "approving the persisted gate must let it through (got {}: {})",
        ok.status(),
        ok.text().unwrap_or_default()
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn omitting_the_project_block_entirely_does_not_bypass_a_persisted_gate() {
    // Audit 2026-07-11 H1: the earlier gate keyed the persisted policy on the
    // client-supplied `project.slug`, so a request that OMITTED `[project]`
    // ENTIRELY (`project: null`, not just empty `environments`) yielded an empty
    // policy and skipped the gate. The fix resolves the GOVERNING project
    // server-side from the target node's nearest-claimed ancestor, so a request
    // to a claimed, gated node can no longer apply just by dropping `[project]`.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("eao-grp");
    let project = common::unique_slug("eao-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let http = reqwest::blocking::Client::new();

    // Seed: declare + approve the gate → claims the group AND persists
    // `production=true` on the owning project.
    let seed = http
        .post(format!("{}/api/v1/admin/tree/apply", env.url))
        .bearer_auth(&env.token)
        .json(&serde_json::json!({
            "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
            "project": {
                "slug": project,
                "environments": { "production": { "confirm": true } }
            },
            "env": "production",
            "approved_envs": ["production"],
            "prune": false,
        }))
        .send()
        .expect("seed apply");
    assert!(
        seed.status().is_success(),
        "the seeding apply (declared + approved) must succeed (got {}: {})",
        seed.status(),
        seed.text().unwrap_or_default()
    );

    // Attack: a raw apply that OMITS the `project` block entirely (no `project`
    // key on the wire) to the same gated node, WITHOUT approving `production`.
    // The server resolves the governing project from the node itself, so this is
    // refused (409) — it does NOT quietly apply.
    let omit = http
        .post(format!("{}/api/v1/admin/tree/apply", env.url))
        .bearer_auth(&env.token)
        .json(&serde_json::json!({
            "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
            "env": "production",
            "approved_envs": [],
            "prune": false,
        }))
        .send()
        .expect("omit apply");
    assert_eq!(
        omit.status().as_u16(),
        409,
        "omitting [project] must not bypass the persisted gate (got {}: {})",
        omit.status(),
        omit.text().unwrap_or_default()
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn flipping_a_gate_to_false_in_the_same_request_still_gates() {
    // §3 M3 HERMETIC (monotonic rule): the gate evaluates against
    // `persisted ∪ declared`, so declaring `confirm = false` for an env a prior
    // apply persisted as `true` does NOT un-gate it in the same request — the
    // ungating apply must itself pass the gate. This closes the flip-to-false
    // bypass.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("eaf-grp");
    let project = common::unique_slug("eaf-proj");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let http = reqwest::blocking::Client::new();
    let apply = |environments: serde_json::Value, approved: serde_json::Value| {
        http.post(format!("{}/api/v1/admin/tree/apply", env.url))
            .bearer_auth(&env.token)
            .json(&serde_json::json!({
                "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] },
                "project": { "slug": project, "environments": environments },
                "env": "production",
                "approved_envs": approved,
                "prune": false,
            }))
            .send()
            .expect("tree apply")
    };

    // Persist `production=true`.
    assert!(apply(
        serde_json::json!({ "production": { "confirm": true } }),
        serde_json::json!(["production"]),
    )
    .status()
    .is_success());

    // Same request flips it to false but does NOT approve → still 409 (the
    // persisted `true` wins under the union rule).
    let flip = apply(
        serde_json::json!({ "production": { "confirm": false } }),
        serde_json::json!([]),
    );
    assert_eq!(
        flip.status().as_u16(),
        409,
        "flipping a persisted gate to false must not un-gate it in the same request (got {}: {})",
        flip.status(),
        flip.text().unwrap_or_default()
    );
}
