//! Per-version compliance corpus loader.
//!
//! The registry (`compliance/registry.yml`) holds rule metadata; the
//! per-version corpus files under `compliance/<spec-version>/*.yml` hold
//! the assertions a rule must satisfy for that spec revision. This module
//! reads one spec version's corpus into a flat map keyed by `rule_id`,
//! capturing each rule's expected behavior as the parsed `expect` blocks.
//!
//! Why capture `expect` as untyped [`serde_yaml::Value`]: the cross-version
//! diff only needs structural equality between two versions'
//! expectations, not a typed view of every matcher. Comparing the parsed
//! YAML values gives an exact, order-insensitive comparison (serde_yaml's
//! `Value` mapping equality ignores key order) without re-implementing the
//! matcher model here.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};
use serde_yaml::Value as YamlValue;

use crate::{Error, Result};

/// One spec version's corpus, flattened to a `rule_id -> expectations`
/// map across every corpus file in the version directory.
///
/// The version directory may hold several files (one per category); this
/// type merges them so a caller works with a single rule index. A rule
/// that appears twice across files in the same version is a corpus
/// authoring error and surfaces as [`Error::InvalidBaseline`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionCorpus {
    /// Spec version directory name (`v2024-11-05`, `draft`, ...).
    pub spec_version: String,
    /// Every rule defined for this version, keyed by `rule_id`. A
    /// `BTreeMap` so iteration is deterministic for stable reports.
    pub rules: BTreeMap<String, RuleExpectations>,
}

/// The expected behavior of one rule for one spec version.
///
/// Holds the ordered `expect` blocks lifted from the rule's assertions.
/// Two [`RuleExpectations`] compare equal when their `expects` lists are
/// structurally identical, which the diff treats as "behavior unchanged."
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuleExpectations {
    /// Stable rule identifier, repeated for convenience.
    pub rule_id: String,
    /// Human-readable name, used in report rows.
    pub name: String,
    /// The `expect` block of each assertion, in declaration order. An
    /// empty list means the rule declared no assertions (legal in the
    /// schema, the runner treats it as a documentation-only entry).
    pub expects: Vec<YamlValue>,
}

/// On-disk shape of a single corpus file (`corpus:` document root).
#[derive(Debug, Deserialize)]
struct CorpusFile {
    corpus: CorpusBody,
}

#[derive(Debug, Deserialize)]
struct CorpusBody {
    #[serde(default)]
    rules: Vec<CorpusRuleDoc>,
}

#[derive(Debug, Deserialize)]
struct CorpusRuleDoc {
    rule_id: String,
    name: String,
    #[serde(default)]
    assertions: Vec<CorpusAssertionDoc>,
}

#[derive(Debug, Deserialize)]
struct CorpusAssertionDoc {
    /// The `expect` block. Optional because a malformed authoring file
    /// could omit it; such an assertion contributes no expectation.
    #[serde(default)]
    expect: Option<YamlValue>,
}

impl VersionCorpus {
    /// Load every corpus file under `dir` (the per-version directory) and
    /// merge them into one rule index.
    ///
    /// Non-corpus files (a `README.md`, or any `*.yml` whose root is not a
    /// `corpus:` document) are skipped rather than erroring, so dropping a
    /// note alongside the corpus does not break the load.
    pub fn load_dir(spec_version: &str, dir: &Path) -> Result<Self> {
        let mut rules: BTreeMap<String, RuleExpectations> = BTreeMap::new();
        let entries = fs::read_dir(dir)?;
        let mut paths: Vec<_> = Vec::new();
        for entry in entries {
            let path = entry?.path();
            if path.extension().and_then(|e| e.to_str()) == Some("yml") {
                paths.push(path);
            }
        }
        // Sort so a duplicate rule_id error names files deterministically.
        paths.sort();
        for path in paths {
            let contents = fs::read_to_string(&path)?;
            let parsed: std::result::Result<CorpusFile, _> = serde_yaml::from_str(&contents);
            let Ok(file) = parsed else {
                // Not a corpus document (for example a stray YAML note);
                // skip it rather than failing the whole load.
                continue;
            };
            for rule in file.corpus.rules {
                let expects: Vec<YamlValue> = rule
                    .assertions
                    .into_iter()
                    .filter_map(|a| a.expect)
                    .collect();
                let entry = RuleExpectations {
                    rule_id: rule.rule_id.clone(),
                    name: rule.name,
                    expects,
                };
                if rules.insert(rule.rule_id.clone(), entry).is_some() {
                    return Err(Error::InvalidBaseline(format!(
                        "rule {} defined more than once in spec version {spec_version}",
                        rule.rule_id
                    )));
                }
            }
        }
        Ok(VersionCorpus {
            spec_version: spec_version.to_string(),
            rules,
        })
    }
}

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

    fn repo_compliance_dir() -> std::path::PathBuf {
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|p| p.parent())
            .map(|root| root.join("compliance"))
            .expect("workspace root above crates/mcptest-core")
    }

    #[test]
    fn loads_a_real_version_directory() {
        let dir = repo_compliance_dir().join("v2024-11-05");
        let corpus = VersionCorpus::load_dir("v2024-11-05", &dir).expect("load");
        assert_eq!(corpus.spec_version, "v2024-11-05");
        assert!(corpus.rules.contains_key("PROTO-002"));
        let proto2 = &corpus.rules["PROTO-002"];
        assert_eq!(proto2.rule_id, "PROTO-002");
        assert!(!proto2.expects.is_empty());
    }

    #[test]
    fn skips_readme_and_loads_only_corpus_files() {
        // draft/ holds a README.md plus lifecycle.yml; the README must not
        // break the load and must not appear as a rule.
        let dir = repo_compliance_dir().join("draft");
        let corpus = VersionCorpus::load_dir("draft", &dir).expect("load");
        assert!(corpus.rules.contains_key("PROTO-008"));
    }

    #[test]
    fn rejects_a_duplicate_rule_id_within_one_version() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            dir.path().join("a.yml"),
            "corpus:\n  category: PROTO\n  rules:\n    - rule_id: PROTO-001\n      name: one\n",
        )
        .expect("write a");
        std::fs::write(
            dir.path().join("b.yml"),
            "corpus:\n  category: PROTO\n  rules:\n    - rule_id: PROTO-001\n      name: two\n",
        )
        .expect("write b");
        let err = VersionCorpus::load_dir("vtest", dir.path()).expect_err("dup should fail");
        assert!(err.to_string().contains("more than once"), "got: {err}");
    }

    #[test]
    fn captures_expect_blocks_in_declaration_order() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            dir.path().join("c.yml"),
            "corpus:\n  category: PROTO\n  rules:\n    - rule_id: PROTO-001\n      name: one\n      assertions:\n        - call: {method: a}\n          expect: {error: {code: -1}}\n        - call: {method: b}\n          expect: {error: {code: -2}}\n",
        )
        .expect("write");
        let corpus = VersionCorpus::load_dir("vtest", dir.path()).expect("load");
        let rule = &corpus.rules["PROTO-001"];
        assert_eq!(rule.expects.len(), 2);
    }
}
