//! Golden-path scoring: efficiency penalties over a recorded trajectory.
//!
//! Where [`super::validate_trace`] answers "did the right calls happen?",
//! the golden path answers "did they happen efficiently?". It compares the
//! recorded tool sequence against an ideal ("golden") sequence and counts
//! wasted work, then folds the counts into a single penalty multiplier.
//! This is the path-efficiency half of the  trajectory vocabulary.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// An ideal call sequence plus the policy for what counts as wasted work.
///
/// The `calls` are the tool names of the ideal path, in order. The three
/// flags choose which inefficiencies to penalize so a suite author can,
/// for example, tolerate extra exploratory steps while still penalizing a
/// model that keeps hammering the same tool.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GoldenPath {
    /// Tool names of the ideal path, in order. Compared by name only;
    /// argument shapes are out of scope for path efficiency (use
    /// [`super::validate_trace`] for argument assertions).
    pub calls: Vec<String>,
    /// When true, recorded calls beyond the golden path length are
    /// tolerated: `extra_steps` is still reported but contributes nothing
    /// to the penalty. When false, each extra step is penalized.
    pub allow_extra_steps: bool,
    /// When true, returning to a tool after the trajectory had moved on to
    /// a different tool counts toward the penalty.
    pub penalize_backtracking: bool,
    /// When true, consecutive duplicate tool calls count toward the
    /// penalty.
    pub penalize_repeated_tools: bool,
}

impl GoldenPath {
    /// Build a golden path from tool names with every penalty enabled and
    /// no tolerance for extra steps (the strictest efficiency policy).
    pub fn new(calls: impl IntoIterator<Item = impl Into<String>>) -> Self {
        Self {
            calls: calls.into_iter().map(Into::into).collect(),
            allow_extra_steps: false,
            penalize_backtracking: true,
            penalize_repeated_tools: true,
        }
    }
}

/// The efficiency verdict for one trajectory against a [`GoldenPath`].
///
/// `passed` is true when the trajectory incurred no penalized waste
/// (`penalty == 1.0`). The three counts are always reported, even for
/// dimensions whose penalty is disabled, so a reporter can show the full
/// picture; only enabled dimensions move `penalty`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PathScore {
    /// True when no penalized waste was found, i.e. `penalty == 1.0`.
    pub passed: bool,
    /// Recorded calls beyond the golden path's length (zero when the trace
    /// is no longer than the path).
    pub extra_steps: usize,
    /// Returns to a tool after the trajectory had moved on to a different
    /// tool.
    pub backtracks: usize,
    /// Consecutive duplicate tool calls.
    pub repeated_tools: usize,
    /// Aggregate efficiency multiplier in `0.0..=1.0`, where `1.0` means no
    /// penalized waste and smaller values mean more waste. See
    /// [`score_golden_path`] for the formula.
    pub penalty: f64,
}

/// Per-unit weight applied to each penalized waste event before the
/// aggregate is folded into the `0.0..=1.0` multiplier.
const WASTE_WEIGHT: f64 = 0.5;

