//! Doctor diagnostics that need a structured report.
//!
//! This module owns two related diagnostics: the tool catalog token cost
//! and the tool-search-friendly posture signal derived
//! from it. The CLI handler in [`crate::cli`] cannot run the diagnostics
//! end-to-end yet because the live `tools/list` call ships with ,
//! so the production path stays a stub. This module owns the pure
//! computation and the pretty-printer, both fully unit-tested against
//! fixture JSON, so flipping the switch later is a one-line change.

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

use mcptest_core::matchers::budgets::Tokenizer;

/// Per-tool token cost row.
///
/// `above_threshold` is precomputed so the printer does not have to know the
/// threshold value at render time and so a future JSON reporter
/// gets the flag for free.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolTokenCost {
    /// Tool name as it appeared in `tools/list`.
    pub name: String,
    /// Approximate token count for name + description + inputSchema JSON.
    pub tokens: usize,
    /// True when `tokens` exceeded `per_tool_warn_threshold`.
    pub above_threshold: bool,
}

/// Token-cost ceiling for a "tool-search-friendly" catalog.
///
/// Anthropic's advanced-tool-use guidance reports that a five-server MCP
/// setup is roughly 55K tokens before any conversation and that real
/// systems reach about 134K. The Tool Search Tool defers definitions to
/// cut that catalog cost by about 85 to 95 percent. A catalog that already
/// sits well under the five-server baseline does not need deferred loading
/// to stay cheap, so we treat 20K tokens (roughly one-third of the 55K
/// baseline, a comfortable margin under it) as the friendly ceiling. Above
/// that, the surface is heavy enough that tool search starts to pay off.
const TOOL_SEARCH_FRIENDLY_TOKEN_CEILING: usize = 20_000;

/// Tool-count ceiling for a "tool-search-friendly" catalog.
///
/// Token cost is the primary signal, but a catalog with only a handful of
/// tools stays friendly even if a few of those tools carry large schemas,
/// because the agent can hold the whole surface in context at once. Ten
/// tools is a small enough surface that deferred loading buys little. Above
/// the token ceiling OR this count, the catalog is classified heavy.
const TOOL_SEARCH_FRIENDLY_TOOL_CEILING: usize = 10;

/// Whether a catalog's tool surface is small enough to skip tool search.
///
/// This is a deterministic posture signal computed from the catalog token
/// cost and the tool count. It tells an operator whether the
/// server's `tools/list` surface is cheap enough to load up front or heavy
/// enough that the Tool Search Tool (deferred definitions) would help.
///
/// `Friendly` is the serde default: an empty catalog has nothing to defer,
/// and reports that predate this field deserialize as friendly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSearchPosture {
    /// The catalog is small or cheap enough to load up front.
    #[default]
    Friendly,
    /// The catalog is large or expensive enough that deferred loading helps.
    Heavy,
}

impl ToolSearchPosture {
    /// The stable label rendered in reports (`friendly` or `heavy`).
    pub fn label(&self) -> &'static str {
        match self {
            ToolSearchPosture::Friendly => "friendly",
            ToolSearchPosture::Heavy => "heavy",
        }
    }
}

/// Classify a catalog as tool-search-friendly or heavy.
///
/// Friendly when the total token cost is at or under
/// [`TOOL_SEARCH_FRIENDLY_TOKEN_CEILING`] AND the tool count is at or under
/// [`TOOL_SEARCH_FRIENDLY_TOOL_CEILING`]; heavy otherwise. Exceeding either
/// ceiling tips the catalog into heavy because both a large token cost and
/// a large tool count are surfaces the Tool Search Tool is built to defer.
fn classify_posture(total_tokens: usize, tool_count: usize) -> ToolSearchPosture {
    if total_tokens <= TOOL_SEARCH_FRIENDLY_TOKEN_CEILING
        && tool_count <= TOOL_SEARCH_FRIENDLY_TOOL_CEILING
    {
        ToolSearchPosture::Friendly
    } else {
        ToolSearchPosture::Heavy
    }
}

