//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture
//! project once (cargo, same toolchain, path-dep on this workspace's
//! aura-core), then drives the aura binary from inside the fixture dir.
//! `aura run` persists nothing without --trace, so the tracked fixture stays
//! clean (target/, Cargo.lock, runs/ are fixture-gitignored).

use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project")
}

fn badcharter_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project")
}

fn built_fixture() -> &'static PathBuf {
    static BUILT: OnceLock<PathBuf> = OnceLock::new();
    BUILT.get_or_init(|| {
        let dir = fixture_dir();
        let out = Command::new("cargo")
            .arg("build")
            .current_dir(&dir)
            .output()
            .expect("spawn cargo build for the fixture project");
        assert!(
            out.status.success(),
            "fixture build failed:\n{}",
            String::from_utf8_lossy(&out.stderr)
        );
        dir
    })
}

fn aura(args: &[&str], cwd: &Path) -> Output {
    Command::new(env!("CARGO_BIN_EXE_aura"))
        .args(args)
        .current_dir(cwd)
        .output()
        .expect("run aura")
}

#[test]
fn project_run_resolves_demo_node_and_is_bit_identical() {
    let dir = built_fixture();
    let a = aura(&["run", "demo_signal.json"], dir);
    assert!(
        a.status.success(),
        "run failed: {}",
        String::from_utf8_lossy(&a.stderr)
    );
    let b = aura(&["run", "demo_signal.json"], dir);
    assert!(b.status.success());
    assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
}

#[test]
fn project_run_manifest_carries_provenance() {
    let dir = built_fixture();
    let out = aura(&["run", "demo_signal.json"], dir);
    assert!(out.status.success());
    let v: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("run report is JSON");
    let p = &v["manifest"]["project"];
    assert_eq!(p["namespace"], "demo");
    let sha = p["dylib_sha256"].as_str().expect("hash present");
    assert_eq!(sha.len(), 64, "sha256 hex");
}

#[test]
fn introspect_vocabulary_lists_project_types() {
    let dir = built_fixture();
    let out = aura(&["graph", "introspect", "--vocabulary"], dir);
    assert!(out.status.success());
    let text = String::from_utf8_lossy(&out.stdout);
    assert!(text.contains("demo::Identity"), "project id listed");
    assert!(text.contains("SMA"), "std ids still listed");
}

/// (#184/#185) Outside any project, `demo::Identity` resolves nowhere: the base
/// failure is house-style prose (never the raw `UnknownNodeType` Debug leak),
/// and — because the unresolved id LOOKS project-namespaced (`::`) while no
/// `Aura.toml` was found up from cwd — the message carries a hint pointing at
/// the missing project, distinguishing "no project" from "genuinely unknown
/// std id" (the bare-id case in `cli_run.rs` gets no such hint).
#[test]
fn outside_a_project_the_demo_blueprint_is_unknown() {
    // cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
    let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
    let bp = fixture_dir().join("demo_signal.json");
    let out = aura(&["run", bp.to_str().unwrap()], cwd);
    assert!(!out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains(r#"unknown node type "demo::Identity""#),
        "house-style prose names the type: {err}"
    );
    assert!(!err.contains("UnknownNodeType"), "does not leak the Debug variant name: {err}");
    assert!(
        err.contains("Aura.toml"),
        "hints that no project was found for the namespaced id: {err}"
    );
}

/// `Env::runs_root()` resolves `Aura.toml`'s `[paths] runs = "runs"` against
/// the DISCOVERED project root (the `Aura.toml` directory found by walking up
/// from cwd), not against the invocation cwd itself: a family recorded while
/// invoking `aura` from a project subdirectory must still land under
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
/// subdirectory. This is the property that makes `[paths]` project-relative
/// rather than shell-relative (C17).
#[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
    let dir = built_fixture();
    let sub = dir.join("src");
    let runs_dir = dir.join("runs");
    std::fs::remove_dir_all(&runs_dir).ok();
    let open_bp =
        format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
    let out = Command::new(env!("CARGO_BIN_EXE_aura"))
        .args([
            "sweep",
            &open_bp,
            "--axis",
            "sma_signal.fast.length=2,4",
            "--axis",
            "sma_signal.slow.length=8,16",
            "--name",
            "proj-anchor",
        ])
        .current_dir(&sub)
        .output()
        .expect("spawn aura sweep");
    assert!(
        out.status.success(),
        "sweep failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        runs_dir.join("families.jsonl").is_file(),
        "family record must land at the project root's runs/, not the invocation cwd's"
    );
    assert!(
        !sub.join("runs").exists(),
        "no stray runs/ must be created under the invocation subdirectory"
    );
    std::fs::remove_dir_all(&runs_dir).ok();
}

