//! The runtime fault taxonomy document and its invariants.
//!
//! Shape mirrors the compliance registry: a format `version`, the top-level
//! `categories` (the fault families), and the leaf `faults`. The validator
//! enforces the cross-references so a malformed taxonomy fails loudly at load
//! instead of silently dropping a family from the coverage report.

use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::{Error, Result};

/// Capability tokens a fault's `probes` list may reference. Each names an
/// mcptest probe (a subcommand or a suite feature) that exercises the fault.
/// Kept in one place so the registry validator can reject typos.
pub const KNOWN_PROBES: &[&str] = &[
    "compliance",
    "conformance",
    "negative-path",
    "fuzz",
    "output-schema",
    "schema-lint",
    "fault-recovery",
    "test-isolation",
    "agent",
    "tool-selection",
    "security",
    "trust-boundary",
    "exec-policy",
];

/// Suite shapes a category can apply to. A category that applies only to
/// `agent` is reported as not-applicable for a server-only suite.
pub const KNOWN_SCOPES: &[&str] = &["server", "agent"];

/// The canonical taxonomy, embedded at build time from `registry.yml`.
const EMBEDDED_YAML: &str = include_str!("registry.yml");

/// Parsed `runtime-faults/registry.yml` document.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultTaxonomy {
    /// Format version. Bumped only when the document shape changes, not when
    /// families or faults are added.
    pub version: u32,
    /// Top-level fault families, in report order.
    pub categories: Vec<FaultCategory>,
    /// Leaf faults, each mapped to a category and the probes that exercise it.
    pub faults: Vec<FaultEntry>,
}

/// A top-level fault family.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FaultCategory {
    /// Category prefix every leaf fault under it carries (for example `PROTO`).
    pub id: String,
    /// Short human-readable family name.
    pub name: String,
    /// One-paragraph description for docs and reporter output.
    pub description: String,
    /// Suite shapes the family is relevant to: a non-empty subset of
    /// [`KNOWN_SCOPES`].
    pub applies_to: Vec<String>,
}

/// A single leaf fault.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FaultEntry {
    /// Stable identifier in `FAULT-<CATEGORY>-<NAME>` form.
    pub id: String,
    /// Short human-readable summary.
    pub name: String,
    /// Category id this fault rolls up to.
    pub category: String,
    /// Longer description for docs and reporter output.
    pub description: String,
    /// Capability tokens that exercise this fault. A non-empty subset of
    /// [`KNOWN_PROBES`].
    pub probes: Vec<String>,
    /// Repo-relative path to a runnable example that exercises the fault.
    #[serde(default)]
    pub fixture: String,
    /// Optional spec or paper citations.
    #[serde(default)]
    pub references: Vec<String>,
}

impl FaultTaxonomy {
    /// Parse and validate the taxonomy embedded in the binary.
    pub fn embedded() -> Result<Self> {
        Self::from_yaml(EMBEDDED_YAML)
    }

    /// Parse a taxonomy from a YAML string, validating its invariants.
    pub fn from_yaml(contents: &str) -> Result<Self> {
        let taxonomy: FaultTaxonomy = serde_yaml::from_str(contents)?;
        taxonomy.validate()?;
        Ok(taxonomy)
    }

    /// Load a taxonomy from disk.
    pub fn load(path: &Path) -> Result<Self> {
        let contents = fs::read_to_string(path)?;
        Self::from_yaml(&contents)
    }

    /// Look up a single fault by id.
    pub fn get(&self, id: &str) -> Option<&FaultEntry> {
        self.faults.iter().find(|f| f.id == id)
    }

    /// Look up a category by id.
    pub fn category(&self, id: &str) -> Option<&FaultCategory> {
        self.categories.iter().find(|c| c.id == id)
    }

    /// Every leaf fault that rolls up to the given category, in file order.
    pub fn faults_in(&self, category: &str) -> Vec<&FaultEntry> {
        self.faults
            .iter()
            .filter(|f| f.category == category)
            .collect()
    }

    /// Total leaf-fault count.
    pub fn len(&self) -> usize {
        self.faults.len()
    }

    /// `true` when no faults are registered.
    pub fn is_empty(&self) -> bool {
        self.faults.is_empty()
    }

    /// Cross-check the document's invariants. Reuses [`Error::InvalidBaseline`]
    /// for the message channel, the same generic registry-error path the
    /// compliance registry uses.
    fn validate(&self) -> Result<()> {
        self.validate_categories()?;
        self.validate_faults()?;
        Ok(())
    }

    fn validate_categories(&self) -> Result<()> {
        let mut seen: Vec<&str> = Vec::with_capacity(self.categories.len());
        for category in &self.categories {
            if category.id.is_empty() {
                return Err(reg_err("a category has an empty id"));
            }
            if seen.contains(&category.id.as_str()) {
                return Err(reg_err(&format!("duplicate category id: {}", category.id)));
            }
            seen.push(&category.id);
            if category.applies_to.is_empty() {
                return Err(reg_err(&format!(
                    "category {} has an empty applies_to",
                    category.id
                )));
            }
            for scope in &category.applies_to {
                if !KNOWN_SCOPES.contains(&scope.as_str()) {
                    return Err(reg_err(&format!(
                        "category {} lists unknown scope `{scope}`",
                        category.id
                    )));
                }
            }
        }
        Ok(())
    }

