//! The two campaign surfaces: `campaign_sweep` (narrow per-cell fan-out — a
//! sweep-only process) and `campaign_heavy` (wide fan-out — sweep → gate →
//! walk-forward → bootstrap), both run as the release `aura` binary on a
//! scratch data-only project over synthetic archives. Fingerprint: the winner
//! ordinals extracted from the always-on final `{"campaign_run":...}` stdout
//! line. One campaign shape misreads regressions in the other (#251), so both
//! are pinned.

use super::{RepOutcome, Sizing};
use crate::child::{run_timed, TimedChild};
use crate::synth::{write_symbol_archive, ScratchDir};
use std::collections::BTreeMap;
use std::path::Path;

pub const FULL_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB", "BSYMC", "BSYMD"];
pub const QUICK_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB"];
/// Full-size span: 24 archive months with a ~22-month campaign window
/// (2024-02-01 .. 2025-11-30 UTC), sized so the measured child runs land in
/// whole seconds — sub-second walls put ordinary spawn/scheduler jitter within
/// reach of the 10% NOTICE threshold and make the report flap.
pub const ARCHIVE_MONTHS_FULL: ((i32, u32), (i32, u32)) = ((2024, 1), (2025, 12));
pub const WINDOW_MS_FULL: (i64, i64) = (1706745600000, 1764547199999);
/// Quick span: the green E2E fixture's shape (archive 2024-01..08, window
/// Mar-01..Jun-30 — ~17 weeks gives the (14d, 7d, 7d) roller a comfortable
/// tiling, research_docs.rs:2440-2441).
pub const ARCHIVE_MONTHS_QUICK: ((i32, u32), (i32, u32)) = ((2024, 1), (2024, 8));
pub const WINDOW_MS_QUICK: (i64, i64) = (1709251200000, 1719791999999);

pub const SWEEP_PROCESS_DOC: &str = r#"{
  "format_version": 1,
  "kind": "process",
  "name": "bench-sweep-only",
  "pipeline": [
    { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }
  ]
}"#;

/// The heavy shape: the E2E walk-forward process with the bootstrap sized up
/// (1000 resamples — the #277 measured workload's count).
pub const HEAVY_PROCESS_DOC: &str = r#"{
  "format_version": 1,
  "kind": "process",
  "name": "bench-heavy",
  "pipeline": [
    { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
    { "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
    { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
      "step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
    { "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 }
  ]
}"#;

pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
  "format_version": 1,
  "kind": "process",
  "name": "bench-heavy",
  "pipeline": [
    { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
    { "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
    { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
      "step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
    { "block": "std::monte_carlo", "resamples": 50, "block_len": 5 }
  ]
}"#;

/// The two SMA strategy blueprints: byte-identical to the shipped
/// `crates/aura-cli/examples/r_sma.json` except the second variant's bound
/// lengths (3/6 instead of 2/4), which gives it a distinct content id — two
/// strategies, same axes surface.
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;

fn run_in(bin: &Path, dir: &Path, args: &[&str]) -> Result<(String, Option<i32>), String> {
    let out = std::process::Command::new(bin)
        .args(args)
        .current_dir(dir)
        .output()
        .map_err(|e| format!("spawn aura {args:?}: {e}"))?;
    let text = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    Ok((text, out.status.code()))
}

/// Blueprint stems currently in the scratch store.
fn blueprint_stems(dir: &Path) -> Vec<String> {
    std::fs::read_dir(dir.join("runs").join("blueprints"))
        .map(|rd| {
            rd.filter_map(|e| e.ok())
                .filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().into_owned()))
                .collect()
        })
        .unwrap_or_default()
}

/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
/// E2E `seed_blueprint` pattern) and return its content id.
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
    let before = blueprint_stems(dir);
    let (out, code) = run_in(
        bin,
        dir,
        &[
            "sweep",
            file,
            "--axis",
            "sma_signal.fast.length=2,4",
            "--axis",
            "sma_signal.slow.length=8,16",
            "--name",
            name,
        ],
    )?;
    if code != Some(0) {
        return Err(format!("seed sweep failed ({code:?}): {out}"));
    }
    blueprint_stems(dir)
        .into_iter()
        .find(|s| !before.contains(s))
        .ok_or_else(|| "seed sweep must store a new blueprint".to_string())
}

fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
    std::fs::write(dir.join(file), doc).map_err(|e| format!("write {file}: {e}"))?;
    let (out, code) = run_in(bin, dir, &["process", "register", file])?;
    if code != Some(0) {
        return Err(format!("process register failed ({code:?}): {out}"));
    }
    out.lines()
        .find(|l| l.starts_with("registered process "))
        .map(|l| {
            l.trim_start_matches("registered process ")
                .split(' ')
                .next()
                .expect("split yields at least one piece")
                .trim_start_matches("content:")
                .to_string()
        })
        .ok_or_else(|| format!("no register line in: {out}"))
}

fn campaign_doc(instruments: &[&str], bp_ids: &[String], proc_id: &str, quick: bool) -> String {
    let instruments_json =
        instruments.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(", ");
    // 5x5 member grid at full size (the workload lever), the E2E-shaped 3x3
    // in quick mode (mechanics only).
    let (fast_vals, slow_vals) = if quick {
        ("[2, 3, 4]", "[8, 12, 16]")
    } else {
        ("[2, 3, 4, 5, 6]", "[8, 10, 12, 14, 16]")
    };
    let window = if quick { WINDOW_MS_QUICK } else { WINDOW_MS_FULL };
    let strategies_json = bp_ids
        .iter()
        .map(|id| {
            format!(
                r#"{{ "ref": {{ "content_id": "{id}" }},
       "axes": {{ "fast.length": {{ "kind": "I64", "values": {fast_vals} }},
                 "slow.length": {{ "kind": "I64", "values": {slow_vals} }} }} }}"#
            )
        })
        .collect::<Vec<_>>()
        .join(",\n    ");
    format!(
        r#"{{
  "format_version": 1,
  "kind": "campaign",
  "name": "bench",
  "data": {{ "instruments": [{instruments_json}], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
  "strategies": [ {strategies_json} ],
  "process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
  "seed": 7,
  "presentation": {{ "persist_taps": [], "emit": [] }}
}}"#,
        from = window.0,
        to = window.1,
    )
}

