//! Phase 5 project-tree apply, end to end via `pic plan/apply --dir`:
//!   * a group + a nested app under it apply atomically as one tree; the app
//!     binds a route to the group's inherited script (created in the SAME
//!     apply); re-plan is a no-op,
//!   * one invalid node aborts the whole tree (nothing written),
//!   * a `pic groups reparent` between plan and apply trips the bound-plan
//!     (StateMoved) check via the tree's structure version.

use std::fs;

use predicates::prelude::*;
use tempfile::TempDir;

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

/// A project tree on disk: root group manifest + a nested app manifest that
/// binds `GET /greet` to the group's (inherited) `shared` script.
fn tree_dir(group: &str, app: &str, group_script_src: &str, app_extra: &str) -> TempDir {
    let dir = TempDir::new().expect("tempdir");
    fs::create_dir_all(dir.path().join("scripts")).unwrap();
    fs::create_dir_all(dir.path().join(app).join("scripts")).unwrap();
    fs::write(dir.path().join("scripts/shared.rhai"), group_script_src).unwrap();
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[group]\nslug = \"{group}\"\nname = \"Tree Group\"\n\n\
             [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
             [vars]\nregion = \"eu\"\n"
        ),
    )
    .unwrap();
    fs::write(
        dir.path().join(app).join("picloud.toml"),
        format!(
            "[app]\nslug = \"{app}\"\nname = \"Tree App\"\n\n\
             [[routes]]\nscript = \"shared\"\nmethod = \"GET\"\n\
             host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n{app_extra}"
        ),
    )
    .unwrap();
    dir
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_applies_group_and_app_atomically_then_noop() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("tr-grp");
    let app = common::unique_slug("tr-app");

    // Group + app must pre-exist (the manifest owns content, not tree shape).
    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 = tree_dir(&group, &app, "\"hi from the group\"", "");

    // Plan the whole tree: a group script+var create and an app route create.
    let plan = String::from_utf8(
        common::pic_as(&env)
            .args(["plan", "--dir"])
            .arg(dir.path())
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    assert!(
        plan.contains("shared") && plan.contains("region") && plan.contains("/greet"),
        "tree plan should cover both nodes:\n{plan}"
    );

    // Apply the whole tree in one transaction.
    common::pic_as(&env)
        .args(["apply", "--dir"])
        .arg(dir.path())
        .assert()
        .success();
    // The group node created its `shared` script (it must drop before its
    // group at teardown — ON DELETE RESTRICT).
    let listed = String::from_utf8(
        common::pic_as(&env)
            .args(["scripts", "ls", "--group", &group])
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    assert!(
        listed.contains("shared"),
        "the group node's script should exist after the tree apply:\n{listed}"
    );
    let gscript_id = group_script_id(&env, &group);
    let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);

    // Re-plan is a clean no-op (idempotent across both nodes — incl. the app's
    // route bound to the inherited group script).
    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 the tree must be a no-op:\n{replan}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_is_atomic_on_an_invalid_node() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let group = common::unique_slug("tra-grp");
    let app = common::unique_slug("tra-app");

    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();

    // The app node carries an INVALID Rhai script → the whole tree must abort.
    let dir = TempDir::new().unwrap();
    fs::create_dir_all(dir.path().join("scripts")).unwrap();
    fs::create_dir_all(dir.path().join(&app).join("scripts")).unwrap();
    fs::write(dir.path().join("scripts/shared.rhai"), "\"ok\"").unwrap();
    fs::write(dir.path().join(&app).join("scripts/bad.rhai"), "let x = ;").unwrap();
    fs::write(
        dir.path().join("picloud.toml"),
        format!(
            "[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
             [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
        ),
    )
    .unwrap();
    fs::write(
        dir.path().join(&app).join("picloud.toml"),
        format!(
            "[app]\nslug = \"{app}\"\nname = \"A\"\n\n\
             [[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
        ),
    )
    .unwrap();

    common::pic_as(&env)
        .args(["apply", "--dir"])
        .arg(dir.path())
        .assert()
        .failure();

    // Atomic: the valid group node's `shared` must NOT have been created.
    let listed = String::from_utf8(
        common::pic_as(&env)
            .args(["scripts", "ls", "--group", &group])
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap();
    assert!(
        !listed.contains("shared"),
        "a failed tree apply must leave nothing behind:\n{listed}"
    );
}

#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_reparent_between_plan_and_apply_trips_state_moved() {
    let Some(fx) = common::fixture_or_skip() else {
        return;
    };
    let env = common::admin_env(fx);
    let parent = common::unique_slug("trp-par");
    let group = common::unique_slug("trp-grp");
    let app = common::unique_slug("trp-app");

    // parent → group → app.
    let _p = GroupGuard::new(&env.url, &env.token, &parent);
    common::pic_as(&env)
        .args(["groups", "create", &parent])
        .assert()
        .success();
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group, "--parent", &parent])
        .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 = tree_dir(&group, &app, "\"x\"", "");

    // Record a bound plan for the whole tree.
    common::pic_as(&env)
        .args(["plan", "--dir"])
        .arg(dir.path())
        .assert()
        .success();

    // Reparent the group to the instance root — bumps its structure version.
    common::pic_as(&env)
        .args(["groups", "reparent", &group])
        .assert()
        .success();

    // Apply the recorded plan: the structure moved underneath it → refuse.
    // (The apply aborts before any write, so no group script is created and the
    // GroupGuards tear down cleanly.)
    common::pic_as(&env)
        .args(["apply", "--dir"])
        .arg(dir.path())
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("changed")
                .or(predicate::str::contains("plan"))
                .or(predicate::str::contains("409")),
        );
}

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 --group");
    common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
        .expect("group should have one script")
}
