//! Orchestration diagnostic sub-scores (MCP-Atlas).
//!
//! MCP-Atlas evaluates an agent's tool orchestration along several independent
//! axes rather than a single pass/fail. A run can reach the right capability
//! yet waste calls, or call the right tools with malformed arguments, or fail
//! once and never recover. Collapsing all of that into one number hides where
//! the agent actually went wrong. This module folds a recorded tool-call trace
//! (plus the declared equal-function classes, for the discovery axis) into five
//! deterministic sub-scores so a suite can assert on each axis on its own.
//!
//! All five sub-scores in this module are computed deterministically from the
//! recorded trace and the declared classes. None of them call a model, so
//! `judged` is always false here. If a future axis needs a judge it must set
//! `judged` true and default to a labeled "not scored offline" value rather
//! than emitting a fabricated number.
//!
//! Each sub-score is an integer percent in 0..=100 to match the existing
//! scorecard convention (see `tool_selection_f1` and the stability aggregate).
//! The exact deterministic rule for each axis:
//!
//! - discovery: did the agent reach the right capability class at all. This
//!   reuses the equal-function-set recall from `tool_selection_f1`: of the
//!   declared expected classes, the percent that at least one trace call hit.
//!   A name-free scenario (intent-only prompt) scores its discovered path here.
//!
//! - parameterization: of all tool calls in the trace, the percent whose args
//!   are a non-empty JSON object. Argument schemas are not available offline,
//!   so this measures argument *presence*, not schema validity. A call with no
//!   args object, or an empty `{}`, counts against this axis.
//!
//! - syntax: of all tool calls in the trace, the percent that are structurally
//!   well formed: a non-empty tool-name string paired with JSON object args
//!   (an object, possibly empty). A call with a blank name or non-object args
//!   is malformed and counts against this axis.
//!
//! - error_recovery: of the calls that returned an error in the trace, the
//!   percent that are followed later in the trace by a successful call to the
//!   same tool or to an equivalent tool in the same declared class. When the
//!   trace contains no errors this axis is 100 (nothing to recover from).
//!
//! - efficiency: the ratio of the optimal path length (the number of declared
//!   expected classes) to the observed call count, as a percent capped at 100.
//!   Calling exactly one tool per expected class scores 100; extra or wasted
//!   calls lower it. With no expected classes declared this axis is 0.
use crate::eval::tool_selection_f1::{score_f1, EqualFunctionSet};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// One recorded tool call in the orchestration trace envelope.
///
/// WHY carry args and the error flag: the parameterization, syntax, and
/// error-recovery axes are computed from them, so the envelope must record
/// more than just the tool name.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OrchestrationCall {
    /// Tool name as recorded in the trace.
    pub tool: String,
    /// Arguments the agent passed to the tool, as recorded JSON.
    pub args: Value,
    /// Whether this call returned an error result in the trace.
    pub is_error: bool,
}

/// The five orchestration diagnostic sub-scores for one scenario run.
///
/// Each field is an integer percent in 0..=100. `judged` records whether any
/// axis required a model; it is false because every axis here is objective.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OrchestrationDiagnostics {
    /// Reached-the-class recall over declared expected classes. See module doc.
    pub discovery: u8,
    /// Percent of calls whose args are a non-empty JSON object. See module doc.
    pub parameterization: u8,
    /// Percent of calls that are structurally well formed. See module doc.
    pub syntax: u8,
    /// Percent of errored calls later recovered by an equivalent call.
    pub error_recovery: u8,
    /// Optimal-over-observed call ratio, capped at 100. See module doc.
    pub efficiency: u8,
    /// True if any axis needed a judge. Always false for this objective set.
    pub judged: bool,
}

impl OrchestrationDiagnostics {
    /// Resolve an assertable target name to its numeric value.
    ///
    /// Mirrors `ToolSelectionF1::resolve_target` and `StabilitySummary::
    /// resolve_target` so the gate evaluator can look up the five
    /// `orchestration.*` targets uniformly without knowing this type.
    pub fn resolve_target(&self, target: &str) -> Option<f64> {
        match target {
            "orchestration.discovery" => Some(self.discovery as f64),
            "orchestration.parameterization" => Some(self.parameterization as f64),
            "orchestration.syntax" => Some(self.syntax as f64),
            "orchestration.error_recovery" => Some(self.error_recovery as f64),
            "orchestration.efficiency" => Some(self.efficiency as f64),
            _ => None,
        }
    }
}

