//! Evaluate a composition's deterministic `expect:` assertions against the
//! trace the executor produced.
//!
//! The composition executor records a [`CompositionTrace`] but does not judge
//! it. This module bridges the trace to the same matcher machinery the suite
//! runner uses, so `mcptest run` can gate on a composition's `expect:` block.
//! The judged `eval:` metrics need a model provider and are handled by the
//! caller; this module is offline and deterministic.

use serde_json::{json, Map, Value};

use crate::composition::CompositionTrace;
use crate::executor::{check_matcher, resolve_target};
use crate::suite::parse_matcher;

/// Result of evaluating one composition run (or one `cases:` row).
#[derive(Debug, Clone)]
pub struct CompositionOutcome {
    /// Reporter row name (composition name, plus the case name when present).
    pub name: String,
    /// True when every deterministic assertion passed.
    pub passed: bool,
    /// One human-readable line per failed assertion (target plus reason).
    pub failures: Vec<String>,
}

/// Build the JSON envelope the documented `expect:` targets address:
/// `composition.ran`, `output.<path>`, `nodes.<id>.status`,
/// `nodes.<id>.output`, and `cost.total`.
fn envelope(trace: &CompositionTrace) -> Value {
    let nodes: Map<String, Value> = trace
        .nodes
        .iter()
        .map(|(id, n)| {
            (
                id.clone(),
                json!({
                    "status": serde_json::to_value(n.status).unwrap_or(Value::Null),
                    "output": n.output.clone(),
                }),
            )
        })
        .collect();
    json!({
        "composition": {"ran": trace.ran},
        "output": trace.output.clone(),
        "nodes": Value::Object(nodes),
        "cost": {"total": trace.cost_total},
    })
}

/// Evaluate every `expect:` entry (a raw `{target, matcher}` object) against
/// the trace envelope and fold the results into one row outcome. `expect`
/// entries are the verbatim YAML the loader stored without typing, so the
/// matcher is parsed here through the suite's `parse_matcher`.
pub fn evaluate_expect(
    name: String,
    trace: &CompositionTrace,
    expect: &[Value],
) -> CompositionOutcome {
    let env = envelope(trace);
    let mut failures = Vec::new();
    for (i, entry) in expect.iter().enumerate() {
        if let Err(reason) = evaluate_one(&env, entry) {
            failures.push(format!("expect #{i}: {reason}"));
        }
    }
    CompositionOutcome {
        name,
        passed: failures.is_empty(),
        failures,
    }
}

fn evaluate_one(env: &Value, entry: &Value) -> Result<(), String> {
    let target = entry
        .get("target")
        .and_then(Value::as_str)
        .ok_or_else(|| "expect entry has no string `target`".to_string())?;
    let matcher_json = entry
        .get("matcher")
        .ok_or_else(|| format!("expect `{target}` has no `matcher`"))?;
    let matcher_yaml = serde_yaml::to_value(matcher_json)
        .map_err(|e| format!("expect `{target}` matcher is not serializable: {e}"))?;
    let matcher = parse_matcher(&matcher_yaml, target)
        .map_err(|e| format!("expect `{target}` has an invalid matcher: {e}"))?;
    let actual = resolve_target(env, target);
    check_matcher(&actual, &matcher).map_err(|reason| format!("`{target}` {reason}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::composition::{NodeStatus, NodeTrace};
    use std::collections::BTreeMap;

    fn trace_with(ran: bool, status: NodeStatus, output: Value) -> CompositionTrace {
        let mut nodes = BTreeMap::new();
        nodes.insert(
            "search".to_string(),
            NodeTrace {
                id: "search".to_string(),
                status,
                output: Value::Null,
                ..Default::default()
            },
        );
        CompositionTrace {
            name: "demo".to_string(),
            order: vec!["search".to_string()],
            ran,
            nodes,
            output,
            ..Default::default()
        }
    }

    fn expect(target: &str, matcher: Value) -> Value {
        json!({"target": target, "matcher": matcher})
    }

    #[test]
    fn passing_expectations_produce_a_passed_outcome() {
        let trace = trace_with(true, NodeStatus::Ok, json!({"packages": 3}));
        let expects = vec![
            expect("composition.ran", json!({"exact": true})),
            expect("output.packages", json!({"exact": 3})),
            expect("nodes.search.status", json!({"exact": "ok"})),
        ];
        let outcome = evaluate_expect("demo".into(), &trace, &expects);
        assert!(outcome.passed, "failures: {:?}", outcome.failures);
    }

    #[test]
    fn a_wrong_expectation_fails_the_outcome_with_a_reason() {
        let trace = trace_with(true, NodeStatus::Ok, json!({"packages": 3}));
        let expects = vec![expect("output.packages", json!({"exact": 99}))];
        let outcome = evaluate_expect("demo".into(), &trace, &expects);
        assert!(!outcome.passed);
        assert!(outcome.failures[0].contains("output.packages"));
    }

    #[test]
    fn ran_false_fails_a_ran_true_expectation() {
        let trace = trace_with(false, NodeStatus::Err, Value::Null);
        let expects = vec![expect("composition.ran", json!({"exact": true}))];
        let outcome = evaluate_expect("demo".into(), &trace, &expects);
        assert!(!outcome.passed);
    }
}
