//! Integration test: drive the built `aura` binary as a downstream user would,
//! asserting the `run` subcommand's stdout/exit contract and the bad-args path.

use std::process::Command;

/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");

/// A fresh, unique working directory for a process test that persists run
/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
/// Unique per test + per process; no external tempfile dependency.
fn temp_cwd(name: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create temp cwd");
    dir
}

#[test]
fn run_prints_json_and_exits_zero() {
    let out = Command::new(BIN).arg("run").output().expect("spawn aura run");
    assert!(out.status.success(), "exit status: {:?}", out.status);

    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
    // exactly one line (the JSON object + a trailing newline from println!).
    assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}");
    let line = stdout.trim_end();

    // canonical cycle-0009 JSON shape: nested manifest + metrics, stable keys.
    // The manifest leads with a `commit` field; its value is the build's git
    // identity (asserted by `run_manifest_commit_carries_real_git_head`), so
    // this shape check pins only the surrounding structure, not the value.
    assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}");
    assert!(line.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "got: {line}");
    assert!(line.contains("\"window\":[1,7]"), "got: {line}");
    assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}");
    // the integer sign-flip count is stable across float renderings.
    assert!(line.ends_with("\"exposure_sign_flips\":1}}"), "got: {line}");
}

/// Property: the RunManifest is self-identifying — its `commit` carries the
/// real code identity that produced the run, not a placeholder. C8/C18 audit
/// trail (this run = this commit): once runs are archived, `manifest.commit`
/// must point back at the source. The build captures the current git HEAD at
/// compile time; the `"unknown"` literal survives only when there is no git.
///
/// Structural assertion (not exact-equality) on purpose: the working tree may
/// be dirty when this runs (e.g. this very test file uncommitted), so a build
/// that appends a `-dirty` suffix would not equal `rev-parse HEAD` verbatim.
/// The honest contract is: the field CONTAINS the current HEAD sha AND is not
/// the bare `"unknown"` placeholder — true regardless of dirtiness/suffix.
#[test]
fn run_manifest_commit_carries_real_git_head() {
    // The current HEAD sha, obtained the same way the build is expected to.
    let head = Command::new("git")
        .args(["rev-parse", "HEAD"])
        .output()
        .expect("spawn git rev-parse HEAD");
    assert!(head.status.success(), "git rev-parse HEAD failed");
    let head_sha = String::from_utf8(head.stdout).expect("utf-8 sha");
    let head_sha = head_sha.trim();
    assert!(!head_sha.is_empty(), "empty HEAD sha");

    // Drive the binary and pull `manifest.commit` out of the single JSON line.
    let out = Command::new(BIN).arg("run").output().expect("spawn aura run");
    assert!(out.status.success(), "exit status: {:?}", out.status);
    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
    let line = stdout.trim_end();

    // Locate the `"commit":"<value>"` value without a JSON dependency (the
    // sibling tests parse this output by substring too).
    let key = "\"commit\":\"";
    let start = line.find(key).expect("manifest has a commit field") + key.len();
    let rest = &line[start..];
    let end = rest.find('"').expect("commit field is a closed string");
    let commit = &rest[..end];

    assert_ne!(
        commit, "unknown",
        "manifest.commit is still the placeholder: {line}"
    );
    assert!(
        commit.contains(head_sha),
        "manifest.commit {commit:?} should contain the current HEAD sha {head_sha:?}; line: {line}"
    );
}

/// Property: `aura run` is strict about its argument vector — a trailing
/// unexpected token (e.g. a typo'd flag like `aura run --sweep`) takes the
/// error path (usage on stderr, exit 2), never a silent full run. This is the
/// strict reading ratified in #16: keying only on the first token being `run`
/// and ignoring the rest would let a typo masquerade as a successful run.
/// The same test pins positive-preservation — bare `aura run` still emits the
/// JSON report on stdout and exits 0 — so the strict guard cannot regress the
/// happy path.
#[test]
fn run_with_trailing_token_is_strict_and_exits_two() {
    // Negative: an unexpected trailing token is rejected like a bad-args call.
    let out = Command::new(BIN)
        .args(["run", "extra-garbage"])
        .output()
        .expect("spawn aura run extra-garbage");
    assert_eq!(
        out.status.code(),
        Some(2),
        "`aura run extra-garbage` must reject the trailing token: {:?}",
        out.status
    );
    assert!(
        out.stdout.is_empty(),
        "stdout should be empty on the usage path, got: {:?}",
        String::from_utf8_lossy(&out.stdout)
    );
    let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
    assert!(stderr.contains("usage"), "stderr was: {stderr:?}");

    // Positive-preservation: bare `aura run` still runs and prints the report.
    let ok = Command::new(BIN).arg("run").output().expect("spawn aura run");
    assert_eq!(
        ok.status.code(),
        Some(0),
        "bare `aura run` must still succeed: {:?}",
        ok.status
    );
    let ok_stdout = String::from_utf8(ok.stdout).expect("utf-8 stdout");
    assert!(
        ok_stdout.trim_start().starts_with("{\"manifest\":"),
        "bare `aura run` should print the JSON report, got: {ok_stdout:?}"
    );
}