/// Aggregate token cost across every tool in the catalog.
///
/// Carries both thresholds and a `exceeded_thresholds` list so callers (the
/// pretty printer, a future JSON reporter, a future quality gate) can render
/// a single decision without re-deriving it. The `posture` field
/// adds the tool-search-friendly classification derived from `total` and the
/// tool count.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogTokenReport {
    /// Sum of `tokens` across every tool.
    pub total: usize,
    /// Tokenizer name, e.g. `whitespace`.
    pub tokenizer: String,
    /// Per-tool rows in the order they appeared in `tools/list`.
    pub per_tool: Vec<ToolTokenCost>,
    /// Threshold above which a single tool is flagged.
    pub per_tool_warn_threshold: usize,
    /// Threshold above which the whole catalog is flagged.
    pub catalog_warn_threshold: usize,
    /// Human-readable reasons the report is over budget.
    pub exceeded_thresholds: Vec<String>,
    /// Tool-search-friendly posture derived from `total` and tool count
    ///. Defaults to `friendly` so older serialized reports that
    /// predate this field still deserialize.
    #[serde(default)]
    pub posture: ToolSearchPosture,
}

/// Compute the catalog token cost report for a `tools/list` response.
///
/// Each tool in `tools_list_response.tools[]` is tokenized: name +
/// description + serialized JSON of `inputSchema`.
///
/// Returns a [`CatalogTokenReport`] that the doctor handler prints in pretty
/// form and emits in JSON form for the (future) JSON reporter.
pub fn compute_catalog_tokens(
    tools_list_response: &Value,
    tokenizer: &dyn Tokenizer,
    per_tool_warn_threshold: usize,
    catalog_warn_threshold: usize,
) -> CatalogTokenReport {
    let tools = tools_list_response
        .get("tools")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    let mut per_tool = Vec::with_capacity(tools.len());
    let mut total = 0usize;
    for tool in &tools {
        let cost = score_one_tool(tool, tokenizer, per_tool_warn_threshold);
        total = total.saturating_add(cost.tokens);
        per_tool.push(cost);
    }

    let mut exceeded_thresholds = Vec::new();
    let over_per_tool: Vec<&ToolTokenCost> =
        per_tool.iter().filter(|t| t.above_threshold).collect();
    if !over_per_tool.is_empty() {
        let names: Vec<&str> = over_per_tool.iter().map(|t| t.name.as_str()).collect();
        exceeded_thresholds.push(format!(
            "{} tool(s) over {}-token per-tool guideline: {}",
            over_per_tool.len(),
            per_tool_warn_threshold,
            names.join(", ")
        ));
    }
    if total > catalog_warn_threshold {
        exceeded_thresholds.push(format!(
            "catalog total {} tokens exceeds {}-token guideline",
            total, catalog_warn_threshold
        ));
    }

    let posture = classify_posture(total, per_tool.len());

    CatalogTokenReport {
        total,
        tokenizer: tokenizer.name().to_string(),
        per_tool,
        per_tool_warn_threshold,
        catalog_warn_threshold,
        exceeded_thresholds,
        posture,
    }
}

/// Score a single `tools/list` entry: name + description + inputSchema JSON.
fn score_one_tool(
    tool: &Value,
    tokenizer: &dyn Tokenizer,
    per_tool_warn_threshold: usize,
) -> ToolTokenCost {
    let name = tool
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("<unnamed>")
        .to_owned();
    let description = tool
        .get("description")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let schema_text = tool
        .get("inputSchema")
        .map(|s| serde_json::to_string(s).unwrap_or_default())
        .unwrap_or_default();
    let combined = format!("{} {} {}", name, description, schema_text);
    let tokens = tokenizer.count(&combined);
    ToolTokenCost {
        name,
        tokens,
        above_threshold: tokens > per_tool_warn_threshold,
    }
}

