//! Tool-definition quality (TDQS) and server coherence scoring.
//!
//! Glama grades how well-written a server's tool definitions are: each tool on
//! six dimensions, then a server aggregate that weights the mean 60 percent and
//! the worst tool 40 percent so one bad tool drags the grade. This module ships
//! the part of that idea a deterministic engine can own and that therefore may
//! contribute to a public grade (ADR 0040): description presence and length,
//! whether every parameter is documented, a conciseness signal, parameter
//! semantics, and server-coherence signals (naming consistency, tool-count vs
//! token budget, name and description disambiguation).
//!
//! The subjective Glama dimensions (Purpose Clarity, Behavioral Transparency)
//! need a reader to judge meaning, not pattern, so they cannot be deterministic.
//! Per ADR 0041 they ship as a Layer B advisory in [`advisory`], structurally
//! separate from the [`TdqsReport`] and unable to flip its score.
//!
//! Two reuse points keep this from reinventing wheels. The per-tool checks build
//! on the same `inputSchema` and `description` reads the DESC-NNN lint rules in
//! [`super::tool_descriptions`] already perform, expressed here as a 0-to-1 score
//! rather than a pass/warn/critical finding. The disambiguation signal mirrors
//! the SEC-011 tool-name-squat near-duplicate logic in
//! `crate::security::namespace` (normalize separators and case, then Levenshtein
//! distance 1); that function is private there, so the same rule is restated
//! here over the single-server catalog rather than imported.

pub mod advisory;

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Weight on the mean of per-tool scores in the server aggregate. Glama uses 60
/// percent mean and 40 percent min so a single weak tool still drags the grade.
const MEAN_WEIGHT: f64 = 0.60;
/// Weight on the worst (minimum) per-tool score in the server aggregate.
const MIN_WEIGHT: f64 = 0.40;

/// A description shorter than this many characters is too terse to carry intent.
/// Matches the DESC-001 threshold so the two lanes agree on "trivial".
const MIN_DESCRIPTION_CHARS: usize = 20;

/// Above this word count a description is verbose enough to cost tokens without
/// adding selection signal. The conciseness score decays past this point. This
/// is the documented simple-word-count fallback for the token-cost concept.
const CONCISE_WORD_BUDGET: usize = 60;

/// Below this many tools a catalog is too small for naming style to be a
/// meaningful coherence signal; one or two tools share no convention to break.
const MIN_TOOLS_FOR_NAMING: usize = 3;

/// Tool count above which a flat catalog starts to cost tool-selection accuracy
/// (the reliability tradeoff  measures). The tool-count signal scores
/// full below this and decays above it. Aligned with the catalog-size note in
/// the scorecard spec, which flags 60-tool surfaces.
const TOOL_COUNT_SOFT_CAP: usize = 40;

/// The six per-tool deterministic quality signals, each scored 0.0 to 1.0.
///
/// These are the deterministic slice of Glama's six dimensions. Purpose Clarity
/// and Behavioral Transparency are deliberately absent: they are subjective and
/// ship as the Layer B advisory, never here.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ToolSignals {
    /// Description present and past the trivial-length floor.
    pub description_present: f64,
    /// Fraction of declared parameters that carry a non-empty description.
    pub params_documented: f64,
    /// Fraction of declared parameters that carry both a type and a description.
    pub parameter_semantics: f64,
    /// Conciseness: full when the description is within the word budget, decaying
    /// as it grows past it.
    pub conciseness: f64,
    /// Whether the description gestures at what the tool returns (the DESC-010
    /// return-format heuristic, as a 0-or-1 score).
    pub return_format: f64,
    /// Whether the tool declares MCP annotations (the DESC-012 heuristic).
    pub annotations_present: f64,
}

impl ToolSignals {
    /// Unweighted mean of the six signals: the tool's deterministic quality on a
    /// 0.0 to 1.0 scale. Equal weighting keeps the math auditable; the spec
    /// documents that the weighting is deliberately flat for the OSS engine.
    pub fn score(&self) -> f64 {
        (self.description_present
            + self.params_documented
            + self.parameter_semantics
            + self.conciseness
            + self.return_format
            + self.annotations_present)
            / 6.0
    }
}

/// The deterministic quality of one tool: its name, its six signals, and the
/// rolled-up score.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolScore {
    /// Tool name from the catalog.
    pub name: String,
    /// The six deterministic signals.
    pub signals: ToolSignals,
    /// Mean of the signals, 0.0 to 1.0.
    pub score: f64,
}