#[test]
fn no_args_prints_usage_and_exits_two() {
    let out = Command::new(BIN).output().expect("spawn aura");
    assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
    assert!(out.stdout.is_empty(), "stdout should be empty on the usage path");
    let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
    assert!(stderr.contains("usage"), "stderr was: {stderr:?}");
}

/// Property: `--help`/`-h` is the success-path help affordance, not the error
/// path. A newcomer's reflex first command (C22 first-contact ergonomics, #20)
/// prints the usage to stdout and exits 0; the conventional `-h` alias behaves
/// identically. An *unknown* subcommand keeps the error path (exit 2) so the
/// help fix does not regress bad-args handling.
#[test]
fn help_flag_prints_usage_to_stdout_and_exits_zero() {
    for flag in ["--help", "-h"] {
        let out = Command::new(BIN).arg(flag).output().expect("spawn aura --help");
        assert_eq!(out.status.code(), Some(0), "`aura {flag}` exit: {:?}", out.status);
        let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
        assert!(
            !stdout.trim().is_empty(),
            "`aura {flag}` should print help to stdout, got: {stdout:?}"
        );
        // The usage names the only subcommand a newcomer can run.
        assert!(
            stdout.contains("run"),
            "`aura {flag}` help should mention the `run` subcommand, got: {stdout:?}"
        );
    }

    // Negative-preservation: an unknown subcommand is still the error path.
    let out = Command::new(BIN).arg("frobnicate").output().expect("spawn aura frobnicate");
    assert_eq!(
        out.status.code(),
        Some(2),
        "unknown subcommand must stay exit 2: {:?}",
        out.status
    );
}

/// Property: `aura graph` emits a single self-contained HTML page — the
/// interactive pin-graph viewer with the deterministic model inlined and the
/// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through
/// the built binary so it pins the observable CLI contract. The DOT/SVG are
/// Graphviz-version-dependent and not asserted (the prototype is the by-hand
/// visual reference); this pins the page envelope + the retirement of the old
/// ascii-dag notation.
#[test]
fn graph_emits_self_contained_html_viewer() {
    let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
    assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
    // a self-contained HTML document...
    assert!(
        stdout.trim_start().starts_with("<!doctype html>"),
        "not an HTML doc: {:?}",
        &stdout[..stdout.len().min(80)]
    );
    // ...with the deterministic model inlined as the viewer's data source...
    assert!(stdout.contains("window.AURA_MODEL = {\"root\":"), "model not inlined: {stdout:?}");
    // ...the Graphviz-WASM bootstrap present, and no remote asset fetch.
    assert!(stdout.contains("Viz.instance"), "viewer bootstrap missing");
    assert!(!stdout.contains("<script src"), "assets must be inlined, found remote <script src>");
    // the retired ascii-dag invented notation is gone.
    assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}");
}

