//! Parser for the `compliance:` test blocks (WOR-1347).
//!
//! Before this module the suite loader read only `tools:`, `resources:`,
//! and `prompts:`; schema-valid `compliance:` entries parsed to nothing,
//! so `mcptest compliance run --from-suite` planned zero checks and the
//! rubric failed every rule. Both YAML shapes land here: the bare-array
//! form (`compliance:` is the list) and the object form
//! (`compliance.tests:` is the list, with `spec_version`, `grade_delta`,
//! and `spec_version_check` parsed by [`super::checks_parse`]).

use serde_yaml::Value as YamlValue;

use super::test_parse::{parse_expectations, parse_skip_only, require_str};
use super::types::Assertion;
use super::ParseError;

/// Schema fields a `compliance:` entry may carry that no runtime consumes
/// yet. Their presence is recorded on [`ComplianceCheck::ignored_fields`]
/// and warned about at load time, because a field that silently does
/// nothing is worse than one that says so.
const UNCONSUMED_FIELDS: [&str; 4] = ["cache", "effects", "server_version", "hooks"];

/// One parsed `compliance:` test entry.
///
/// Exists so the `compliance run --from-suite` path has a typed check to
/// plan from instead of re-walking the YAML. The `check:` string names a
/// raw MCP protocol method (`initialize`, `tools/list`, ...), not a tool,
/// which is why this is not a [`super::TypedTest`] with an
/// [`super::Action`].
#[derive(Debug, Clone, PartialEq)]
pub struct ComplianceCheck {
    /// Test name from the YAML. Its leading token doubles as the rule id
    /// the compliance rubric scores against (e.g. `PROTO-002`).
    pub name: String,
    /// Key into the top-level `servers:` map.
    pub server: String,
    /// Protocol method the check issues, normalized from the YAML
    /// `check:` field (hyphen aliases like `tools-list` become
    /// `tools/list`). `initialize` is special-cased at run time: the
    /// executor evaluates the handshake captured at connect time because
    /// re-issuing `initialize` mid-session is not valid MCP.
    pub method: String,
    /// Assertions evaluated against the `{"result": ...}` envelope of
    /// the check's response. Empty means "the call must succeed".
    pub expectations: Vec<Assertion>,
    /// Optional `skip: "<reason>"` marker (WOR-1282). The compliance
    /// runner parks the check: it prints a skip line with this reason
    /// and never executes, so the check yields no result row. An
    /// explicit skip wins over [`Self::only`] on the same check.
    pub skip: Option<String>,
    /// `only: true` focus marker (WOR-1282). When any check carries it,
    /// unmarked checks are implicitly skipped; under CI=true the
    /// compliance run refuses the whole suite with exit code 2.
    pub only: bool,
    /// Schema-valid fields present on this entry that nothing consumes
    /// yet (`cache`, `effects`, `server_version`, `hooks`). Carried so
    /// the CLI can warn the author instead of no-opping silently.
    pub ignored_fields: Vec<String>,
}

/// Parse the optional `compliance:` block into typed checks.
///
/// Accepts both schema shapes: a bare array of entries, or an object
/// whose `tests:` carries them. An absent block, or an object form with
/// no `tests:`, yields an empty list so suites without compliance tests
/// are unchanged. Any other shape is a typed error so a scalar typo does
/// not vanish.
pub(super) fn parse(root: &YamlValue) -> Result<Vec<ComplianceCheck>, ParseError> {
    let Some(compliance) = root.get("compliance") else {
        return Ok(Vec::new());
    };
    let (seq, base_path) = if let Some(seq) = compliance.as_sequence() {
        (seq, "/compliance")
    } else if compliance.as_mapping().is_some() {
        match compliance.get("tests").and_then(|v| v.as_sequence()) {
            Some(tests) => (tests, "/compliance/tests"),
            // The schema requires `tests:` on the object form, but the
            // typed parser stays tolerant (validation runs upstream).
            None => return Ok(Vec::new()),
        }
    } else {
        return Err(ParseError::BadShape {
            path: "/compliance".to_string(),
            field: "compliance",
            expected: "a list of compliance tests or an object with `tests:`",
        });
    };
    let mut out = Vec::with_capacity(seq.len());
    for (i, entry) in seq.iter().enumerate() {
        out.push(parse_one(entry, &format!("{base_path}/{i}"))?);
    }
    Ok(out)
}

/// Parse one compliance entry, warning (not erroring) on schema fields
/// no runtime consumes yet.
fn parse_one(entry: &YamlValue, path: &str) -> Result<ComplianceCheck, ParseError> {
    let name = require_str(entry, path, "name")?.to_string();
    let server = require_str(entry, path, "server")?.to_string();
    let check = require_str(entry, path, "check")?;
    let method = normalize_check_method(check);
    let expectations = parse_expectations(entry, path)?;
    let (skip, only) = parse_skip_only(entry, path)?;
    let ignored_fields: Vec<String> = UNCONSUMED_FIELDS
        .iter()
        .filter(|f| entry.get(**f).is_some())
        .map(|f| (*f).to_string())
        .collect();
    if !ignored_fields.is_empty() {
        tracing::warn!(
            target: "mcptest_core::suite",
            test = %name,
            fields = %ignored_fields.join(", "),
            "compliance test declares fields no runtime consumes yet; they are ignored"
        );
    }
    Ok(ComplianceCheck {
        name,
        server,
        method,
        expectations,
        skip,
        only,
        ignored_fields,
    })
}

