//! Matcher grammar for the suite loader.
//!
//! Holds `parse_matcher` and the named-matcher vocabulary it dispatches
//! to, split out of [`super::test_parse`] so the per-test parsers and
//! the matcher grammar each stay under the AGENTS module-size ceiling.
//! The composition evaluator reuses [`parse_matcher`] through the
//! crate-level re-export in [`super`].

use serde_yaml::Value as YamlValue;

use super::matcher::Matcher;
use super::named_graded;
use super::test_parse::{require_str, yaml_to_json};
use super::ParseError;

pub(crate) fn parse_matcher(node: &YamlValue, path: &str) -> Result<Matcher, ParseError> {
    let map = node.as_mapping().ok_or_else(|| ParseError::BadShape {
        path: path.to_string(),
        field: "matcher",
        expected: "object",
    })?;
    let mut keys: Vec<&str> = Vec::new();
    for k in map.keys() {
        if let Some(s) = k.as_str() {
            keys.push(s);
        }
    }
    if keys.len() != 1 {
        return Err(ParseError::MatcherShape {
            path: path.to_string(),
        });
    }
    let key = keys[0];
    let value_node = map.get(YamlValue::String(key.to_string())).unwrap();
    match key {
        "exact" => Ok(Matcher::Exact(yaml_to_json(value_node)?)),
        "contains" => Ok(Matcher::Contains(yaml_to_json(value_node)?)),
        "regex" => {
            let s = value_node.as_str().ok_or_else(|| ParseError::BadShape {
                path: path.to_string(),
                field: "regex",
                expected: "string",
            })?;
            Ok(Matcher::Regex(s.to_string()))
        }
        "subset" => Ok(Matcher::Subset(yaml_to_json(value_node)?)),
        "schema" => Ok(Matcher::Schema(yaml_to_json(value_node)?)),
        "snapshot" => {
            let s = value_node.as_str().ok_or_else(|| ParseError::BadShape {
                path: path.to_string(),
                field: "snapshot",
                expected: "string",
            })?;
            Ok(Matcher::Snapshot(s.to_string()))
        }
        "llm-judge" => Ok(Matcher::LlmJudge(yaml_to_json(value_node)?)),
        "llm-jury" => Ok(Matcher::LlmJury(yaml_to_json(value_node)?)),
        "similar" => Ok(Matcher::Similar(yaml_to_json(value_node)?)),
        "citation" => Ok(Matcher::Citation(yaml_to_json(value_node)?)),
        "cel" => Ok(Matcher::Cel(require_matcher_str(value_node, path, "cel")?)),
        k if named_graded::NAMED_GRADED_KEYS.contains(&k) => {
            parse_named_graded(k, value_node, path)
        }
        other => parse_named_matcher(other, value_node, path),
    }
}

/// Desugar a named model-graded matcher (`factuality`, `answer-relevance`,
/// `context-faithfulness`) into an `llm-judge` block.
///
/// Each metric requires one string field (the reference answer, the query, or
/// the retrieved context); an optional `threshold:` overrides the judge default
/// and an optional `model:` overrides the judge model. The vetted rubric text
/// lives in [`super::named_graded`]; this function only extracts the fields and
/// assembles the judge block, so the executor sees an ordinary `LlmJudge`.
fn parse_named_graded(
    key: &str,
    value_node: &YamlValue,
    path: &str,
) -> Result<Matcher, ParseError> {
    let rubric = match key {
        "factuality" => {
            named_graded::factuality_rubric(require_str(value_node, path, "reference")?)
        }
        "answer-relevance" => {
            named_graded::answer_relevance_rubric(require_str(value_node, path, "query")?)
        }
        "context-faithfulness" => {
            named_graded::context_faithfulness_rubric(require_str(value_node, path, "context")?)
        }
        // The caller only routes the keys in NAMED_GRADED_KEYS here.
        other => {
            return Err(ParseError::UnknownMatcher {
                path: path.to_string(),
                key: other.to_string(),
            })
        }
    };
    let threshold = value_node
        .get("threshold")
        .and_then(|v| v.as_f64())
        .unwrap_or(named_graded::DEFAULT_THRESHOLD);
    let model = value_node.get("model").and_then(|v| v.as_str());
    Ok(Matcher::LlmJudge(named_graded::judge_block(
        rubric, threshold, model,
    )))
}