/// Deterministic server-coherence signals, each scored 0.0 to 1.0.
///
/// Coherence grades the catalog as a whole, the cause of the tool-selection
/// degradation  measures as a symptom.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct CoherenceSignals {
    /// Naming consistency: the fraction of tools that follow the catalog's
    /// dominant naming style (snake_case, camelCase, or kebab-case).
    pub naming_consistency: f64,
    /// Tool-count appropriateness: full below the soft cap, decaying above it so
    /// a sprawling flat catalog scores lower.
    pub tool_count: f64,
    /// Disambiguation: full when no two tools are confusable by name or
    /// description, lower for each near-duplicate pair (mirrors SEC-011).
    pub disambiguation: f64,
}

impl CoherenceSignals {
    /// Unweighted mean of the coherence signals, 0.0 to 1.0.
    pub fn score(&self) -> f64 {
        (self.naming_consistency + self.tool_count + self.disambiguation) / 3.0
    }
}

/// A structured TDQS report: per-tool scores, the server-coherence score, and
/// the 60/40 aggregate.
///
/// Serializable so the scorecard reporter and any downstream collector get a
/// stable shape. This report is the deterministic, grade-contributing artifact;
/// the advisory half lives in a separate type by design (ADR 0041).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TdqsReport {
    /// One entry per tool, in catalog order.
    pub tools: Vec<ToolScore>,
    /// 60 percent mean plus 40 percent worst tool, 0.0 to 1.0. `None` for an
    /// empty catalog, where there is nothing to aggregate.
    pub definition_quality: Option<f64>,
    /// Server-coherence signals over the whole catalog.
    pub coherence: CoherenceSignals,
}

impl TdqsReport {
    /// The mean of per-tool scores, or `None` for an empty catalog.
    pub fn mean_tool_score(&self) -> Option<f64> {
        if self.tools.is_empty() {
            return None;
        }
        let sum: f64 = self.tools.iter().map(|t| t.score).sum();
        Some(sum / self.tools.len() as f64)
    }

    /// The worst per-tool score, or `None` for an empty catalog. This is the
    /// "one bad tool drags the grade" half of the aggregate.
    pub fn min_tool_score(&self) -> Option<f64> {
        self.tools
            .iter()
            .map(|t| t.score)
            .fold(None, |acc, s| Some(acc.map_or(s, |a: f64| a.min(s))))
    }
}

/// Score an entire `tools/list`-style catalog.
///
/// `catalog` is the parsed JSON-RPC response, expected to carry a `tools` array.
/// A missing or non-array `tools` value scores as an empty catalog: no per-tool
/// scores, no aggregate, and coherence signals computed over zero tools.
pub fn score_catalog(catalog: &Value) -> TdqsReport {
    let tools = catalog
        .get("tools")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    let scored: Vec<ToolScore> = tools.iter().map(score_tool).collect();
    let definition_quality = aggregate_quality(&scored);
    let coherence = coherence_signals(&tools);

    TdqsReport {
        tools: scored,
        definition_quality,
        coherence,
    }
}

/// The 60 percent mean plus 40 percent min aggregate over per-tool scores.
/// `None` for an empty slice. This is the documented Glama rule: a high mean
/// cannot fully hide one badly written tool, because the worst score keeps 40
/// percent of the weight.
fn aggregate_quality(scored: &[ToolScore]) -> Option<f64> {
    if scored.is_empty() {
        return None;
    }
    let mean = scored.iter().map(|t| t.score).sum::<f64>() / scored.len() as f64;
    let min = scored.iter().map(|t| t.score).fold(f64::INFINITY, f64::min);
    Some(MEAN_WEIGHT * mean + MIN_WEIGHT * min)
}

/// Score one tool's six deterministic signals and roll them up.
fn score_tool(tool: &Value) -> ToolScore {
    let signals = ToolSignals {
        description_present: description_present_score(tool),
        params_documented: params_documented_score(tool),
        parameter_semantics: parameter_semantics_score(tool),
        conciseness: conciseness_score(tool),
        return_format: return_format_score(tool),
        annotations_present: annotations_present_score(tool),
    };
    ToolScore {
        name: tool_name(tool),
        score: signals.score(),
        signals,
    }
}

fn tool_name(tool: &Value) -> String {
    tool.get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>")
        .to_string()
}

fn description(tool: &Value) -> &str {
    tool.get("description")
        .and_then(|v| v.as_str())
        .unwrap_or("")
}