/// Render a [`CatalogTokenReport`] as the human-friendly doctor block.
///
/// Output matches the  spec, with `!!` standing in for the warning
/// glyph so the file stays ASCII (no em-dashes, no Unicode warning sign).
pub fn render_report(report: &CatalogTokenReport) -> String {
    let mut out = String::new();
    out.push_str("Tool catalog context cost:\n");
    out.push_str(&format!(
        "  Total: {} tokens ({})\n",
        format_thousands(report.total),
        report.tokenizer
    ));
    out.push_str(&format!(
        "  Tool-search posture: {} ({} tool(s))\n",
        report.posture.label(),
        report.per_tool.len()
    ));
    if report.per_tool.is_empty() {
        out.push_str("  By tool: (no tools advertised)\n");
        return out;
    }
    out.push_str("  By tool:\n");
    let name_width = report
        .per_tool
        .iter()
        .map(|t| t.name.len())
        .max()
        .unwrap_or(0);
    for tool in &report.per_tool {
        let mut line = format!(
            "    {:<width$}  {} tokens",
            tool.name,
            tool.tokens,
            width = name_width
        );
        if tool.above_threshold {
            line.push_str(&format!(
                "   !! above {}-token guideline",
                report.per_tool_warn_threshold
            ));
        }
        line.push('\n');
        out.push_str(&line);
    }
    if !report.exceeded_thresholds.is_empty() {
        out.push_str("  Exceeded thresholds:\n");
        for reason in &report.exceeded_thresholds {
            out.push_str(&format!("    - {reason}\n"));
        }
    }
    out
}