/// Parse the  named matchers (`contains-all`, `contains-any`,
/// `icontains`, `starts-with`, `is-json`, `is-valid-tools-call`,
/// `levenshtein`, `not`) plus the  additions (`is-xml`, `is-sql`). Kept
/// out of [`parse_matcher`] so that function
/// stays under the 60-line ceiling and so the deterministic-matcher
/// vocabulary lives in one place. `not:` recurses into [`parse_matcher`] for
/// its inner matcher; the one-key shape check there rejects a malformed body.
fn parse_named_matcher(
    key: &str,
    value_node: &YamlValue,
    path: &str,
) -> Result<Matcher, ParseError> {
    match key {
        "contains-all" => Ok(Matcher::ContainsAll(yaml_to_json(value_node)?)),
        "contains-any" => Ok(Matcher::ContainsAny(yaml_to_json(value_node)?)),
        "icontains" => Ok(Matcher::IContains(require_matcher_str(
            value_node,
            path,
            "icontains",
        )?)),
        "starts-with" => Ok(Matcher::StartsWith(require_matcher_str(
            value_node,
            path,
            "starts-with",
        )?)),
        "is-json" => Ok(Matcher::IsJson(yaml_to_json(value_node)?)),
        "is-valid-tools-call" => Ok(Matcher::IsValidToolsCall(yaml_to_json(value_node)?)),
        "is-xml" => Ok(Matcher::IsXml(yaml_to_json(value_node)?)),
        "is-sql" => Ok(Matcher::IsSql(yaml_to_json(value_node)?)),
        "levenshtein" => Ok(Matcher::Levenshtein(yaml_to_json(value_node)?)),
        "not" => Ok(Matcher::Not(Box::new(parse_matcher(value_node, path)?))),
        "oneOf" => Ok(Matcher::OneOf(parse_composition_children(
            value_node, path, "oneOf",
        )?)),
        "anyOf" => Ok(Matcher::AnyOf(parse_composition_children(
            value_node, path, "anyOf",
        )?)),
        "allOf" => Ok(Matcher::AllOf(parse_composition_children(
            value_node, path, "allOf",
        )?)),
        other => Err(ParseError::UnknownMatcher {
            path: path.to_string(),
            key: other.to_string(),
        }),
    }
}

/// Parse the inner-matcher list for an `oneOf`/`anyOf`/`allOf` composition
/// wrapper. The body must be a YAML sequence of matcher objects. Two
/// shape errors live here: not-a-sequence and zero children (an empty
/// composition has no useful semantics and almost always indicates a
/// typo, so we reject it early).
fn parse_composition_children(
    value_node: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<Vec<Matcher>, ParseError> {
    let seq = value_node.as_sequence().ok_or(ParseError::BadShape {
        path: path.to_string(),
        field,
        expected: "sequence of matchers",
    })?;
    if seq.is_empty() {
        return Err(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "at least one inner matcher",
        });
    }
    let mut out = Vec::with_capacity(seq.len());
    for child in seq {
        out.push(parse_matcher(child, path)?);
    }
    Ok(out)
}

/// Coerce a matcher value node into an owned string or raise a typed
/// [`ParseError::BadShape`] naming the offending matcher key.
fn require_matcher_str(
    value_node: &YamlValue,
    path: &str,
    field: &'static str,
) -> Result<String, ParseError> {
    value_node
        .as_str()
        .map(str::to_string)
        .ok_or(ParseError::BadShape {
            path: path.to_string(),
            field,
            expected: "string",
        })
}
