//! Load-time lint pass for vacuous matchers (WOR-1294).
//!
//! Two matcher shapes can never fail, so an assertion built on them
//! checks nothing: `contains` with an empty operand (subset semantics
//! mean `{}` is contained in every object, `[]` in every array, and
//! `""` in every string), and a regex that matches the empty string
//! (`\d*`, `.*`, `(foo)?` match any input because the matcher searches
//! rather than anchors). A suite full of green checkmarks built on such
//! assertions gives false confidence, so the run path surfaces them as
//! warnings before any server is spawned, and `--deny-lints` turns them
//! into a refusal.

use std::fmt;

use serde_json::Value;

use super::{Assertion, Matcher, ResolvedSuite};

/// One vacuous-matcher finding, addressed by test name and assertion
/// target so the author can jump straight to the YAML line that checks
/// nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintWarning {
    /// Name of the test, agent, or composition carrying the assertion.
    pub test: String,
    /// The assertion's `target:` path.
    pub target: String,
    /// Why the matcher can never fail.
    pub message: String,
}

impl fmt::Display for LintWarning {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "test '{}' target '{}': {}",
            self.test, self.target, self.message
        )
    }
}

/// Scan every assertion in the suite for matchers that can never fail.
///
/// Walks tool/resource/prompt tests and agent tests (their `expect:`
/// lists plus every assert-set member) and composition `expect:`
/// entries. Returns findings in declaration order; an empty vector
/// means the suite carries no vacuous assertion. Pure and offline, so
/// the run path can call it before any server connection.
pub fn lint_suite(suite: &ResolvedSuite) -> Vec<LintWarning> {
    let mut out = Vec::new();
    for test in &suite.tests {
        lint_assertions(&test.name, &test.expectations, &mut out);
        for set in &test.assert_sets {
            lint_assertions(&test.name, &set.assertions, &mut out);
        }
    }
    for agent in &suite.agents {
        lint_assertions(&agent.name, &agent.expectations, &mut out);
        for set in &agent.assert_sets {
            lint_assertions(&agent.name, &set.assertions, &mut out);
        }
    }
    for comp in &suite.compositions {
        lint_composition_expects(&comp.name, &comp.expect, &mut out);
    }
    out
}

/// Lint one assertion list belonging to the named test.
fn lint_assertions(test: &str, assertions: &[Assertion], out: &mut Vec<LintWarning>) {
    for assertion in assertions {
        lint_matcher(test, &assertion.target, &assertion.matcher, out);
    }
}

/// Lint a composition's raw `expect:` entries. The loader stores them
/// untyped (the evaluator parses them at run time), so the lint parses
/// each entry's matcher through the same grammar here.
fn lint_composition_expects(name: &str, expect: &[Value], out: &mut Vec<LintWarning>) {
    for entry in expect {
        let Some(target) = entry.get("target").and_then(Value::as_str) else {
            // A malformed entry is the composition evaluator's error to
            // report at run time; the lint only inspects well-formed ones.
            continue;
        };
        let Some(matcher_json) = entry.get("matcher") else {
            continue;
        };
        let Ok(matcher_yaml) = serde_yaml::to_value(matcher_json) else {
            continue;
        };
        let Ok(matcher) = super::parse_matcher(&matcher_yaml, target) else {
            continue;
        };
        lint_matcher(name, target, &matcher, out);
    }
}

/// Check one matcher for the two vacuous shapes. Recurses into the
/// `oneOf` / `anyOf` / `allOf` composition wrappers because a vacuous
/// child there still asserts in the positive direction; `not:` is left
/// alone since negation inverts the semantics (a never-fail matcher
/// under `not:` is a never-pass assertion, a different bug the test
/// itself will surface).
fn lint_matcher(test: &str, target: &str, matcher: &Matcher, out: &mut Vec<LintWarning>) {
    match matcher {
        Matcher::Contains(operand) => {
            if let Some(message) = empty_contains_message(operand) {
                out.push(LintWarning {
                    test: test.to_string(),
                    target: target.to_string(),
                    message: message.to_string(),
                });
            }
        }
        Matcher::Regex(pattern) => {
            // A pattern that does not compile is the executor's failure
            // to report; the lint only judges compilable patterns.
            if let Ok(re) = regex::Regex::new(pattern) {
                if re.is_match("") {
                    out.push(LintWarning {
                        test: test.to_string(),
                        target: target.to_string(),
                        message: format!(
                            "regex `{pattern}` matches the empty string, so it passes on any \
                             input; the assertion can never fail"
                        ),
                    });
                }
            }
        }
        Matcher::OneOf(children) | Matcher::AnyOf(children) | Matcher::AllOf(children) => {
            for child in children {
                lint_matcher(test, target, child, out);
            }
        }
        _ => {}
    }
}

