//! Shared YAML parser for the `rubric:` block.
//!
//! Why this lives in `eval` and not in the suite loader: two surfaces accept
//! the same rubric shape, the agent-test `eval.rubric` matcher (parsed by the
//! suite loader) and the top-level `evals:` test type (parsed by the `mcptest
//! eval` command in the binary crate). Lifting the parser here gives both one
//! source of truth so the two cannot drift, per the rubric-test-type epic. The
//! error is self-contained (path plus message) so neither caller has to share
//! an error enum across the layer boundary.

use serde_yaml::Value as YamlValue;
use thiserror::Error;

use crate::eval::rubric::{
    AggregationMode, Criterion, CriterionExample, CriterionWhen, RubricConfig, RubricNode,
    ScoreScale,
};

/// Default pass threshold when a rubric omits `threshold:`.
pub const DEFAULT_RUBRIC_THRESHOLD: f64 = 0.7;

/// A rubric that failed to parse: the YAML path to the offending node plus a
/// one-line reason. Callers map this onto their own error type.
#[derive(Debug, Error)]
#[error("{path}: {message}")]
pub struct RubricParseError {
    /// Slash-delimited path to the offending node, e.g.
    /// `evals/0/rubric/criteria/2`.
    pub path: String,
    /// What the parser expected at that node.
    pub message: String,
}

impl RubricParseError {
    fn at(path: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            message: message.into(),
        }
    }
}

/// Parse a `rubric` mapping into a [`RubricConfig`].
///
/// A rubric is either a decision `tree:` or a flat weighted `criteria:` list,
/// never both. `threshold` defaults to [`DEFAULT_RUBRIC_THRESHOLD`] and
/// `strict` to false. `path` is the YAML location of the rubric node, used to
/// build precise error paths.
pub fn parse_rubric(value: &YamlValue, path: &str) -> Result<RubricConfig, RubricParseError> {
    let threshold = value
        .get("threshold")
        .and_then(YamlValue::as_f64)
        .unwrap_or(DEFAULT_RUBRIC_THRESHOLD);
    let strict = value
        .get("strict")
        .and_then(YamlValue::as_bool)
        .unwrap_or(false);
    let require_evidence = value
        .get("require_evidence")
        .and_then(YamlValue::as_bool)
        .unwrap_or(false);
    // On by default (WOR-1370): an extremes-only anchor set gains a synthetic
    // mid-tier exemplar. Authors who want exactly their anchors set it false.
    let default_mid_anchor = value
        .get("default_mid_anchor")
        .and_then(YamlValue::as_bool)
        .unwrap_or(true);
    let scale = parse_scale(value, path)?;
    if let Some(tree_node) = value.get("tree") {
        let tree = parse_rubric_node(tree_node, &format!("{path}/tree"))?;
        return Ok(RubricConfig {
            tree: Some(tree),
            threshold,
            strict,
            scale,
            require_evidence,
            // A `tree:` rubric always walks the tree; the author does not pick
            // an aggregation mode for it.
            aggregation: AggregationMode::Tree,
            default_mid_anchor,
            ..RubricConfig::default()
        });
    }
    let criteria_node = value
        .get("criteria")
        .and_then(YamlValue::as_sequence)
        .ok_or_else(|| RubricParseError::at(path, "missing `criteria` (or provide a `tree`)"))?;
    if criteria_node.is_empty() {
        return Err(RubricParseError::at(
            format!("{path}/criteria"),
            "expected at least one criterion",
        ));
    }
    let mut criteria = Vec::with_capacity(criteria_node.len());
    for (i, c) in criteria_node.iter().enumerate() {
        let cpath = format!("{path}/criteria/{i}");
        let name = c
            .get("name")
            .and_then(YamlValue::as_str)
            .ok_or_else(|| RubricParseError::at(cpath.clone(), "missing `name`"))?
            .to_string();
        let description = c
            .get("description")
            .and_then(YamlValue::as_str)
            .ok_or_else(|| RubricParseError::at(cpath.clone(), "missing `description`"))?
            .to_string();
        let weight = c.get("weight").and_then(YamlValue::as_f64).unwrap_or(1.0);
        let required = c
            .get("required")
            .and_then(YamlValue::as_bool)
            .unwrap_or(false);
        let guard = c.get("guard").and_then(YamlValue::as_bool).unwrap_or(false);
        let examples = parse_examples(c);
        let when = parse_when(c, &cpath)?;
        let threshold = c.get("threshold").and_then(YamlValue::as_f64);
        criteria.push(Criterion {
            name,
            weight,
            description,
            required,
            guard,
            examples,
            when,
            threshold,
        });
    }
    Ok(RubricConfig {
        criteria,
        threshold,
        strict,
        scale,
        require_evidence,
        aggregation: parse_aggregation(value, path)?,
        default_mid_anchor,
        ..RubricConfig::default()
    })
}