/// True when the call's args are a JSON object with at least one entry.
fn args_present(args: &Value) -> bool {
    args.as_object().map(|m| !m.is_empty()).unwrap_or(false)
}

/// True when the call is structurally well formed: named, object args.
fn call_well_formed(call: &OrchestrationCall) -> bool {
    !call.tool.trim().is_empty() && call.args.is_object()
}

/// The class name a tool belongs to in the declared universe, if any.
fn class_label_for(tool: &str, classes: &[EqualFunctionSet]) -> Option<String> {
    classes
        .iter()
        .find(|c| c.contains(tool))
        .map(|c| c.name.clone())
}

/// Percent of calls satisfying `pred`, or 100 when there are no calls.
///
/// WHY 100 on empty: an empty trace has no malformed or under-parameterized
/// calls, so the presence-style axes are vacuously perfect; the discovery and
/// efficiency axes carry the "did nothing useful" signal instead.
fn percent_where(calls: &[OrchestrationCall], pred: impl Fn(&OrchestrationCall) -> bool) -> u8 {
    if calls.is_empty() {
        return 100;
    }
    let hits = calls.iter().filter(|c| pred(c)).count();
    ((hits as f64 / calls.len() as f64) * 100.0).round() as u8
}

/// Compute the error-recovery axis from the trace and declared classes.
///
/// For each errored call, scan the rest of the trace for a later successful
/// call to the same tool or to a tool in the same declared class. Returns the
/// percent of errored calls so recovered, or 100 when there were no errors.
fn error_recovery_score(calls: &[OrchestrationCall], classes: &[EqualFunctionSet]) -> u8 {
    let errored: Vec<usize> = calls
        .iter()
        .enumerate()
        .filter(|(_, c)| c.is_error)
        .map(|(i, _)| i)
        .collect();
    if errored.is_empty() {
        return 100;
    }
    let recovered = errored
        .iter()
        .filter(|&&i| recovered_after(calls, i, classes))
        .count();
    ((recovered as f64 / errored.len() as f64) * 100.0).round() as u8
}

/// True when index `i` (an errored call) is followed by an equivalent success.
fn recovered_after(calls: &[OrchestrationCall], i: usize, classes: &[EqualFunctionSet]) -> bool {
    let failed_tool = &calls[i].tool;
    let failed_class = class_label_for(failed_tool, classes);
    calls.iter().skip(i + 1).any(|later| {
        if later.is_error {
            return false;
        }
        if &later.tool == failed_tool {
            return true;
        }
        match (&failed_class, class_label_for(&later.tool, classes)) {
            (Some(a), Some(b)) => a == &b,
            _ => false,
        }
    })
}

/// Compute the efficiency axis: optimal-over-observed call ratio, capped at 100.
///
/// The optimal path length is the number of declared expected classes (one
/// call per class). With no expected classes declared the axis is 0 because no
/// optimal path is defined. An empty trace also scores 0 (nothing observed).
fn efficiency_score(call_count: usize, expected: &[EqualFunctionSet]) -> u8 {
    let optimal = expected.len();
    if optimal == 0 || call_count == 0 {
        return 0;
    }
    let ratio = (optimal as f64 / call_count as f64) * 100.0;
    ratio.round().min(100.0) as u8
}

/// Fold a recorded trace and the declared classes into the five sub-scores.
///
/// `trace` is the recorded sequence of tool calls. `expected` is the classes
/// the scenario expected the agent to reach (the optimal path). `universe` is
/// every class known to the scenario, used to map calls to classes for the
/// discovery and error-recovery axes. No model is consulted.
pub fn fold_trace(
    trace: &[OrchestrationCall],
    expected: &[EqualFunctionSet],
    universe: &[EqualFunctionSet],
) -> OrchestrationDiagnostics {
    let selected: Vec<String> = trace.iter().map(|c| c.tool.clone()).collect();
    // The discovery axis reuses MSC-Bench equal-function-set recall: of the
    // expected classes, the percent that at least one trace call reached.
    // `universe` (every known class) maps calls to classes for error-recovery.
    let f1 = score_f1(expected, &selected);
    OrchestrationDiagnostics {
        discovery: clamp_percent(f1.recall_percent),
        parameterization: percent_where(trace, |c| args_present(&c.args)),
        syntax: percent_where(trace, call_well_formed),
        error_recovery: error_recovery_score(trace, universe),
        efficiency: efficiency_score(trace.len(), expected),
        judged: false,
    }
}