    fn validate_faults(&self) -> Result<()> {
        let mut seen: Vec<&str> = Vec::with_capacity(self.faults.len());
        for fault in &self.faults {
            if seen.contains(&fault.id.as_str()) {
                return Err(reg_err(&format!("duplicate fault id: {}", fault.id)));
            }
            seen.push(&fault.id);
            if self.category(&fault.category).is_none() {
                return Err(reg_err(&format!(
                    "fault {} names unknown category `{}`",
                    fault.id, fault.category
                )));
            }
            let prefix = format!("FAULT-{}-", fault.category);
            if !fault.id.starts_with(&prefix) {
                return Err(reg_err(&format!(
                    "fault {} must start with `{prefix}` to match its category",
                    fault.id
                )));
            }
            if fault.probes.is_empty() {
                return Err(reg_err(&format!("fault {} has no probes", fault.id)));
            }
            for probe in &fault.probes {
                if !KNOWN_PROBES.contains(&probe.as_str()) {
                    return Err(reg_err(&format!(
                        "fault {} lists unknown probe `{probe}`",
                        fault.id
                    )));
                }
            }
        }
        Ok(())
    }
}

/// Wrap a taxonomy-validation message in the shared registry error variant.
fn reg_err(msg: &str) -> Error {
    Error::InvalidBaseline(format!("runtime fault taxonomy: {msg}"))
}

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

    fn sample() -> &'static str {
        r#"
version: 1
categories:
  - id: PROTO
    name: "Protocol interaction"
    description: "lifecycle faults"
    applies_to: [server, agent]
  - id: PROVIDER
    name: "Model-provider integration"
    description: "model boundary faults"
    applies_to: [agent]
faults:
  - id: FAULT-PROTO-UNKNOWN-METHOD
    name: "unknown method"
    category: PROTO
    description: "unknown method not rejected"
    probes: [negative-path]
    fixture: examples/negative-path.yml
  - id: FAULT-PROVIDER-TOOL-SELECTION
    name: "tool selection"
    category: PROVIDER
    description: "model fails to select the tool"
    probes: [agent, tool-selection]
"#
    }

    #[test]
    fn parses_a_well_formed_taxonomy() {
        let tax = FaultTaxonomy::from_yaml(sample()).unwrap();
        assert_eq!(tax.version, 1);
        assert_eq!(tax.categories.len(), 2);
        assert_eq!(tax.len(), 2);
        assert_eq!(tax.faults_in("PROTO").len(), 1);
    }

    #[test]
    fn get_returns_a_known_fault() {
        let tax = FaultTaxonomy::from_yaml(sample()).unwrap();
        assert_eq!(
            tax.get("FAULT-PROTO-UNKNOWN-METHOD").unwrap().category,
            "PROTO"
        );
        assert!(tax.get("FAULT-NOPE").is_none());
    }

    #[test]
    fn rejects_a_fault_whose_id_prefix_misses_its_category() {
        let yaml = r#"
version: 1
categories:
  - { id: PROTO, name: p, description: d, applies_to: [server] }
faults:
  - id: FAULT-TOOL-OOPS
    name: n
    category: PROTO
    description: d
    probes: [fuzz]
"#;
        let err = FaultTaxonomy::from_yaml(yaml).unwrap_err().to_string();
        assert!(err.contains("must start with `FAULT-PROTO-`"), "got: {err}");
    }

    #[test]
    fn rejects_an_unknown_probe_token() {
        let yaml = r#"
version: 1
categories:
  - { id: PROTO, name: p, description: d, applies_to: [server] }
faults:
  - id: FAULT-PROTO-X
    name: n
    category: PROTO
    description: d
    probes: [made-up-probe]
"#;
        let err = FaultTaxonomy::from_yaml(yaml).unwrap_err().to_string();
        assert!(err.contains("unknown probe `made-up-probe`"), "got: {err}");
    }

    #[test]
    fn rejects_an_unknown_category_scope() {
        let yaml = r#"
version: 1
categories:
  - { id: PROTO, name: p, description: d, applies_to: [galaxy] }
faults: []
"#;
        let err = FaultTaxonomy::from_yaml(yaml).unwrap_err().to_string();
        assert!(err.contains("unknown scope `galaxy`"), "got: {err}");
    }

    #[test]
    fn embedded_taxonomy_parses_and_covers_eight_families() {
        let tax = FaultTaxonomy::embedded().unwrap();
        assert_eq!(
            tax.categories.len(),
            8,
            "the eight acceptance-criteria fault families must all be present"
        );
        // Every category carries at least one leaf fault with a fixture.
        for category in &tax.categories {
            let faults = tax.faults_in(&category.id);
            assert!(!faults.is_empty(), "category {} has no faults", category.id);
            assert!(
                faults.iter().any(|f| !f.fixture.is_empty()),
                "category {} has no fault with a fixture",
                category.id
            );
        }
    }

    #[test]
    fn embedded_fixtures_point_at_real_files() {
        let tax = FaultTaxonomy::embedded().unwrap();
        // CARGO_MANIFEST_DIR is crates/mcptest-core; the workspace root is two
        // levels up, where the `examples/` fixtures live.
        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|p| p.parent())
            .expect("workspace root resolves");
        for fault in &tax.faults {
            if fault.fixture.is_empty() {
                continue;
            }
            let path = root.join(&fault.fixture);
            assert!(
                path.exists(),
                "fault {} fixture is missing on disk: {}",
                fault.id,
                path.display()
            );
        }
    }
}
