//! Cross-cutting end-to-end test for the composition surface.
//!
//! Wires every composition feature together against the suite
//! loader + executor and asserts the resolved trace shape so a
//! regression in any single layer (validator, data flow, mock
//! short-circuit, fan-out, output assembly, budget, eval bridge)
//! shows up here as a single failed assertion.
//!
//! The suite is mocked end-to-end so the test stays offline.

use std::sync::Arc;

use mcptest_core::composition::{self, ResolvedComposition};
use mcptest_core::connector::ServerPool;
use mcptest_core::suite::parse;
use serde_yaml::Value;

#[tokio::test]
async fn composition_full_surface_runs_end_to_end_against_mocks() {
    let yaml = include_str!("../../../examples/composition-full.yml");
    let value: Value = serde_yaml::from_str(yaml).expect("YAML parses");
    let suite = parse(&value).expect("suite loads through validator");

    assert_eq!(suite.compositions.len(), 1, "one composition declared");
    let composition: &ResolvedComposition = &suite.compositions[0];
    assert_eq!(composition.name, "render-readme-with-auth-check");
    assert_eq!(
        composition.topo_order,
        vec!["search", "auth_check", "enrich"]
    );

    // Suite-side wiring: budget + eval rode through to the resolved
    // composition.
    let budget = composition.budget.as_ref().expect("budget block parsed");
    assert!((budget.max_cost_usd.unwrap() - 0.50).abs() < 1e-9);
    assert_eq!(budget.max_tokens, Some(20000));
    assert_eq!(budget.max_duration_ms, Some(60_000));
    assert_eq!(composition.eval.len(), 2, "two eval metrics");
    assert_eq!(composition.cases.len(), 2, "two cases declared");

    // Run the first case end-to-end against an empty server pool.
    // Every node carries a mock fixture so the run does not need a
    // transport. The runner threads the case vars through
    // substitute_with so `${var.query}` resolves to the row value.
    let pool = Arc::new(ServerPool::new());
    let case_traces = composition::run_cases(composition, pool.as_ref(), None).await;
    assert_eq!(case_traces.len(), 2);
    let first = &case_traces[0];
    assert_eq!(first.name, "common-query");
    let trace = &first.trace;

    // Each node ran successfully (mocks short-circuit before any
    // transport would be touched, so the absence of a connected
    // server pool is fine).
    assert!(trace.ran);
    assert_eq!(trace.order, vec!["search", "auth_check", "enrich"]);
    let search = trace.nodes.get("search").expect("search recorded");
    assert_eq!(
        search.status,
        mcptest_core::composition::NodeStatus::Ok,
        "search ok"
    );
    // The per-node transform.jq reduced the mocked result to the
    // hits array so downstream fan-out can iterate it directly.
    let search_output = &search.output;
    assert!(search_output.is_array(), "search output is the hits array");
    assert_eq!(search_output.as_array().map(Vec::len), Some(3));

    let auth = trace.nodes.get("auth_check").expect("auth recorded");
    assert_eq!(
        auth.status,
        mcptest_core::composition::NodeStatus::Ok,
        "auth_check ok (when predicate passed)"
    );

    let enrich = trace.nodes.get("enrich").expect("enrich recorded");
    assert_eq!(
        enrich.status,
        mcptest_core::composition::NodeStatus::Ok,
        "enrich ok"
    );
    assert_eq!(enrich.output["description"], "Sample description");

    // Output assembly fused the three node outputs into one shape
    // via the output.assemble jq program.
    let combined = &trace.output;
    assert_eq!(combined["authorized"], true);
    assert_eq!(combined["packages"], 3);
    assert!(combined["top_hit"].is_object());
    assert_eq!(combined["metadata"]["description"], "Sample description");

    // Budget did not trip on a mocked run (no cost attribution).
    assert_eq!(trace.cost_total, 0.0);
    assert_eq!(trace.tokens_total, 0);
    assert!(trace.budget_tripped.is_none());

    // The second case binds an empty query; the same DAG still
    // runs deterministically against its mocks.
    let second = &case_traces[1];
    assert_eq!(second.name, "empty-query");
    assert!(second.trace.ran);
    // The case carried a golden value; it rides on the CaseTrace
    // unchanged so the reporter can compare against it.
    assert!(second.golden.is_some());
}