/// Clamp an integer-percent `u32` from the F1 scorer into the `u8` range.
///
/// The F1 scorer guarantees `0..=100`, but the diagnostics use `u8` for the
/// 0..=100 convention, so this narrows without risking an overflow panic.
fn clamp_percent(value: u32) -> u8 {
    value.min(100) as u8
}

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

    fn search_class() -> EqualFunctionSet {
        EqualFunctionSet {
            name: "search".to_string(),
            members: vec!["search".to_string(), "web_search".to_string()],
        }
    }

    fn fetch_class() -> EqualFunctionSet {
        EqualFunctionSet {
            name: "fetch".to_string(),
            members: vec!["fetch".to_string(), "get".to_string()],
        }
    }

    fn call(tool: &str, args: Value, is_error: bool) -> OrchestrationCall {
        OrchestrationCall {
            tool: tool.to_string(),
            args,
            is_error,
        }
    }

    #[test]
    fn discovery_is_full_recall_when_all_classes_reached() {
        let universe = vec![search_class(), fetch_class()];
        let trace = vec![
            call("web_search", json!({"q": "x"}), false),
            call("get", json!({"id": 1}), false),
        ];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.discovery, 100);
    }

    #[test]
    fn discovery_drops_when_a_class_is_missed() {
        let universe = vec![search_class(), fetch_class()];
        let trace = vec![call("search", json!({"q": "x"}), false)];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.discovery, 50);
    }

    #[test]
    fn parameterization_counts_non_empty_args_objects() {
        let universe = vec![search_class()];
        let trace = vec![
            call("search", json!({"q": "x"}), false),
            call("search", json!({}), false),
        ];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.parameterization, 50);
    }

    #[test]
    fn syntax_flags_blank_name_and_non_object_args() {
        let universe = vec![search_class()];
        let trace = vec![
            call("search", json!({"q": "x"}), false),
            call("", json!({"q": "x"}), false),
            call("search", json!("not-an-object"), false),
        ];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.syntax, 33);
    }

    #[test]
    fn error_recovery_is_100_with_no_errors() {
        let universe = vec![search_class()];
        let trace = vec![call("search", json!({"q": "x"}), false)];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.error_recovery, 100);
    }

    #[test]
    fn error_recovery_credits_later_equivalent_success() {
        let universe = vec![search_class()];
        let trace = vec![
            call("search", json!({"q": "x"}), true),
            call("web_search", json!({"q": "x"}), false),
        ];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.error_recovery, 100);
    }

    #[test]
    fn error_recovery_zero_when_failure_never_recovered() {
        let universe = vec![search_class()];
        let trace = vec![call("search", json!({"q": "x"}), true)];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.error_recovery, 0);
    }

    #[test]
    fn efficiency_is_100_for_one_call_per_expected_class() {
        let universe = vec![search_class(), fetch_class()];
        let trace = vec![
            call("search", json!({"q": "x"}), false),
            call("fetch", json!({"id": 1}), false),
        ];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.efficiency, 100);
    }

    #[test]
    fn efficiency_drops_with_wasted_calls() {
        let universe = vec![search_class()];
        let trace = vec![
            call("search", json!({"q": "x"}), false),
            call("search", json!({"q": "y"}), false),
        ];
        let d = fold_trace(&trace, &universe, &universe);
        assert_eq!(d.efficiency, 50);
    }

    #[test]
    fn resolve_target_exposes_each_axis() {
        let d = OrchestrationDiagnostics {
            discovery: 100,
            parameterization: 90,
            syntax: 80,
            error_recovery: 70,
            efficiency: 60,
            judged: false,
        };
        assert_eq!(d.resolve_target("orchestration.discovery"), Some(100.0));
        assert_eq!(
            d.resolve_target("orchestration.parameterization"),
            Some(90.0)
        );
        assert_eq!(d.resolve_target("orchestration.syntax"), Some(80.0));
        assert_eq!(d.resolve_target("orchestration.error_recovery"), Some(70.0));
        assert_eq!(d.resolve_target("orchestration.efficiency"), Some(60.0));
        assert_eq!(d.resolve_target("orchestration.unknown"), None);
    }
}