/// Parse the optional `scale:` field: a scale name (`unit`, `boolean`,
/// `letter`) or a `likert: {min, max}` mapping. Absent means `unit`. An
/// unknown name is an error so a typo surfaces at load time.
fn parse_scale(value: &YamlValue, path: &str) -> Result<ScoreScale, RubricParseError> {
    match value.get("scale") {
        None => Ok(ScoreScale::Unit),
        Some(YamlValue::String(s)) => match s.as_str() {
            "unit" => Ok(ScoreScale::Unit),
            "boolean" | "bool" => Ok(ScoreScale::Boolean),
            "letter" => Ok(ScoreScale::Letter),
            other => Err(RubricParseError::at(
                format!("{path}/scale"),
                format!(
                    "unknown scale `{other}` (expected `unit`, `boolean`, `letter`, \
                     or a `likert` mapping)"
                ),
            )),
        },
        Some(map) if map.get("likert").is_some() => {
            let likert = map
                .get("likert")
                .expect("checked by the guard above that `likert` is present");
            let min = likert.get("min").and_then(YamlValue::as_i64).unwrap_or(1);
            let max = likert.get("max").and_then(YamlValue::as_i64).unwrap_or(5);
            if max <= min {
                return Err(RubricParseError::at(
                    format!("{path}/scale/likert"),
                    "`max` must be greater than `min`",
                ));
            }
            Ok(ScoreScale::Likert { min, max })
        }
        Some(_) => Err(RubricParseError::at(
            format!("{path}/scale"),
            "expected a scale name (`unit`, `boolean`, `letter`) or a `likert: {min, max}` mapping",
        )),
    }
}

/// Parse the optional `when:` predicate on a criterion: `{ contains: <str> }`
/// or `{ regex: <pattern> }`. A regex is compiled here so an invalid pattern
/// fails at load time rather than silently skipping the criterion at grade
/// time.
fn parse_when(
    criterion: &YamlValue,
    path: &str,
) -> Result<Option<CriterionWhen>, RubricParseError> {
    let Some(when) = criterion.get("when") else {
        return Ok(None);
    };
    if let Some(needle) = when.get("contains").and_then(YamlValue::as_str) {
        return Ok(Some(CriterionWhen::Contains(needle.to_string())));
    }
    if let Some(pattern) = when.get("regex").and_then(YamlValue::as_str) {
        regex::Regex::new(pattern).map_err(|e| {
            RubricParseError::at(format!("{path}/when/regex"), format!("invalid regex: {e}"))
        })?;
        return Ok(Some(CriterionWhen::Regex(pattern.to_string())));
    }
    Err(RubricParseError::at(
        format!("{path}/when"),
        "expected `contains` or `regex`",
    ))
}

/// Parse the optional `examples:` calibration anchors on a criterion: a list
/// of `{ response, score }` entries. An entry missing either field is skipped.
fn parse_examples(criterion: &YamlValue) -> Vec<CriterionExample> {
    let Some(seq) = criterion.get("examples").and_then(YamlValue::as_sequence) else {
        return Vec::new();
    };
    seq.iter()
        .filter_map(|e| {
            let response = e.get("response").and_then(YamlValue::as_str)?.to_string();
            let score = e.get("score").and_then(YamlValue::as_f64)?;
            Some(CriterionExample { response, score })
        })
        .collect()
}

