//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
//!   * a GROUP declares a module name `theme` an extension point (with a
//!     default body) and a group endpoint `render` that imports it,
//!   * an app under the group provides its OWN `theme`; invoking the inherited
//!     `render` in that app's context resolves the APP's `theme` (the
//!     inversion) — NOT sealed to the group,
//!   * an app that provides no `theme` falls back to the group's default body,
//!   * `pull` round-trips the declaration (a re-applied pulled manifest is a
//!     no-op, so `--prune` never silently drops it).

use std::fs;

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");
    dir
}

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

    // GroupGuard first so it drops LAST (after apps + group scripts).
    let _g = GroupGuard::new(&env.url, &env.token, &group);
    common::pic_as(&env)
        .args(["groups", "create", &group])
        .assert()
        .success();

    let dir = manifest_dir();

    // --- Group manifest: a default `theme` body, a `render` endpoint that
    // imports `theme`, and the `[[extension_points]]` declaration. ---
    fs::write(
        dir.path().join("scripts/defaulttheme.rhai"),
        r#"fn name() { "default" }"#,
    )
    .unwrap();
    fs::write(
        dir.path().join("scripts/render.rhai"),
        r#"import "theme" as t; t::name()"#,
    )
    .unwrap();
    let group_manifest = format!(
        "[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
         [[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
         [[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
         [[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
    );
    let group_path = dir.path().join("group.toml");
    fs::write(&group_path, &group_manifest).unwrap();
    let out = common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&group_path)
        .output()
        .expect("group apply");
    assert!(
        out.status.success(),
        "group apply failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // Group scripts block group deletion (RESTRICT) — guard them.
    let _gr = ScriptGuard::new(
        &env.url,
        &env.token,
        &group_script_id(&env, &group, "render"),
    );
    let _gd = ScriptGuard::new(
        &env.url,
        &env.token,
        &group_script_id(&env, &group, "defaulttheme"),
    );

    // --- App A under the group: provides its OWN `theme` + a caller that
    // invokes the inherited `render`. ---
    let _ga = AppGuard::new(&env.url, &env.token, &app_a);
    common::pic_as(&env)
        .args(["apps", "create", &app_a, "--group", &group])
        .assert()
        .success();
    fs::write(
        dir.path().join("scripts/theme-a.rhai"),
        r#"fn name() { "app-a" }"#,
    )
    .unwrap();
    fs::write(
        dir.path().join("scripts/caller.rhai"),
        r#"invoke("render", #{})"#,
    )
    .unwrap();
    let app_a_manifest = format!(
        "[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
         [[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
         [[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
    );
    let app_a_path = dir.path().join("a.toml");
    fs::write(&app_a_path, &app_a_manifest).unwrap();
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&app_a_path)
        .assert()
        .success();

    // The inherited `render` imports the ext point `theme`; in App A's context
    // it resolves App A's OWN `theme`.
    let caller_a = app_script_id(&env, &app_a, "caller");
    assert_eq!(
        invoke_body(&env, &caller_a),
        serde_json::json!("app-a"),
        "extension point must resolve the inheriting app's module"
    );

    // --- App B under the group: provides NO `theme` → render falls back to the
    // group's default body. ---
    let _gb = AppGuard::new(&env.url, &env.token, &app_b);
    common::pic_as(&env)
        .args(["apps", "create", &app_b, "--group", &group])
        .assert()
        .success();
    let app_b_manifest = format!(
        "[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
         [[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
    );
    let app_b_path = dir.path().join("b.toml");
    fs::write(&app_b_path, &app_b_manifest).unwrap();
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&app_b_path)
        .assert()
        .success();
    let caller_b = app_script_id(&env, &app_b, "caller");
    assert_eq!(
        invoke_body(&env, &caller_b),
        serde_json::json!("default"),
        "an app providing no module must fall back to the ext point's default"
    );
}

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

    let _a = AppGuard::new(&env.url, &env.token, &app);
    common::pic_as(&env)
        .args(["apps", "create", &app])
        .assert()
        .success();

    // Apply an app manifest that declares an extension point with a default.
    let dir = manifest_dir();
    fs::write(
        dir.path().join("scripts/theme.rhai"),
        r#"fn name() { "x" }"#,
    )
    .unwrap();
    let manifest = format!(
        "[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
         [[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
         [[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
    );
    let manifest_path = dir.path().join("picloud.toml");
    fs::write(&manifest_path, &manifest).unwrap();
    common::pic_as(&env)
        .args(["apply", "--file"])
        .arg(&manifest_path)
        .assert()
        .success();

    // Pull into a fresh dir, then re-plan: the extension point must round-trip
    // (no Create/Delete), so `--prune` would never drop it.
    let pulled = manifest_dir();
    common::pic_as(&env)
        .args(["pull", &app, "--dir"])
        .arg(pulled.path())
        .assert()
        .success();
    let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
    assert!(
        toml.contains("theme_slot"),
        "pulled manifest must export the extension point:\n{toml}"
    );
    let plan = common::pic_as(&env)
        .args(["plan", "--file"])
        .arg(pulled.path().join("picloud.toml"))
        .output()
        .expect("plan");
    let stdout = String::from_utf8_lossy(&plan.stdout);
    assert!(
        !stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
        "re-planning a pulled manifest must be a no-op:\n{stdout}"
    );
}

/// Id of the named script in a group (`pic scripts ls --group <g>`).
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
    named_id(env, &["scripts", "ls", "--group", group], name)
}

/// Id of the named script in an app (`pic scripts ls --app <a>`).
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
    named_id(env, &["scripts", "ls", "--app", app], name)
}

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

fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
    let out = common::pic_as(env)
        .args(["scripts", "invoke", id])
        .output()
        .expect("scripts invoke");
    assert!(
        out.status.success(),
        "invoke failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}