/// 1.0 when the description clears the trivial-length floor, else 0.0. Mirrors
/// the DESC-001 threshold so a description the lint calls "too terse" scores
/// zero here too.
fn description_present_score(tool: &Value) -> f64 {
    if description(tool).chars().count() >= MIN_DESCRIPTION_CHARS {
        1.0
    } else {
        0.0
    }
}

/// Borrow the `inputSchema.properties` object, or `None` when the tool declares
/// no parameters.
fn properties(tool: &Value) -> Option<&serde_json::Map<String, Value>> {
    tool.get("inputSchema")
        .and_then(|s| s.get("properties"))
        .and_then(|p| p.as_object())
}

/// Fraction of parameters that carry a non-empty description. A tool with no
/// parameters scores 1.0: there is nothing left undocumented. Mirrors the
/// DESC-006 missing-argument-description check, scored as a ratio.
fn params_documented_score(tool: &Value) -> f64 {
    let Some(props) = properties(tool) else {
        return 1.0;
    };
    if props.is_empty() {
        return 1.0;
    }
    let documented = props
        .values()
        .filter(|prop| {
            prop.get("description")
                .and_then(|d| d.as_str())
                .map(|s| !s.trim().is_empty())
                .unwrap_or(false)
        })
        .count();
    documented as f64 / props.len() as f64
}

/// Fraction of parameters that carry both a declared type and a description. A
/// tool with no parameters scores 1.0. This is the Parameter Semantics
/// dimension: an agent that cannot see a parameter's type and meaning cannot
/// fill it confidently.
fn parameter_semantics_score(tool: &Value) -> f64 {
    let Some(props) = properties(tool) else {
        return 1.0;
    };
    if props.is_empty() {
        return 1.0;
    }
    let well_formed = props
        .values()
        .filter(|prop| {
            let has_type = prop.get("type").and_then(|t| t.as_str()).is_some()
                || prop.get("enum").and_then(|e| e.as_array()).is_some();
            let has_desc = prop
                .get("description")
                .and_then(|d| d.as_str())
                .map(|s| !s.trim().is_empty())
                .unwrap_or(false);
            has_type && has_desc
        })
        .count();
    well_formed as f64 / props.len() as f64
}

/// Conciseness on a 0.0 to 1.0 scale. Full within the word budget, then decaying
/// linearly to 0.0 at twice the budget so a runaway description scores low. An
/// empty description scores 0.0; its absence is already penalized by
/// description-present, and an empty body is not concise prose, it is missing
/// prose. Uses a plain word count, the documented fallback for the token-cost
/// concept.
fn conciseness_score(tool: &Value) -> f64 {
    let words = description(tool).split_whitespace().count();
    if words == 0 {
        return 0.0;
    }
    if words <= CONCISE_WORD_BUDGET {
        return 1.0;
    }
    let over = (words - CONCISE_WORD_BUDGET) as f64;
    (1.0 - over / CONCISE_WORD_BUDGET as f64).max(0.0)
}

/// Keywords that signal a description documents what the tool returns. Kept in
/// step with the DESC-010 return-format lint so the two lanes agree.
const RETURN_KEYWORDS: &[&str] = &[
    "return",
    "returns",
    "returning",
    "output",
    "outputs",
    "result",
    "results",
    "response",
    "responds",
    "yields",
    "produces",
    "emits",
];

/// 1.0 when the description mentions a return keyword or the tool declares an
/// `outputSchema`, else 0.0. The DESC-010 heuristic, scored.
fn return_format_score(tool: &Value) -> f64 {
    if tool.get("outputSchema").is_some() {
        return 1.0;
    }
    let desc_lower = description(tool).to_lowercase();
    if desc_lower.is_empty() {
        return 0.0;
    }
    let mentions = RETURN_KEYWORDS.iter().any(|kw| {
        desc_lower
            .split_whitespace()
            .any(|w| w.trim_matches(|c: char| !c.is_alphanumeric()) == *kw)
    });
    if mentions {
        1.0
    } else {
        0.0
    }
}

/// 1.0 when the tool declares an `annotations` object (even empty), else 0.0.
/// The DESC-012 heuristic, scored. An empty object counts: it signals the author
/// considered annotations, which is all this shallow check can read.
fn annotations_present_score(tool: &Value) -> f64 {
    if tool.get("annotations").is_some() {
        1.0
    } else {
        0.0
    }
}

mod coherence;
pub use coherence::coherence_signals;

#[cfg(test)]
mod tests;