/// Parse the optional `aggregation:` field: `weighted_average` (default) or
/// `min`/`worst`. An unknown value is an error. Tree rubrics never call this;
/// they set [`AggregationMode::Tree`] directly.
fn parse_aggregation(value: &YamlValue, path: &str) -> Result<AggregationMode, RubricParseError> {
    match value.get("aggregation") {
        None => Ok(AggregationMode::WeightedAverage),
        Some(YamlValue::String(s)) => match s.as_str() {
            "weighted_average" | "weighted-average" | "weighted" | "average" => {
                Ok(AggregationMode::WeightedAverage)
            }
            "min" | "worst" | "worst_criterion" | "worst-criterion" => Ok(AggregationMode::Min),
            other => Err(RubricParseError::at(
                format!("{path}/aggregation"),
                format!("unknown aggregation `{other}` (expected `weighted_average` or `min`)"),
            )),
        },
        Some(_) => Err(RubricParseError::at(
            format!("{path}/aggregation"),
            "aggregation must be a string (`weighted_average` or `min`)",
        )),
    }
}

/// Recursively parse a decision-tree node: either a `score` leaf or an `ask`
/// branch with `yes`/`no` children.
fn parse_rubric_node(value: &YamlValue, path: &str) -> Result<RubricNode, RubricParseError> {
    if let Some(score) = value.get("score").and_then(YamlValue::as_f64) {
        return Ok(RubricNode::Leaf {
            score,
            reason: value
                .get("reason")
                .and_then(YamlValue::as_str)
                .map(str::to_string),
        });
    }
    if let Some(question) = value.get("ask").and_then(YamlValue::as_str) {
        let yes = value
            .get("yes")
            .ok_or_else(|| RubricParseError::at(path, "`ask` node is missing `yes`"))?;
        let no = value
            .get("no")
            .ok_or_else(|| RubricParseError::at(path, "`ask` node is missing `no`"))?;
        return Ok(RubricNode::Ask {
            question: question.to_string(),
            yes: Box::new(parse_rubric_node(yes, &format!("{path}/yes"))?),
            no: Box::new(parse_rubric_node(no, &format!("{path}/no"))?),
        });
    }
    Err(RubricParseError::at(
        path,
        "expected either `ask` (with `yes` and `no`) or a `score` leaf",
    ))
}

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

    fn yaml(src: &str) -> YamlValue {
        serde_yaml::from_str(src).expect("test YAML parses")
    }

    #[test]
    fn parses_weighted_criteria_with_defaults() {
        let v = yaml(
            r#"
threshold: 0.8
criteria:
  - name: on topic
    description: "stays on the subject"
    weight: 2
  - name: concise
    description: "no filler"
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert_eq!(cfg.criteria.len(), 2);
        assert!((cfg.threshold - 0.8).abs() < 1e-9);
        assert!(!cfg.strict);
        assert!((cfg.criteria[0].weight - 2.0).abs() < 1e-9);
        // Default weight is 1.0 when omitted.
        assert!((cfg.criteria[1].weight - 1.0).abs() < 1e-9);
        assert!(cfg.tree.is_none());
    }

    #[test]
    fn parses_decision_tree() {
        let v = yaml(
            r#"
tree:
  ask: "Did it call the tool?"
  yes: { score: 1.0, reason: "called it" }
  no: { score: 0.0 }
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert!(cfg.criteria.is_empty());
        assert!(cfg.tree.is_some());
        // Threshold falls back to the default when omitted.
        assert!((cfg.threshold - DEFAULT_RUBRIC_THRESHOLD).abs() < 1e-9);
    }

    #[test]
    fn missing_criteria_and_tree_is_an_error() {
        let v = yaml("threshold: 0.5\n");
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("must fail");
        assert_eq!(err.path, "evals/0/rubric");
        assert!(err.message.contains("criteria"));
    }

    #[test]
    fn criterion_missing_description_points_at_the_criterion() {
        let v = yaml(
            r#"
criteria:
  - name: incomplete
"#,
        );
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("must fail");
        assert_eq!(err.path, "evals/0/rubric/criteria/0");
        assert!(err.message.contains("description"));
    }

    #[test]
    fn tree_ask_without_branches_is_an_error() {
        let v = yaml(
            r#"
tree:
  ask: "no branches?"
"#,
        );
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("must fail");
        assert_eq!(err.path, "evals/0/rubric/tree");
        assert!(err.message.contains("yes"));
    }

    #[test]
    fn parses_min_aggregation() {
        let v = yaml(
            r#"
aggregation: min
criteria:
  - name: a
    description: "x"
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert_eq!(cfg.aggregation, AggregationMode::Min);
    }

    #[test]
    fn criteria_default_to_weighted_average() {
        let v = yaml(
            r#"
criteria:
  - name: a
    description: "x"
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert_eq!(cfg.aggregation, AggregationMode::WeightedAverage);
    }

    #[test]
    fn a_tree_rubric_sets_tree_aggregation() {
        let v = yaml(
            r#"
tree:
  ask: "ok?"
  yes: { score: 1.0 }
  no: { score: 0.0 }
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert_eq!(cfg.aggregation, AggregationMode::Tree);
    }

    #[test]
    fn parses_a_likert_scale() {
        let v = yaml(
            r#"
scale:
  likert: { min: 1, max: 5 }
criteria:
  - name: a
    description: "x"
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert_eq!(cfg.scale, ScoreScale::Likert { min: 1, max: 5 });
    }

    #[test]
    fn parses_named_scales() {
        for (name, expected) in [
            ("boolean", ScoreScale::Boolean),
            ("letter", ScoreScale::Letter),
            ("unit", ScoreScale::Unit),
        ] {
            let v = yaml(&format!(
                "scale: {name}\ncriteria:\n  - name: a\n    description: x\n"
            ));
            let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
            assert_eq!(cfg.scale, expected);
        }
    }

    #[test]
    fn unknown_aggregation_is_an_error() {
        let v = yaml(
            r#"
aggregation: bananas
criteria:
  - name: a
    description: "x"
"#,
        );
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("must fail");
        assert_eq!(err.path, "evals/0/rubric/aggregation");
    }

    #[test]
    fn parses_when_predicate_and_per_criterion_threshold() {
        let v = yaml(
            r#"
criteria:
  - name: on errors
    description: "x"
    when: { contains: "error" }
    threshold: 0.9
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert_eq!(
            cfg.criteria[0].when,
            Some(CriterionWhen::Contains("error".into()))
        );
        assert_eq!(cfg.criteria[0].threshold, Some(0.9));
    }

    #[test]
    fn an_invalid_when_regex_is_a_load_time_error() {
        let v = yaml(
            r#"
criteria:
  - name: bad
    description: "x"
    when: { regex: "(" }
"#,
        );
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("invalid regex");
        assert!(err.path.contains("when/regex"), "path: {}", err.path);
    }

    #[test]
    fn parses_calibration_anchors_on_a_criterion() {
        let v = yaml(
            r#"
criteria:
  - name: grounded
    description: "is it supported"
    examples:
      - response: "a good, supported answer"
        score: 0.9
      - response: "a fabricated answer"
        score: 0.1
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        let examples = &cfg.criteria[0].examples;
        assert_eq!(examples.len(), 2);
        assert!((examples[0].score - 0.9).abs() < 1e-9);
        assert_eq!(examples[1].response, "a fabricated answer");
    }

    #[test]
    fn parses_require_evidence() {
        let v = yaml(
            r#"
require_evidence: true
criteria:
  - name: a
    description: "x"
"#,
        );
        let cfg = parse_rubric(&v, "evals/0/rubric").expect("parses");
        assert!(cfg.require_evidence);
    }

    #[test]
    fn unknown_scale_is_an_error() {
        let v = yaml(
            r#"
scale: percentage
criteria:
  - name: a
    description: "x"
"#,
        );
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("must fail");
        assert_eq!(err.path, "evals/0/rubric/scale");
    }

    #[test]
    fn a_likert_scale_with_max_below_min_is_an_error() {
        let v = yaml(
            r#"
scale:
  likert: { min: 5, max: 1 }
criteria:
  - name: a
    description: "x"
"#,
        );
        let err = parse_rubric(&v, "evals/0/rubric").expect_err("must fail");
        assert_eq!(err.path, "evals/0/rubric/scale/likert");
    }
}