/// Normalize a `check:` identifier to the protocol method it names.
///
/// The documented built-in set is spelled as raw methods (`initialize`,
/// `tools/list`), but the shipped examples also use hyphenated spellings
/// (`tools-list`). Mapping the known aliases here keeps those suites
/// running; everything else is passed through verbatim so any protocol
/// method can be probed.
fn normalize_check_method(check: &str) -> String {
    match check {
        "tools-list" => "tools/list".to_string(),
        "resources-list" => "resources/list".to_string(),
        "prompts-list" => "prompts/list".to_string(),
        other => other.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::suite::{parse as parse_suite, Matcher};
    use serde_yaml::from_str;

    fn suite(yaml: &str) -> crate::suite::ResolvedSuite {
        let value: YamlValue = from_str(yaml).unwrap();
        parse_suite(&value).expect("suite parses")
    }

    #[test]
    fn parses_bare_array_compliance_tests_with_expectations() {
        let suite = suite(
            r#"
servers:
  s: { command: ["./server"] }
compliance:
  - name: PROTO-001 initialize handshake
    server: s
    check: "initialize"
    expect:
      - target: result.capabilities
        matcher:
          schema: { type: object }
"#,
        );
        assert_eq!(suite.compliance_tests.len(), 1);
        let check = &suite.compliance_tests[0];
        assert_eq!(check.name, "PROTO-001 initialize handshake");
        assert_eq!(check.server, "s");
        assert_eq!(check.method, "initialize");
        assert_eq!(check.expectations.len(), 1);
        assert_eq!(check.expectations[0].target, "result.capabilities");
        assert!(matches!(check.expectations[0].matcher, Matcher::Schema(_)));
    }

    #[test]
    fn parses_object_form_compliance_tests_under_the_tests_key() {
        let suite = suite(
            r#"
servers:
  s: { command: ["./server"] }
compliance:
  spec_version: v2024-11-05
  tests:
    - name: PROTO-002
      server: s
      check: "initialize"
    - name: TOOL-001
      server: s
      check: "tools/list"
"#,
        );
        let methods: Vec<&str> = suite
            .compliance_tests
            .iter()
            .map(|c| c.method.as_str())
            .collect();
        assert_eq!(methods, vec!["initialize", "tools/list"]);
    }

    #[test]
    fn captures_skip_reason_and_only_marker_on_compliance_tests() {
        let suite = suite(
            r#"
servers:
  s: { command: ["./server"] }
compliance:
  - name: parked
    server: s
    check: "ping"
    skip: "server has no ping yet"
  - name: focused
    server: s
    check: "tools/list"
    only: true
"#,
        );
        assert_eq!(
            suite.compliance_tests[0].skip.as_deref(),
            Some("server has no ping yet")
        );
        assert!(!suite.compliance_tests[0].only);
        assert!(suite.compliance_tests[1].skip.is_none());
        assert!(suite.compliance_tests[1].only);
    }

    #[test]
    fn records_unconsumed_schema_fields_for_the_load_time_warning() {
        let suite = suite(
            r#"
servers:
  s: { command: ["./server"] }
compliance:
  - name: cached check
    server: s
    check: "tools/list"
    cache: prefer
    server_version: "1.2.3"
"#,
        );
        assert_eq!(
            suite.compliance_tests[0].ignored_fields,
            vec!["cache".to_string(), "server_version".to_string()]
        );
    }

    #[test]
    fn entry_without_unconsumed_fields_records_none() {
        let suite = suite(
            r#"
servers:
  s: { command: ["./server"] }
compliance:
  - name: plain
    server: s
    check: "initialize"
"#,
        );
        assert!(suite.compliance_tests[0].ignored_fields.is_empty());
    }

    #[test]
    fn normalizes_hyphenated_check_aliases_to_protocol_methods() {
        assert_eq!(normalize_check_method("tools-list"), "tools/list");
        assert_eq!(normalize_check_method("resources-list"), "resources/list");
        assert_eq!(normalize_check_method("prompts-list"), "prompts/list");
        assert_eq!(normalize_check_method("ping"), "ping");
        assert_eq!(normalize_check_method("server/discover"), "server/discover");
    }

    #[test]
    fn absent_compliance_block_yields_no_tests() {
        let suite = suite(
            r#"
servers:
  s: { command: ["./server"] }
"#,
        );
        assert!(suite.compliance_tests.is_empty());
    }

    #[test]
    fn scalar_compliance_block_is_a_typed_error() {
        let value: YamlValue = from_str(
            r#"
servers:
  s: { command: ["./server"] }
compliance: "not a list"
"#,
        )
        .unwrap();
        let err = parse_suite(&value).expect_err("scalar compliance must error");
        assert!(matches!(err, ParseError::BadShape { .. }), "got: {err:?}");
    }

    #[test]
    fn missing_check_field_is_a_parse_error() {
        let value: YamlValue = from_str(
            r#"
servers:
  s: { command: ["./server"] }
compliance:
  - name: incomplete
    server: s
"#,
        )
        .unwrap();
        let err = parse_suite(&value).expect_err("missing check must error");
        match err {
            ParseError::Missing { field, .. } => assert_eq!(field, "check"),
            other => panic!("expected Missing, got {other:?}"),
        }
    }
}
