//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
//!   * a root manifest `[project]` block marks an environment confirm-required,
//!   * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
//!     does not cover it) — refused non-interactively at the CLI,
//!   * `--approve <env>` (as an admin) lets it through,
//!   * a non-gated environment applies with plain `--yes`,
//!   * single-node `apply --file` to a gated env is refused (no silent bypass),
//!   * approving a gated apply needs ADMIN authority on the node — a non-admin
//!     editor with `--approve` is refused server-side (403).

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

use tempfile::TempDir;

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

/// A single-app project dir whose root manifest declares a `[project]` policy:
/// `production` is confirm-required, `staging` is not. A `[vars]` entry gives
/// the app write-requiring content so an `editor` member's AppVarsWrite is
/// exercised (proving the admin gate is ABOVE editor-write). Vars cascade with
/// the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.
fn project_dir(app: &str) -> TempDir {
    let dir = TempDir::new().expect("tempdir");
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
             [[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
             [[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
             [vars]\nregion = \"eu\"\n"
        ),
    )
    .unwrap();
    // `--env <e>` requires the overlay file to exist; empty overlays keep the
    // base slug (same app across envs — we're testing the gate, not env routing).
    fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
    fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
    dir
}

fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
    let mut cmd = common::pic_as(env);
    cmd.args(["apply", "--dir"]).arg(dir).args(extra);
    cmd.output().expect("apply --dir")
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approval() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("appr-g");
    let app = common::unique_slug("appr-a");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();
    let _a = AppGuard::new(&env.url, &env.token, &app);
    common::pic_as(&env)
        .args(["apps", "create", &app, "--group", &group])
        .assert()
        .success();

    let dir = project_dir(&app);

    // --- production is confirm-required: --yes alone is refused. ---
    let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
    assert!(
        !out.status.success(),
        "production apply without --approve must be refused"
    );
    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        err.contains("approve"),
        "refusal should mention --approve:\n{err}"
    );

    // --- with --approve production, it applies. ---
    let ok = apply(
        &env,
        dir.path(),
        &["--env", "production", "--approve", "production"],
    );
    assert!(
        ok.status.success(),
        "approved production apply should succeed: {}",
        String::from_utf8_lossy(&ok.stderr)
    );

    // --- staging is NOT gated: plain --yes applies. ---
    let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
    assert!(
        ok2.status.success(),
        "non-gated staging apply should succeed: {}",
        String::from_utf8_lossy(&ok2.stderr)
    );

    // --- single-node `apply --file` to a gated env is refused (no bypass). ---
    let single = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(dir.path().join("picloud.toml"))
        .args(["--env", "production", "--yes"])
        .output()
        .expect("apply --file");
    assert!(
        !single.status.success(),
        "single-node apply to a confirm-required env must be refused"
    );
    let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
    assert!(
        serr.contains("confirm-required") || serr.contains("--dir"),
        "single-node refusal should point at --dir:\n{serr}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn approving_a_gated_apply_requires_admin() {
    // §4.2: approving a confirm-required env is admin-gated — a second gate on
    // top of the editor-level write caps an ordinary apply needs. An editor who
    // CAN write the app (its `[vars]`) still cannot approve a gated apply.
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("appr2-g");
    let app = common::unique_slug("appr2-a");
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();
    let _a = AppGuard::new(&env.url, &env.token, &app);
    common::pic_as(&env)
        .args(["apps", "create", &app, "--group", &group])
        .assert()
        .success();

    // A member with `editor` (write) on the app — enough for an ordinary apply,
    // not enough to approve a gated environment.
    let m = member::member_user(fx, &common::unique_username("appr"));
    member::grant_membership(fx, &app, &m.id, "editor");
    let member_env = common::custom_env(&fx.url, &m.token);
    common::seed_credentials(&member_env, &m.username);

    let dir = project_dir(&app);
    let out = apply(
        &member_env,
        dir.path(),
        &["--env", "production", "--approve", "production"],
    );
    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"),
        "approval denial should be an authz error:\n{err}"
    );
}
