//! #282: a hand-authored blueprint with a declared tap, run on the single-run
//! path, records the tapped series; the same blueprint in a sweep does not
//! (`run_blueprint_member`, the sweep/reduce path, never calls `bind_tap` —
//! structurally guaranteed by Task 6 leaving it untouched, not re-asserted
//! here).

use std::path::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 this process test (so the run's
/// `./runs/traces/` never dirties the repo). Unique per test + per process.
fn temp_cwd(name: &str) -> std::path::PathBuf {
    let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-tap-{name}"));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create temp cwd");
    dir
}

/// The exact synthetic price array `r_sma_prices()` (main.rs) feeds the
/// `RunData::Synthetic` path — duplicated here (not re-exported; a private
/// CLI helper) so the tapped SMA(2) column can be pinned against an
/// independently-derived expectation, not a blind capture.
const R_SMA_PRICES: [f64; 18] = [
    1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
    1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
];

/// The shipped `examples/r_sma.json` blueprint, with one additional declared
/// tap on node 0 ("fast", `SMA(length=2)`) field 0 — the smallest addition
/// that names an interior producer output without touching the existing
/// topology (nodes/edges/input_roles/output all verbatim).
fn tap_blueprint_json() -> String {
    let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
    let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
    let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
    v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
    serde_json::to_string(&v).expect("re-serialize tapped blueprint")
}

/// Property: on the single-run path (`aura run <blueprint.json>` over the
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
/// per-cycle series is persisted through the trace store — the persisted
/// `fast_tap.json` carries exactly the SMA(2) values over the known synthetic
/// price stream (silent until warm at sample 2, one value per cycle after).
#[test]
fn single_run_persists_a_declared_tap_series() {
    let cwd = temp_cwd("single-run-persists");
    let bp_path = cwd.join("tapped_r_sma.json");
    std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");

    let out = Command::new(BIN)
        .args(["run", bp_path.to_str().expect("utf-8 path")])
        .current_dir(&cwd)
        .output()
        .expect("spawn aura run");
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));

    let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
    let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
        panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
    });
    let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");

    assert_eq!(trace["tap"], "fast_tap");
    assert_eq!(trace["kinds"], serde_json::json!(["F64"]));

    // SMA(2), silent until warm (2 samples): first emission at the 2nd sample
    // (ts=2), value = mean(prices[0], prices[1]); then a rolling mean of the
    // last two prices per subsequent cycle (ts = i+1 for prices[i]).
    let expected: Vec<f64> = (1..R_SMA_PRICES.len())
        .map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
        .collect();
    let expected_ts: Vec<i64> = (2..=R_SMA_PRICES.len() as i64).collect();

    let got_ts: Vec<i64> = trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
    assert_eq!(got_ts, expected_ts, "recorded timestamps");

    let got: Vec<f64> = trace["columns"][0]
        .as_array()
        .expect("columns[0] array")
        .iter()
        .map(|v| v.as_f64().unwrap())
        .collect();
    assert_eq!(got.len(), expected.len(), "recorded row count");
    for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
        assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
    }
}

/// A tap-free blueprint run (the existing `run_prints_json_and_exits_zero`
/// shape) leaves no `runs/` directory at all — the tap machinery is fully
/// inert (no binds, no trace_store write) when a blueprint declares no taps,
/// matching Task 6's byte-identical guarantee.
#[test]
fn tap_free_run_writes_no_trace_store() {
    let cwd = temp_cwd("tap-free-writes-nothing");
    let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
    let out = Command::new(BIN)
        .args(["run", &bp])
        .current_dir(&cwd)
        .output()
        .expect("spawn aura run");
    assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
    assert!(!cwd.join("runs").exists(), "a tap-free run must not create a trace store");
}

/// The shipped `examples/r_sma.json` blueprint with TWO declared taps that
/// share the same name (`"dup"`), on two distinct producers (node 0 "fast" and
/// node 1 "slow") — the shape `run_signal_r`'s `seen: BTreeSet` dedup guard
/// exists for (`FlatGraph::bind_tap` itself keeps no cross-call state per its
/// own doc comment, so nothing upstream of the CLI catches this).
fn duplicate_tap_blueprint_json() -> String {
    let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
    let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
    let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
    v["blueprint"]["taps"] = serde_json::json!([
        {"name": "dup", "from": {"node": 0, "field": 0}},
        {"name": "dup", "from": {"node": 1, "field": 0}},
    ]);
    serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
}

/// Property: **a blueprint declaring two taps under the same name is refused
/// on the single-run path with a named error and exit code 1, before any
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
/// guard (bind_tap itself binds a single tap and keeps no cross-call state,
/// per its own doc comment) actually fires end-to-end, and the partial first
/// bind never reaches disk as a half-written trace store.
#[test]
fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
    let cwd = temp_cwd("duplicate-tap-name-refused");
    let bp_path = cwd.join("dup_tap_r_sma.json");
    std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");

    let out = Command::new(BIN)
        .args(["run", bp_path.to_str().expect("utf-8 path")])
        .current_dir(&cwd)
        .output()
        .expect("spawn aura run");

    assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("dup") && stderr.contains("more than once"),
        "stderr must name the offending tap and the duplicate-bind fault: {stderr}"
    );
    assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
}