/// The fixed message for an empty `contains` operand, or `None` when
/// the operand is non-empty (and the assertion can actually fail).
fn empty_contains_message(operand: &Value) -> Option<&'static str> {
    match operand {
        Value::Object(map) if map.is_empty() => {
            Some("contains {} matches every value; the assertion can never fail")
        }
        Value::Array(items) if items.is_empty() => {
            Some("contains [] matches every array; the assertion can never fail")
        }
        Value::String(s) if s.is_empty() => {
            Some("contains \"\" matches every string; the assertion can never fail")
        }
        _ => None,
    }
}

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

    /// Parse a YAML document into a [`ResolvedSuite`] and lint it.
    fn lint_yaml(doc: &str) -> Vec<LintWarning> {
        let yaml: serde_yaml::Value = from_str(doc).expect("test YAML parses");
        let suite = crate::suite::parse(&yaml).expect("suite parses");
        lint_suite(&suite)
    }

    #[test]
    fn flags_contains_with_empty_object_array_and_string_operands() {
        let warnings = lint_yaml(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: vacuous trio
    server: fs
    tool: t
    expect:
      - target: result
        matcher: { contains: {} }
      - target: result.content
        matcher: { contains: [] }
      - target: "result.content[0].text"
        matcher: { contains: "" }
"#,
        );
        assert_eq!(warnings.len(), 3, "got: {warnings:?}");
        assert_eq!(
            warnings[0].to_string(),
            "test 'vacuous trio' target 'result': contains {} matches every value; \
             the assertion can never fail"
        );
        assert!(warnings[1].message.contains("contains []"));
        assert!(warnings[2].message.contains("contains \"\""));
    }

    #[test]
    fn flags_regex_patterns_that_match_the_empty_string() {
        let warnings = lint_yaml(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: hollow regexes
    server: fs
    tool: t
    expect:
      - target: result.text
        matcher: { regex: "\\d*" }
      - target: result.text
        matcher: { regex: ".*" }
      - target: result.text
        matcher: { regex: "(foo)?" }
"#,
        );
        assert_eq!(warnings.len(), 3, "got: {warnings:?}");
        for w in &warnings {
            assert_eq!(w.test, "hollow regexes");
            assert_eq!(w.target, "result.text");
            assert!(w.message.contains("matches the empty string"), "{w}");
        }
    }

    #[test]
    fn flags_vacuous_matchers_inside_assert_sets_and_compositions() {
        let warnings = lint_yaml(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: bundled
    server: fs
    tool: t
    expect:
      - assert-set:
          name: coverage
          threshold: 1.0
          assertions:
            - target: result
              matcher: { contains: {} }
compositions:
  - name: pipeline
    nodes:
      - id: step
        server: fs
        tool: t
    expect:
      - target: composition.ran
        matcher: { regex: ".*" }
"#,
        );
        assert_eq!(warnings.len(), 2, "got: {warnings:?}");
        assert_eq!(warnings[0].test, "bundled");
        assert_eq!(warnings[1].test, "pipeline");
        assert_eq!(warnings[1].target, "composition.ran");
    }

    #[test]
    fn recurses_into_composition_wrappers_but_not_into_not() {
        let warnings = lint_yaml(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: wrapped
    server: fs
    tool: t
    expect:
      - target: result
        matcher:
          allOf:
            - { contains: ok }
            - { contains: {} }
      - target: result
        matcher:
          not: { regex: ".*" }
"#,
        );
        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
        assert!(warnings[0].message.contains("contains {}"));
    }

    #[test]
    fn clean_suite_produces_no_warnings() {
        let warnings = lint_yaml(
            r#"
servers:
  fs: { command: ["a"] }
tools:
  - name: real checks
    server: fs
    tool: t
    expect:
      - target: result.isError
        matcher: { exact: false }
      - target: "result.content[0].text"
        matcher: { contains: pong }
      - target: "result.content[0].text"
        matcher: { regex: "\\d+" }
agents:
  - name: agent checks
    model: claude-sonnet-4-5
    servers: [fs]
    prompt: hi
    expect:
      - target: trace.text
        matcher: { contains: done }
"#,
        );
        assert!(warnings.is_empty(), "got: {warnings:?}");
    }
}
