//! Integration coverage for the objective multi-server tool-selection F1
//! aggregate (MSC-Bench equal-function sets).
//!
//! Drives recorded multi-run trace envelopes through `aggregate_f1` and asserts
//! the per-class breakdown, the folded `tool_selection.*` targets, and the
//! floor-gate behavior, all offline with no provider in the loop. The traces
//! are plain JSON envelopes of the same shape `ConversationTrace::to_envelope`
//! produces, so this exercises the real scoring path without a live model.

use mcptest_core::eval::tool_selection_f1::{
    aggregate_f1, default_expect, metrics_object, EqualFunctionSet,
};
use mcptest_core::executor::{eval_metric_assertion, resolve_target};
use serde_json::json;

/// Two interchangeable search tools across two servers, plus a single-member
/// fetch class. Mirrors a realistic multi-server scenario.
fn classes() -> Vec<EqualFunctionSet> {
    vec![
        EqualFunctionSet::new(
            "search",
            ["brave.web_search".to_string(), "google.search".to_string()],
        ),
        EqualFunctionSet::new("fetch", ["http.get".to_string()]),
    ]
}

#[test]
fn recorded_trace_scores_a_perfect_run_and_passes_the_default_floor() {
    // One run that picks one member of each class: every class matched once.
    let envelopes = vec![json!({
        "tool_calls": [
            { "name": "search", "server": "google" },
            { "name": "get", "server": "http" }
        ],
        "conversation": { "tokens": { "total": 1000 } }
    })];
    let aggregate = aggregate_f1(&classes(), &envelopes);
    let metrics = metrics_object(&aggregate);

    assert_eq!(resolve_target(&metrics, "tool_selection.f1"), json!(100));
    assert_eq!(
        resolve_target(&metrics, "tool_selection.precision"),
        json!(100)
    );
    assert_eq!(
        resolve_target(&metrics, "tool_selection.recall"),
        json!(100)
    );

    // The default floor (tool_selection.f1 >= 50) passes on a perfect run.
    for assertion in default_expect() {
        assert!(eval_metric_assertion(&metrics, &assertion).is_ok());
    }
    assert!(aggregate.missed_classes().is_empty());
    assert!(aggregate.unexpected_tools().is_empty());
}

#[test]
fn recorded_trace_below_floor_fails_with_a_per_class_breakdown() {
    // One run matches the search class but never calls the fetch tool and picks
    // two wrong extra tools. With one true positive, two false positives, and
    // one false negative the precision is 33 and recall is 50, so the F1 lands
    // at 39, below the default floor. Fetch is the only class missed.
    let envelopes = vec![json!({
        "tool_calls": [
            { "name": "search", "server": "google" },
            { "name": "exec", "server": "shell" },
            { "name": "rm", "server": "shell" }
        ]
    })];
    let aggregate = aggregate_f1(&classes(), &envelopes);

    // The breakdown names the missed class and the unexpected tools so the
    // failure is actionable without any model in the loop.
    assert_eq!(aggregate.missed_classes(), vec!["fetch".to_string()]);
    assert_eq!(
        aggregate.unexpected_tools(),
        vec!["shell.exec".to_string(), "shell.rm".to_string()]
    );

    // The default floor fails deterministically below the threshold.
    let metrics = metrics_object(&aggregate);
    let failed = default_expect()
        .iter()
        .any(|assertion| eval_metric_assertion(&metrics, assertion).is_err());
    assert!(failed, "f1 below the floor must fail the gate");
}

#[test]
fn an_explicit_recall_floor_gates_the_recall_target() {
    // One run that matches search but misses fetch: recall is 50, so a
    // recall >= 80 floor must fail while an f1 >= 50 floor would pass.
    let envelopes = vec![json!({
        "tool_calls": [{ "name": "search", "server": "google" }]
    })];
    let aggregate = aggregate_f1(&classes(), &envelopes);
    let metrics = metrics_object(&aggregate);

    assert_eq!(resolve_target(&metrics, "tool_selection.recall"), json!(50));

    let recall_floor = mcptest_core::suite::Assertion {
        target: "tool_selection.recall".to_string(),
        matcher: mcptest_core::suite::Matcher::Schema(json!({ "minimum": 80 })),
        weight: 1.0,
        name: None,
        transform: None,
    };
    assert!(eval_metric_assertion(&metrics, &recall_floor).is_err());
}

#[test]
fn aggregate_is_byte_stable_regardless_of_run_order() {
    let run_a = json!({ "tool_calls": [{ "name": "search", "server": "google" }] });
    let run_b = json!({ "tool_calls": [{ "name": "get", "server": "http" }] });
    let forward = aggregate_f1(&classes(), &[run_a.clone(), run_b.clone()]);
    let reversed = aggregate_f1(&classes(), &[run_b, run_a]);
    // Micro-averaged sums are commutative, so run order never moves the score.
    assert_eq!(forward.f1_percent, reversed.f1_percent);
    assert_eq!(forward.precision_percent, reversed.precision_percent);
    assert_eq!(forward.recall_percent, reversed.recall_percent);
}