/// A project whose listed type id is not `<namespace>::`-prefixed is refused
/// (exit 1, cause named) through the REAL load path — `libloading` +
/// descriptor read + `check_charter` wired together in `project::load` — not
/// merely the pure `check_charter` function (already unit-tested in
/// `aura-cli::project`). Pins the "refuse-don't-guess" vocabulary charter as
/// an end-to-end guarantee: a regression that skipped the charter call in the
/// `load()` glue would pass every existing unit test but still be caught here.
#[test]
fn vocabulary_charter_violation_refuses_end_to_end() {
    let dir = badcharter_dir();
    let build = Command::new("cargo")
        .arg("build")
        .current_dir(&dir)
        .output()
        .expect("spawn cargo build for the badcharter fixture");
    assert!(
        build.status.success(),
        "badcharter fixture build failed:\n{}",
        String::from_utf8_lossy(&build.stderr)
    );
    let run = aura(&["run", "x.json"], &dir);
    assert_eq!(
        run.status.code(),
        Some(1),
        "a charter violation is a runtime refusal (exit 1), not a usage error"
    );
    let err = String::from_utf8_lossy(&run.stderr);
    assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
}

/// Force a file's modification time to a fixed instant (explicit mtime, no
/// sleeping — mtime granularity would make a "touch then compare" race).
fn set_mtime(path: &Path, secs_since_epoch: u64) {
    let t = UNIX_EPOCH + Duration::from_secs(secs_since_epoch);
    let f = std::fs::OpenOptions::new()
        .write(true)
        .open(path)
        .unwrap_or_else(|e| panic!("open {} to set mtime: {e}", path.display()));
    f.set_modified(t)
        .unwrap_or_else(|e| panic!("set mtime on {}: {e}", path.display()));
}

/// The role-2 authoring loop is edit -> `cargo build` -> run (C25). A forgotten
/// build is a silent-wrong-results footgun: the loader load-and-holds the
/// *previous* dylib and reports plausible, stale numbers. The property: when a
/// source file under the project is newer (mtime) than the built dylib, the run
/// still proceeds — a stale run is deterministic and manifest-stamped, so a
/// refusal is not warranted — but it surfaces the staleness on stderr, naming
/// both the dylib's and the source's modification times so the omission is
/// visible instead of silent.
#[test]
fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
    let dir = built_fixture();
    let src = dir.join("src/lib.rs");
    let dylib = dir.join("target/debug").join(format!(
        "{}demo_project{}",
        std::env::consts::DLL_PREFIX,
        std::env::consts::DLL_SUFFIX,
    ));
    assert!(
        dylib.is_file(),
        "built fixture must have produced a dylib at {}",
        dylib.display()
    );

    // Pin two far-apart, distinctive mtimes so any human-readable rendering of
    // either timestamp carries a stable, non-colliding year token. Source
    // (2033) is newer than the dylib (2001): stale.
    const DYLIB_MTIME: u64 = 993_859_200; // 2001-06-30 UTC
    const SOURCE_MTIME: u64 = 2_003_702_400; // 2033-06-30 UTC
    set_mtime(&dylib, DYLIB_MTIME);
    set_mtime(&src, SOURCE_MTIME);

    let out = aura(&["run", "demo_signal.json"], dir);

    // Restore sane mtimes before asserting, so a failed assertion does not
    // leave the shared fixture wedged "stale" (or with a future mtime that
    // makes every later `cargo build` re-fingerprint it).
    let now = SystemTime::now();
    let _ = std::fs::OpenOptions::new()
        .write(true)
        .open(&dylib)
        .and_then(|f| f.set_modified(now));
    let _ = std::fs::OpenOptions::new()
        .write(true)
        .open(&src)
        .and_then(|f| f.set_modified(now));

    // Warn, not refuse: the run still succeeds and still emits its JSON report.
    assert!(
        out.status.success(),
        "a stale dylib warns but the run proceeds (exit 0): {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let report: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("stale run still emits its JSON report");
    assert_eq!(
        report["manifest"]["project"]["namespace"], "demo",
        "the stale run behaves exactly like a fresh one"
    );

    // Names both timestamps: the dylib's (2001) and the newer source's (2033).
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        err.contains("2001"),
        "stderr warning must name the dylib's modification time (2001): {err}"
    );
    assert!(
        err.contains("2033"),
        "stderr warning must name the newer source's modification time (2033): {err}"
    );
}

#[test]
fn missing_artifact_refuses_with_build_hint() {
    // A minimal never-built project: valid cargo metadata, no dylib on disk.
    let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id()));
    std::fs::create_dir_all(tmp.join("src")).unwrap();
    std::fs::write(tmp.join("Aura.toml"), "").unwrap();
    std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
    std::fs::write(
        tmp.join("Cargo.toml"),
        "[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
    )
    .unwrap();
    let out = aura(&["run", "x.json"], &tmp);
    assert_eq!(out.status.code(), Some(1), "runtime refusal");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("cargo build"), "build hint present: {err}");
    std::fs::remove_dir_all(&tmp).ok();
}