/// Build a fresh scratch campaign project and return (scratch, campaign doc
/// file name). Setup, unmeasured.
fn build_scratch(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<(ScratchDir, String), String> {
    let scratch = ScratchDir::new("campaign").map_err(|e| format!("scratch dir: {e}"))?;
    let root = &scratch.0;
    std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
        .map_err(|e| format!("write Aura.toml: {e}"))?;
    let data_dir = root.join("data");
    std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
    let instruments = if sizing.quick { QUICK_INSTRUMENTS } else { FULL_INSTRUMENTS };
    let months = if sizing.quick { ARCHIVE_MONTHS_QUICK } else { ARCHIVE_MONTHS_FULL };
    for sym in instruments {
        write_symbol_archive(&data_dir, sym, months.0, months.1);
    }
    std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
        .map_err(|e| format!("write blueprint a: {e}"))?;
    let mut bp_ids = vec![seed_blueprint(bin, root, "bench_sma_a.json", "seed-a")?];
    if !sizing.quick {
        std::fs::write(root.join("bench_sma_b.json"), BP_SMA_B)
            .map_err(|e| format!("write blueprint b: {e}"))?;
        bp_ids.push(seed_blueprint(bin, root, "bench_sma_b.json", "seed-b")?);
    }
    let proc_id = register_process(bin, root, "bench.process.json", process_doc)?;
    let doc = campaign_doc(instruments, &bp_ids, &proc_id, sizing.quick);
    std::fs::write(root.join("bench.campaign.json"), doc)
        .map_err(|e| format!("write campaign doc: {e}"))?;
    Ok((scratch, "bench.campaign.json".to_string()))
}

/// Extract the determinism fingerprint from the always-on final
/// `{"campaign_run":...}` stdout line: per stage the selection's winner
/// ordinal (`w`), the survivor-ordinal list (`v[..]`), and the bootstrap's
/// trade count (`b`, pooled or summed across per-survivor entries). A
/// wrong-result regression in the sweep, gate, or bootstrap machinery
/// therefore fails the bench — not just a wrong sweep winner. The
/// walk-forward stage currently emits an empty realization record
/// (no selection, no survivors, no bootstrap), so it contributes nothing
/// directly and is covered only indirectly, through the bootstrap trade
/// count that depends on its out-of-sample output (coverage gap parked on
/// the tracker, #279).
pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
    let line = stdout
        .lines()
        .find(|l| l.starts_with("{\"campaign_run\":"))
        .ok_or("no campaign_run record line on stdout")?;
    let v: serde_json::Value =
        serde_json::from_str(line).map_err(|e| format!("campaign_run line parses: {e}"))?;
    let cells = v["campaign_run"]["cells"]
        .as_array()
        .ok_or("campaign_run.cells is an array")?;
    let mut parts = Vec::new();
    for (ci, cell) in cells.iter().enumerate() {
        let stages = cell["stages"].as_array().ok_or("cell.stages is an array")?;
        for (si, stage) in stages.iter().enumerate() {
            if let Some(w) = stage["selection"]["winner_ordinal"].as_u64() {
                parts.push(format!("c{ci}s{si}w{w}"));
            }
            if let Some(surv) = stage["survivor_ordinals"].as_array() {
                let list = surv
                    .iter()
                    .filter_map(|x| x.as_u64())
                    .map(|x| x.to_string())
                    .collect::<Vec<_>>()
                    .join(".");
                parts.push(format!("c{ci}s{si}v[{list}]"));
            }
            if let Some(n) = stage["bootstrap"]["pooled_oos"]["n_trades"].as_u64() {
                parts.push(format!("c{ci}s{si}b{n}"));
            } else if let Some(per) = stage["bootstrap"]["per_survivor"].as_array() {
                let n: u64 = per.iter().filter_map(|pair| pair[1]["n_trades"].as_u64()).sum();
                parts.push(format!("c{ci}s{si}b{n}"));
            }
        }
    }
    if parts.is_empty() {
        return Err("no winner ordinals in the campaign_run record".to_string());
    }
    Ok(format!("cells={} {}", cells.len(), parts.join(" ")))
}

fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
    let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
    let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
    if timed.exit != Some(0) {
        return Err(format!(
            "campaign run exited {:?}\nstdout: {}\nstderr: {}",
            timed.exit, timed.stdout, timed.stderr
        ));
    }
    let fingerprint = winner_fingerprint(&timed.stdout)?;
    let mut metrics = BTreeMap::new();
    metrics.insert("wall_s".to_string(), timed.wall_s);
    if let Some(c) = timed.cpu_percent {
        metrics.insert("cpu_percent".to_string(), c);
    }
    if let Some(r) = timed.peak_rss_mb {
        metrics.insert("peak_rss_mb".to_string(), r);
    }
    Ok(RepOutcome { metrics, fingerprint })
}

pub fn sweep_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
    campaign_rep(bin, sizing, SWEEP_PROCESS_DOC)
}

pub fn heavy_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
    let doc = if sizing.quick { HEAVY_PROCESS_DOC_QUICK } else { HEAVY_PROCESS_DOC };
    campaign_rep(bin, sizing, doc)
}

/// Used by `cli_fixed_cost`: a minimal one-symbol scratch project.
pub fn build_minimal_project() -> Result<ScratchDir, String> {
    let scratch = ScratchDir::new("fixedcost").map_err(|e| format!("scratch dir: {e}"))?;
    let root = &scratch.0;
    std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
        .map_err(|e| format!("write Aura.toml: {e}"))?;
    let data_dir = root.join("data");
    std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
    write_symbol_archive(&data_dir, "BSYMA", (2024, 1), (2024, 1));
    std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
        .map_err(|e| format!("write blueprint: {e}"))?;
    Ok(scratch)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn winner_fingerprint_extracts_ordinals_from_a_record_line() {
        let stdout = "noise\n{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
                      \"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
                      \"stages\":[{\"block\":\"std::sweep\",\"selection\":{\"winner_ordinal\":4,\"params\":[],\
                      \"selection\":\"argmax\"}},{\"block\":\"std::gate\",\"survivor_ordinals\":[0,2]},\
                      {\"block\":\"std::monte_carlo\",\"bootstrap\":{\"pooled_oos\":{\"n_trades\":42}}}]}]}}\n";
        let fp = winner_fingerprint(stdout).expect("parses");
        assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
    }

    #[test]
    fn winner_fingerprint_sums_per_survivor_bootstrap_trades() {
        let stdout = "{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
                      \"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
                      \"stages\":[{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"per_survivor\":\
                      [[0,{\"n_trades\":10}],[3,{\"n_trades\":5}]]}}]}]}}\n";
        let fp = winner_fingerprint(stdout).expect("parses");
        assert_eq!(fp, "cells=1 c0s0b15");
    }

    #[test]
    fn winner_fingerprint_rejects_missing_record_line() {
        assert!(winner_fingerprint("no record here\n").is_err());
    }

    #[test]
    fn campaign_doc_lists_all_instruments_and_strategies() {
        for quick in [true, false] {
            let doc = campaign_doc(
                &["BSYMA", "BSYMB"],
                &["id-a".to_string(), "id-b".to_string()],
                "proc-id",
                quick,
            );
            let v: serde_json::Value = serde_json::from_str(&doc).expect("doc is valid JSON");
            assert_eq!(v["data"]["instruments"].as_array().expect("instruments").len(), 2);
            assert_eq!(v["strategies"].as_array().expect("strategies").len(), 2);
            assert_eq!(v["seed"], 7);
            assert_eq!(v["process"]["ref"]["content_id"], "proc-id");
            let grid = v["strategies"][0]["axes"]["fast.length"]["values"]
                .as_array()
                .expect("fast axis")
                .len()
                * v["strategies"][0]["axes"]["slow.length"]["values"]
                    .as_array()
                    .expect("slow axis")
                    .len();
            assert_eq!(grid, if quick { 9 } else { 25 }, "member grid, quick={quick}");
        }
    }

    /// Lockstep guard for the stdout-schema coupling: build a REAL
    /// `CampaignRunRecord` out of the aura-registry / aura-analysis /
    /// aura-engine types the CLI serializes, wrap it the way the record line
    /// is printed, and assert `winner_fingerprint` extracts every layer. A
    /// field rename in any of those crates now breaks this test (compile or
    /// assert) instead of silently eroding fingerprint coverage through the
    /// lenient JSON parse.
    #[test]
    fn winner_fingerprint_stays_in_lockstep_with_the_registry_record_types() {
        use aura_analysis::{FamilySelection, SelectionMode};
        use aura_engine::{MetricStats, RBootstrap};
        use aura_registry::{CampaignRunRecord, CellRealization, StageRealization, StageSelection};

        let stats = MetricStats { mean: 0.1, p5: 0.0, p25: 0.05, p50: 0.1, p75: 0.15, p95: 0.2 };
        let record = CampaignRunRecord {
            campaign: "camp-id".into(),
            process: "proc-id".into(),
            run: 0,
            seed: 7,
            cells: vec![CellRealization {
                strategy: "bp-id".into(),
                instrument: "BSYMA".into(),
                window_ms: (1, 2),
                stages: vec![
                    StageRealization {
                        block: "std::sweep".into(),
                        family_id: None,
                        survivor_ordinals: None,
                        selection: Some(StageSelection {
                            winner_ordinal: 4,
                            params: vec![],
                            selection: FamilySelection {
                                selection_metric: "sqn_normalized".into(),
                                n_trials: 25,
                                raw_winner_metric: 0.5,
                                mode: SelectionMode::Argmax,
                                deflated_score: None,
                                overfit_probability: None,
                                n_resamples: None,
                                block_len: None,
                                seed: None,
                                neighbourhood_score: None,
                                n_neighbours: None,
                            },
                        }),
                        bootstrap: None,
                        window_faults: vec![],
                    },
                    StageRealization {
                        block: "std::gate".into(),
                        family_id: None,
                        survivor_ordinals: Some(vec![0, 2]),
                        selection: None,
                        bootstrap: None,
                        window_faults: vec![],
                    },
                    StageRealization {
                        block: "std::monte_carlo".into(),
                        family_id: None,
                        survivor_ordinals: None,
                        selection: None,
                        bootstrap: Some(aura_registry::StageBootstrap::PooledOos(RBootstrap {
                            e_r: stats,
                            prob_le_zero: 0.3,
                            n_trades: 42,
                            block_len: 5,
                            n_resamples: 1000,
                        })),
                        window_faults: vec![],
                    },
                ],
                regime: None,
                regime_ordinal: 0,
                fault: None,
                coverage: None,
            }],
            generalizations: vec![],
            trace_name: None,
        };
        let line = format!(
            "{{\"campaign_run\":{}}}",
            serde_json::to_string(&record).expect("record serializes")
        );
        let fp = winner_fingerprint(&line).expect("the real record shape must parse");
        assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
    }

    #[test]
    fn process_docs_are_valid_json() {
        for doc in [SWEEP_PROCESS_DOC, HEAVY_PROCESS_DOC, HEAVY_PROCESS_DOC_QUICK] {
            let v: serde_json::Value = serde_json::from_str(doc).expect("process doc parses");
            assert_eq!(v["kind"], "process");
        }
    }
}