/// Score a recorded trajectory against a [`GoldenPath`] for efficiency.
///
/// `actual_tool_calls` is the recorded `tool_calls` slice (the same shape
/// [`super::validate_trace`] takes); only each call's `name` is read. The
/// function is pure and never performs I/O.
///
/// It counts three kinds of waste:
///
/// - `extra_steps`: `actual.len().saturating_sub(golden.calls.len())`.
/// - `backtracks`: a call to a tool that was used earlier but is not the
///   immediately preceding tool (the trajectory had moved on and came
///   back). Consecutive repeats are not backtracks.
/// - `repeated_tools`: a call whose tool name equals the immediately
///   preceding call's name.
///
/// Penalty formula: let `w` be the sum of the *enabled* waste counts
/// (`extra_steps` only when `allow_extra_steps` is false, `backtracks`
/// only when `penalize_backtracking`, `repeated_tools` only when
/// `penalize_repeated_tools`). Then
///
/// ```text
/// penalty = 1.0 / (1.0 + WASTE_WEIGHT * w)
/// ```
///
/// with `WASTE_WEIGHT = 0.5`. The result is `1.0` exactly when `w == 0`
/// and decreases monotonically toward `0.0` as waste grows, so it always
/// lands in `(0.0, 1.0]`. `passed` is `w == 0`.
pub fn score_golden_path(actual_tool_calls: &[Value], golden: &GoldenPath) -> PathScore {
    let names: Vec<&str> = actual_tool_calls
        .iter()
        .map(|c| c.get("name").and_then(Value::as_str).unwrap_or(""))
        .collect();

    let extra_steps = names.len().saturating_sub(golden.calls.len());

    let mut backtracks = 0usize;
    let mut repeated_tools = 0usize;
    let mut seen: Vec<&str> = Vec::new();
    for (i, &name) in names.iter().enumerate() {
        if i > 0 && names[i - 1] == name {
            // Immediate repeat: a repeated tool, not a backtrack.
            repeated_tools += 1;
        } else if seen.contains(&name) {
            // Seen before but not immediately prior: the trajectory moved
            // on and returned, a backtrack.
            backtracks += 1;
        }
        if !seen.contains(&name) {
            seen.push(name);
        }
    }

    let mut weighted = 0.0f64;
    if !golden.allow_extra_steps {
        weighted += extra_steps as f64;
    }
    if golden.penalize_backtracking {
        weighted += backtracks as f64;
    }
    if golden.penalize_repeated_tools {
        weighted += repeated_tools as f64;
    }

    let penalty = 1.0 / (1.0 + WASTE_WEIGHT * weighted);
    PathScore {
        passed: weighted == 0.0,
        extra_steps,
        backtracks,
        repeated_tools,
        penalty,
    }
}

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

    fn call(name: &str) -> Value {
        json!({"name": name, "server": "s", "args": {}})
    }

    fn trace(names: &[&str]) -> Vec<Value> {
        names.iter().map(|n| call(n)).collect()
    }

    #[test]
    fn clean_path_scores_a_perfect_one() {
        let actual = trace(&["search", "open", "summarize"]);
        let golden = GoldenPath::new(["search", "open", "summarize"]);
        let score = score_golden_path(&actual, &golden);
        assert!(score.passed);
        assert_eq!(score.extra_steps, 0);
        assert_eq!(score.backtracks, 0);
        assert_eq!(score.repeated_tools, 0);
        assert_eq!(score.penalty, 1.0);
    }

    #[test]
    fn backtracking_trace_scores_lower_than_clean_trace() {
        // search, open, search again: the second search is a backtrack.
        let backtracking = trace(&["search", "open", "search"]);
        let clean = trace(&["search", "open", "summarize"]);
        let golden = GoldenPath {
            calls: vec!["search".into(), "open".into(), "summarize".into()],
            allow_extra_steps: true,
            penalize_backtracking: true,
            penalize_repeated_tools: true,
        };
        let bt = score_golden_path(&backtracking, &golden);
        let cl = score_golden_path(&clean, &golden);
        assert_eq!(bt.backtracks, 1);
        assert!(!bt.passed);
        assert!(cl.passed);
        assert!(
            bt.penalty < cl.penalty,
            "backtracking {} should score below clean {}",
            bt.penalty,
            cl.penalty
        );
    }

    #[test]
    fn consecutive_duplicate_is_a_repeated_tool_not_a_backtrack() {
        let actual = trace(&["search", "search", "open"]);
        let golden = GoldenPath::new(["search", "open"]);
        let score = score_golden_path(&actual, &golden);
        assert_eq!(score.repeated_tools, 1);
        assert_eq!(score.backtracks, 0);
        assert!(!score.passed);
    }

    #[test]
    fn repeated_tool_penalty_disabled_when_flag_off() {
        let actual = trace(&["search", "search"]);
        let golden = GoldenPath {
            calls: vec!["search".into(), "search".into()],
            allow_extra_steps: true,
            penalize_backtracking: true,
            penalize_repeated_tools: false,
        };
        let score = score_golden_path(&actual, &golden);
        // The repeat is still reported, but does not move the penalty.
        assert_eq!(score.repeated_tools, 1);
        assert!(score.passed);
        assert_eq!(score.penalty, 1.0);
    }

    #[test]
    fn allow_extra_steps_tolerates_extras() {
        let actual = trace(&["search", "open", "log", "audit"]);
        let lenient = GoldenPath {
            calls: vec!["search".into(), "open".into()],
            allow_extra_steps: true,
            penalize_backtracking: false,
            penalize_repeated_tools: false,
        };
        let strict = GoldenPath {
            calls: vec!["search".into(), "open".into()],
            allow_extra_steps: false,
            penalize_backtracking: false,
            penalize_repeated_tools: false,
        };
        let lenient_score = score_golden_path(&actual, &lenient);
        let strict_score = score_golden_path(&actual, &strict);
        // Both report two extra steps, but only the strict policy penalizes.
        assert_eq!(lenient_score.extra_steps, 2);
        assert_eq!(strict_score.extra_steps, 2);
        assert!(lenient_score.passed);
        assert!(!strict_score.passed);
        assert!(strict_score.penalty < lenient_score.penalty);
    }

    #[test]
    fn penalty_stays_within_zero_to_one_and_decreases_with_waste() {
        // A pathological trace: many backtracks and repeats.
        let actual = trace(&["a", "b", "a", "b", "a", "a", "b", "b"]);
        let golden = GoldenPath::new(["a", "b"]);
        let score = score_golden_path(&actual, &golden);
        assert!(score.penalty > 0.0 && score.penalty < 1.0);
        assert!(!score.passed);
    }

    #[test]
    fn empty_trace_against_empty_path_is_perfect() {
        let actual: Vec<Value> = Vec::new();
        let golden = GoldenPath::new(Vec::<String>::new());
        let score = score_golden_path(&actual, &golden);
        assert!(score.passed);
        assert_eq!(score.penalty, 1.0);
    }

    #[test]
    fn three_way_backtrack_counts_each_return() {
        // a, b, a, c, a: two returns to `a` after moving on.
        let actual = trace(&["a", "b", "a", "c", "a"]);
        let golden = GoldenPath {
            calls: vec!["a".into(), "b".into(), "c".into()],
            allow_extra_steps: true,
            penalize_backtracking: true,
            penalize_repeated_tools: true,
        };
        let score = score_golden_path(&actual, &golden);
        assert_eq!(score.backtracks, 2);
        assert_eq!(score.repeated_tools, 0);
    }
}