/// Format a number with comma thousands separators ("1247" -> "1,247").
fn format_thousands(n: usize) -> String {
    let s = n.to_string();
    let bytes = s.as_bytes();
    let mut out = String::with_capacity(s.len() + s.len() / 3);
    for (i, c) in bytes.iter().enumerate() {
        if i > 0 && (bytes.len() - i) % 3 == 0 {
            out.push(',');
        }
        out.push(*c as char);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::matchers::budgets::WhitespaceTokenizer;
    use serde_json::json;

    fn fixture_two_tools() -> Value {
        json!({
            "tools": [
                {
                    "name": "create_issue",
                    "description": "Create a new issue in the tracker",
                    "inputSchema": {
                        "type": "object",
                        "properties": { "title": { "type": "string" } }
                    }
                },
                {
                    "name": "update_issue",
                    "description": "Update an existing issue with new fields and a long description that pushes the token count up over the per-tool threshold so we can verify the warning path",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "id": { "type": "string" },
                            "title": { "type": "string" },
                            "body": { "type": "string" }
                        }
                    }
                }
            ]
        })
    }

    #[test]
    fn totals_sum_per_tool_counts() {
        let report = compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 100, 1000);
        let summed: usize = report.per_tool.iter().map(|t| t.tokens).sum();
        assert_eq!(report.total, summed);
        assert_eq!(report.per_tool.len(), 2);
    }

    #[test]
    fn flags_tool_above_per_tool_threshold() {
        let report = compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 10, 10_000);
        let update = report
            .per_tool
            .iter()
            .find(|t| t.name == "update_issue")
            .expect("update_issue row");
        assert!(update.above_threshold);
        assert!(report
            .exceeded_thresholds
            .iter()
            .any(|r| r.contains("update_issue")));
    }

    #[test]
    fn no_warning_when_all_tools_under_threshold() {
        let report =
            compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 10_000, 10_000);
        assert!(report.per_tool.iter().all(|t| !t.above_threshold));
        assert!(report.exceeded_thresholds.is_empty());
    }

    #[test]
    fn empty_tools_list_yields_zero_total() {
        let report = compute_catalog_tokens(&json!({ "tools": [] }), &WhitespaceTokenizer, 50, 500);
        assert_eq!(report.total, 0);
        assert!(report.per_tool.is_empty());
        assert!(report.exceeded_thresholds.is_empty());
    }

    #[test]
    fn missing_tools_field_treated_as_empty() {
        let report = compute_catalog_tokens(&json!({}), &WhitespaceTokenizer, 50, 500);
        assert_eq!(report.total, 0);
        assert!(report.per_tool.is_empty());
    }

    #[test]
    fn catalog_threshold_flag_fires_on_aggregate() {
        let report = compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 10_000, 5);
        assert!(report
            .exceeded_thresholds
            .iter()
            .any(|r| r.contains("catalog total")));
    }

    #[test]
    fn render_report_contains_header_and_each_tool() {
        let report = compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 10, 10_000);
        let rendered = render_report(&report);
        assert!(rendered.contains("Tool catalog context cost:"));
        assert!(rendered.contains("create_issue"));
        assert!(rendered.contains("update_issue"));
        assert!(rendered.contains("!! above 10-token guideline"));
        // No em-dashes anywhere.
        assert!(!rendered.contains('\u{2014}'));
    }

    #[test]
    fn format_thousands_handles_small_and_large() {
        assert_eq!(format_thousands(0), "0");
        assert_eq!(format_thousands(42), "42");
        assert_eq!(format_thousands(1_247), "1,247");
        assert_eq!(format_thousands(1_000_000), "1,000,000");
    }

    /// Build a `tools/list` response with `count` tools, each carrying a
    /// `filler`-word description so we can drive the token cost up.
    fn fixture_n_tools(count: usize, filler_words: usize) -> Value {
        let description = vec!["word"; filler_words].join(" ");
        let tools: Vec<Value> = (0..count)
            .map(|i| {
                json!({
                    "name": format!("tool_{i}"),
                    "description": description,
                    "inputSchema": { "type": "object" }
                })
            })
            .collect();
        json!({ "tools": tools })
    }

    #[test]
    fn small_catalog_classifies_as_friendly() {
        // Two tools, light descriptions: well under both ceilings.
        let report = compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 100, 1000);
        assert_eq!(report.posture, ToolSearchPosture::Friendly);
    }

    #[test]
    fn many_tools_classifies_as_heavy() {
        // Twenty tools is over the ten-tool ceiling even with cheap
        // descriptions, so the surface is heavy.
        let report =
            compute_catalog_tokens(&fixture_n_tools(20, 1), &WhitespaceTokenizer, 100, 1000);
        assert_eq!(report.posture, ToolSearchPosture::Heavy);
    }

    #[test]
    fn expensive_catalog_classifies_as_heavy() {
        // A handful of tools but each description blows past the 20K-token
        // ceiling, so the surface is heavy on token cost alone.
        let report = compute_catalog_tokens(
            &fixture_n_tools(3, 8_000),
            &WhitespaceTokenizer,
            1_000_000,
            1_000_000,
        );
        assert!(report.total > TOOL_SEARCH_FRIENDLY_TOKEN_CEILING);
        assert_eq!(report.posture, ToolSearchPosture::Heavy);
    }

    #[test]
    fn empty_catalog_is_friendly() {
        let report = compute_catalog_tokens(&json!({ "tools": [] }), &WhitespaceTokenizer, 50, 500);
        assert_eq!(report.posture, ToolSearchPosture::Friendly);
    }

    #[test]
    fn render_report_includes_posture_line() {
        let report = compute_catalog_tokens(&fixture_two_tools(), &WhitespaceTokenizer, 100, 1000);
        let rendered = render_report(&report);
        assert!(rendered.contains("Tool-search posture: friendly (2 tool(s))"));
    }

    #[test]
    fn report_without_posture_field_deserializes_as_friendly() {
        // Backward-compat: a serialized report produced before  has
        // no `posture` field. Serde's default must fill it in.
        let legacy = json!({
            "total": 42,
            "tokenizer": "whitespace",
            "per_tool": [],
            "per_tool_warn_threshold": 100,
            "catalog_warn_threshold": 1000,
            "exceeded_thresholds": []
        });
        let report: CatalogTokenReport =
            serde_json::from_value(legacy).expect("legacy report deserializes");
        assert_eq!(report.posture, ToolSearchPosture::Friendly);
    }

    #[test]
    fn posture_round_trips_through_json() {
        let report =
            compute_catalog_tokens(&fixture_n_tools(20, 1), &WhitespaceTokenizer, 100, 1000);
        let json = serde_json::to_string(&report).expect("serialize");
        assert!(json.contains("\"posture\":\"heavy\""));
        let back: CatalogTokenReport = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.posture, ToolSearchPosture::Heavy);
    }
}
