//! Smell categories: group the per-rule DESC-NNN findings into the named
//! description-quality smells from recent tool-description research.
//!
//! The lint pass in [`crate::lint::tool_descriptions`] emits fine-grained
//! rule findings (DESC-001..DESC-013). A benchmark wants a coarser, stable
//! vocabulary so it can report "this catalog is underspecified and missing
//! examples" rather than a list of rule ids, and so a mutation can be tied to
//! the smell it introduces. This module maps each rule to one smell category
//! and summarizes a [`LintReport`] into per-category counts.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::lint::tool_descriptions::{LintReport, Severity};

/// A named description-quality smell. Coarser than a DESC-NNN rule: several
/// rules can map to one smell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Smell {
    /// The description (or an argument) carries too little information to act
    /// on: too short, an undocumented argument, an enum without value docs.
    Underspecified,
    /// The description is bloated: too long, or an argument doc that dwarfs
    /// the tool description.
    Verbose,
    /// The description adds nothing a reader could not get from the name: it
    /// duplicates the name or lacks an action verb.
    Uninformative,
    /// The description leans on something that will not hold: a positional
    /// reference ("the first tool"), which breaks as the catalog reorders.
    Brittle,
    /// A non-trivial input schema ships without an example call.
    MissingExamples,
    /// The description never says what the tool returns.
    MissingReturnFormat,
    /// Behavior annotations (read-only, destructive, ...) are absent.
    MissingAnnotations,
    /// An annotation hint is present but malformed (not a boolean).
    InvalidAnnotation,
}

impl Smell {
    /// The stable snake_case label used in reports and metrics.
    pub fn label(self) -> &'static str {
        match self {
            Smell::Underspecified => "underspecified",
            Smell::Verbose => "verbose",
            Smell::Uninformative => "uninformative",
            Smell::Brittle => "brittle",
            Smell::MissingExamples => "missing_examples",
            Smell::MissingReturnFormat => "missing_return_format",
            Smell::MissingAnnotations => "missing_annotations",
            Smell::InvalidAnnotation => "invalid_annotation",
        }
    }
}

/// Map a DESC-NNN rule id to its smell category. Unknown ids (including the
/// synthetic `PASS`) return `None` so they do not inflate any category.
pub fn smell_for_rule(rule_id: &str) -> Option<Smell> {
    let smell = match rule_id {
        "DESC-001" | "DESC-006" | "DESC-007" | "DESC-013" => Smell::Underspecified,
        "DESC-002" | "DESC-008" => Smell::Verbose,
        "DESC-003" | "DESC-004" => Smell::Uninformative,
        "DESC-005" => Smell::Brittle,
        "DESC-009" => Smell::MissingExamples,
        "DESC-010" => Smell::MissingReturnFormat,
        "DESC-011" => Smell::InvalidAnnotation,
        "DESC-012" => Smell::MissingAnnotations,
        _ => return None,
    };
    Some(smell)
}

/// Count findings per smell category across a whole lint report. Only
/// `Warning` and `Critical` findings count; `Pass` findings are ignored.
pub fn summarize(report: &LintReport) -> BTreeMap<Smell, usize> {
    let mut counts: BTreeMap<Smell, usize> = BTreeMap::new();
    for finding in &report.findings {
        if matches!(finding.severity, Severity::Pass) {
            continue;
        }
        if let Some(smell) = smell_for_rule(finding.rule_id) {
            *counts.entry(smell).or_insert(0) += 1;
        }
    }
    counts
}

/// Render a smell summary as a JSON object keyed by the smell label, for the
/// report envelope and the TDQS metrics object.
pub fn metrics_object(counts: &BTreeMap<Smell, usize>) -> serde_json::Value {
    let map: serde_json::Map<String, serde_json::Value> = counts
        .iter()
        .map(|(smell, count)| (smell.label().to_string(), serde_json::json!(count)))
        .collect();
    serde_json::Value::Object(map)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lint::tool_descriptions::{default_rules, lint_catalog};
    use serde_json::json;

    fn lint(catalog: &serde_json::Value) -> LintReport {
        let rules = default_rules();
        let refs: Vec<&dyn crate::lint::tool_descriptions::Rule> =
            rules.iter().map(AsRef::as_ref).collect();
        lint_catalog(catalog, &refs)
    }

    #[test]
    fn every_desc_rule_maps_to_a_smell() {
        for n in 1..=13 {
            let id = format!("DESC-{n:03}");
            assert!(smell_for_rule(&id).is_some(), "{id} has no smell");
        }
        assert!(smell_for_rule("PASS").is_none());
        assert!(smell_for_rule("SCH-001").is_none());
    }

    #[test]
    fn a_short_undescribed_tool_is_underspecified() {
        let catalog = json!({
            "tools": [{ "name": "x", "description": "do", "inputSchema": { "type": "object" } }]
        });
        let counts = summarize(&lint(&catalog));
        assert!(
            counts.get(&Smell::Underspecified).copied().unwrap_or(0) >= 1,
            "{counts:?}"
        );
    }

    #[test]
    fn a_well_described_tool_has_no_underspecified_smell() {
        let catalog = json!({
            "tools": [{
                "name": "get_weather",
                "description": "Get the current weather for a city and return a JSON object with temperature in celsius.",
                "inputSchema": {
                    "type": "object",
                    "properties": { "city": { "type": "string", "description": "City name to look up." } }
                },
                "annotations": { "readOnlyHint": true }
            }]
        });
        let counts = summarize(&lint(&catalog));
        assert_eq!(
            counts.get(&Smell::Underspecified).copied().unwrap_or(0),
            0,
            "{counts:?}"
        );
    }
}