#[test]
fn sweep_prints_four_family_json_lines_and_exits_zero() {
    let cwd = temp_cwd("sweep4");
    let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
    assert!(out.status.success(), "exit status: {:?}", out.status);

    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
    assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
    let first = stdout.lines().next().unwrap();
    // each line is now a family member: the assigned `family_id` plus the nested
    // RunReport (C18/C21 lineage). The first run assigns `sweep-0`; the nested
    // report is serialized via `serde_json`, so the manifest's fields lead with
    // `broker` (the commit value is the volatile git HEAD, pinned by params below).
    assert!(first.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {stdout}");
    assert!(
        first.contains("\"params\":[[\"signals.trend.fast.length\",{\"I64\":2}],[\"signals.trend.slow.length\",{\"I64\":4}],[\"signals.momentum.fast.length\",{\"I64\":2}],[\"signals.momentum.slow.length\",{\"I64\":4}],[\"signals.momentum.signal.length\",{\"I64\":3}],[\"signals.blend.weights[0]\",{\"F64\":1.0}],[\"signals.blend.weights[1]\",{\"F64\":1.0}],[\"exposure.scale\",{\"F64\":0.5}]]"),
        "got: {stdout}"
    );
    let _ = std::fs::remove_dir_all(&cwd);
}

#[test]
fn sweep_with_trailing_token_is_strict_and_exits_two() {
    // strict-arg reading (#16): an unexpected trailing token is a usage error.
    let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura");
    assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
}

/// Property: `aura mc` runs the built-in Monte-Carlo family end-to-end through the
/// binary — it prints one member line per seed (carrying the assigned `family_id`)
/// plus one `mc_aggregate` line, exits 0, and persists the draws as a discoverable
/// `MonteCarlo` family (C12 axis 4 / C18/C21). Driven through the built binary so
/// it pins the observable CLI contract, not just the in-crate render helper.
#[test]
fn mc_runs_persists_a_monte_carlo_family_and_lists_it() {
    let cwd = temp_cwd("mc-family");
    let out = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
    assert!(out.status.success(), "mc exit: {:?}", out.status);

    let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
    let lines: Vec<&str> = stdout.lines().collect();
    // three built-in seeds -> three member lines + one aggregate line.
    assert_eq!(lines.len(), 4, "mc stdout: {stdout:?}");
    for line in &lines[..3] {
        assert!(line.contains("\"family_id\":\"mc-0\""), "member missing family_id: {line}");
        assert!(line.contains("\"seed\":"), "member missing seed: {line}");
    }
    assert!(lines[3].contains("\"mc_aggregate\":"), "missing aggregate line: {}", lines[3]);

    // the run persisted the draws as a discoverable MonteCarlo family.
    let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
    assert!(fams.status.success(), "families exit: {:?}", fams.status);
    let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
    assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
    assert!(fams_out.contains("\"family_id\":\"mc-0\""), "families: {fams_out:?}");
    assert!(fams_out.contains("\"kind\":\"MonteCarlo\""), "families: {fams_out:?}");
    assert!(fams_out.contains("\"members\":3"), "families: {fams_out:?}");

    let _ = std::fs::remove_dir_all(&cwd);
}

#[test]
fn runs_families_list_and_per_family_rank_across_invocations() {
    let cwd = temp_cwd("runs-flow");
    // two sweeps accumulate two families (sweep-0, sweep-1) over time, each a
    // related set of 4 records (C18/C21 lineage), in the sibling family store.
    for _ in 0..2 {
        let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep");
        assert!(out.status.success(), "sweep exit: {:?}", out.status);
    }

    // `runs families` shows both stored families in first-seen order, each with
    // its kind + member count.
    let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
    assert!(fams.status.success(), "families exit: {:?}", fams.status);
    let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
    let fam_lines: Vec<&str> = fams_out.lines().collect();
    assert_eq!(fam_lines.len(), 2, "families: {fams_out:?}");
    assert!(fam_lines[0].contains("\"family_id\":\"sweep-0\""), "fam0: {fams_out:?}");
    assert!(fam_lines[1].contains("\"family_id\":\"sweep-1\""), "fam1: {fams_out:?}");
    assert!(fam_lines.iter().all(|l| l.contains("\"members\":4")), "members: {fams_out:?}");

    // `runs family sweep-0 rank total_pips`: the family's 4 members, highest
    // total_pips first.
    let rank = Command::new(BIN)
        .args(["runs", "family", "sweep-0", "rank", "total_pips"])
        .current_dir(&cwd)
        .output()
        .expect("spawn rank");
    assert!(rank.status.success(), "rank exit: {:?}", rank.status);
    let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
    assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}");
    assert!(rank_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "rank shape: {rank_out:?}");
    let pips: Vec<f64> = rank_out
        .lines()
        .map(|l| {
            let key = "\"total_pips\":";
            let start = l.find(key).expect("line has total_pips") + key.len();
            let tail = &l[start..];
            let end = tail.find([',', '}']).expect("token end");
            tail[..end].parse().expect("total_pips is an f64")
        })
        .collect();
    assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}");

    // an unknown metric is a usage error
    let bogus = Command::new(BIN)
        .args(["runs", "family", "sweep-0", "rank", "bogus"])
        .current_dir(&cwd)
        .output()
        .expect("spawn bogus");
    assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status);

    // an unknown family id is an empty family: zero lines, exit 0 (consistent
    // with `runs list` over an empty store).
    let unknown = Command::new(BIN)
        .args(["runs", "family", "nope-9"])
        .current_dir(&cwd)
        .output()
        .expect("spawn unknown");
    assert!(unknown.status.success(), "unknown family exit: {:?}", unknown.status);
    assert_eq!(unknown.stdout.len(), 0, "unknown family stdout: {:?}", unknown.stdout);

    let _ = std::fs::remove_dir_all(&cwd);
}

#[test]
fn runs_list_on_empty_registry_is_zero_lines_exit_zero() {
    let cwd = temp_cwd("runs-empty");
    let out = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list");
    assert!(out.status.success(), "list exit: {:?}", out.status);
    assert_eq!(out.stdout.len(), 0, "expected no output, got: {:?}", out.stdout);
    let _ = std::fs::remove_dir_all(&cwd);
}

#[test]
fn runs_bare_subcommand_is_strict_and_exits_two() {
    // `aura runs` with no/unknown sub-subcommand is a usage error (#16 strict).
    let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
    assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
}
