//! The `tool_quality:` check block.
//!
//! Each entry connects to a server, pulls its `tools/list` catalog, scores it
//! with the deterministic TDQS heuristics ([`crate::lint::tdqs::score_catalog`]),
//! and exposes the aggregate and per-tool scores as assertable targets:
//! `min_score`, `mean_score`, and `tool["<name>"].score`.  adds the
//! DESC-NNN lint counts from [`crate::lint::tool_descriptions`] as two more
//! targets, `critical_count` and `warning_count`, so a suite can fail on a
//! research-backed critical finding even when the averaged scores look fine.
//! The LLM `quality_advisory` is deliberately out of scope: a CI gate stays
//! deterministic, so the advisory can be added later behind an opt-in flag
//! without changing this block.

use serde_json::{json, Value};

use crate::lint::tdqs::{score_catalog, TdqsReport};
use crate::lint::tool_descriptions::{default_rules, lint_catalog, LintReport};
use crate::suite::Assertion;

/// One parsed `tool_quality:` entry.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolQualityCheck {
    /// Human-readable name; the report row name for this entry.
    pub name: String,
    /// Server key (into the suite's `servers:` map) whose catalog is scored.
    pub server: String,
    /// Assertions over the targets. Empty means "apply the defaults".
    pub expect: Vec<Assertion>,
}

/// Default minimum-score floor when the entry declares no `expect:`. One badly
/// written tool drags the catalog, so the worst tool must clear a basic bar.
const DEFAULT_MIN_SCORE: f64 = 0.50;
/// Default mean-score floor when the entry declares no `expect:`.
const DEFAULT_MEAN_SCORE: f64 = 0.70;
/// Default ceiling on critical lint findings when the entry declares no
/// `expect:`. A DESC-NNN critical means an agent will likely
/// struggle to use the tool reliably, so any critical fails the gate.
const DEFAULT_MAX_CRITICAL: u64 = 0;

/// Build the targets object the `tool_quality:` assertions resolve against.
///
/// Shape: `{ "min_score": <num|null>, "mean_score": <num|null>, "tool": {
/// "<name>": { "score": <num> }, ... }, "critical_count": <num>,
/// "warning_count": <num> }`. The aggregate scores are `null` for an empty
/// catalog (nothing to aggregate), so an assertion on them fails with a clear
/// "absent" diff rather than a silent pass. The per-tool `tool[...]` map lets a
/// power user gate one tool by name. `critical_count` / `warning_count` are the
/// DESC-NNN lint totals over the same catalog; both are `0` for an empty one.
/// `smells` groups those findings into named categories (`smells.underspecified`,
/// `smells.missing_examples`, ...) with `smell_total` as their sum, so a suite
/// can gate on a research-named smell rather than a raw rule id.
pub fn metrics_object(report: &TdqsReport, lint: &LintReport) -> Value {
    let mut tools = serde_json::Map::new();
    for tool in &report.tools {
        tools.insert(tool.name.clone(), json!({ "score": tool.score }));
    }
    let smells = crate::tool_bench::smells::summarize(lint);
    json!({
        "min_score": report.min_tool_score(),
        "mean_score": report.mean_tool_score(),
        "tool": Value::Object(tools),
        "critical_count": lint.critical_count(),
        "warning_count": lint.warning_count(),
        "smells": crate::tool_bench::smells::metrics_object(&smells),
        "smell_total": smells.values().sum::<usize>(),
    })
}

/// The default assertion set applied when an entry declares no `expect:`
/// block: `min_score >= 0.50`, `mean_score >= 0.70`, and
/// `critical_count <= 0` (fail on any critical lint finding).
pub fn default_expect() -> Vec<Assertion> {
    vec![
        metric_assertion("min_score", json!({ "minimum": DEFAULT_MIN_SCORE })),
        metric_assertion("mean_score", json!({ "minimum": DEFAULT_MEAN_SCORE })),
        metric_assertion("critical_count", json!({ "maximum": DEFAULT_MAX_CRITICAL })),
    ]
}

/// Build a `schema`-matcher assertion over a numeric target, reusing the same
/// assertion shape a user would write.
fn metric_assertion(target: &str, schema: Value) -> Assertion {
    use crate::suite::Matcher;
    Assertion {
        target: target.to_string(),
        matcher: Matcher::Schema(schema),
        weight: 1.0,
        name: None,
        transform: None,
    }
}

/// Evaluate one tool-quality entry against an already-fetched `tools/list`
/// catalog.
///
/// `catalog` is the JSON-RPC `tools/list` result (an object carrying a `tools`
/// array). The connection and the catalog fetch are the caller's job (they
/// need the live pool); this function is the pure scoring-plus-assertion half,
/// so it is unit-testable without a server. Applies the entry's `expect:` or
/// the defaults when it declared none, and folds the result into a
/// [`super::CheckOutcome`].
pub fn evaluate(check: &ToolQualityCheck, catalog: &Value) -> super::CheckOutcome {
    let report = score_catalog(catalog);
    // Run the DESC-NNN lint over the same catalog so the
    // critical_count / warning_count targets resolve. `default_rules` owns the
    // boxes; borrow them as trait objects for `lint_catalog`.
    let rules = default_rules();
    let rule_refs: Vec<&dyn crate::lint::tool_descriptions::Rule> =
        rules.iter().map(|r| r.as_ref()).collect();
    let lint = lint_catalog(catalog, &rule_refs);
    // Strict input-schema lint over the same catalog, exposed as the
    // `schema_warnings` and `schema_criticals` targets so a suite can gate on an
    // under-constrained schema (SCH-NNN) alongside the description score.
    let schema_lint = crate::lint::tool_schema::lint_catalog(catalog);
    let mut metrics = metrics_object(&report, &lint);
    if let Value::Object(map) = &mut metrics {
        map.insert("schema_warnings".into(), json!(schema_lint.warning_count()));
        map.insert(
            "schema_criticals".into(),
            json!(schema_lint.critical_count()),
        );
    }

    let assertions: Vec<Assertion> = if check.expect.is_empty() {
        default_expect()
    } else {
        check.expect.clone()
    };

    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = crate::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "assertion #{i} (`{}`) failed: {reason}",
                    assertion.target
                ));
            }
        }
    }

    super::CheckOutcome {
        name: check.name.clone(),
        passed: failure.is_none(),
        failure,
    }
}

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

    /// A catalog with one well-documented tool and one bare tool, so the
    /// per-tool scores spread and the min drags below the mean.
    fn mixed_catalog() -> Value {
        json!({
            "tools": [
                {
                    "name": "search",
                    "description": "Search the corpus and return the top matching documents with scores.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "query": { "type": "string", "description": "The search query string." }
                        }
                    },
                    "annotations": {}
                },
                {
                    "name": "x",
                    "description": "",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "arg": {}
                        }
                    }
                }
            ]
        })
    }

    fn check(expect: Vec<Assertion>) -> ToolQualityCheck {
        ToolQualityCheck {
            name: "catalog quality".into(),
            server: "local".into(),
            expect,
        }
    }

    /// Lint the same catalog the TDQS report covers, so a test can build the
    /// full metrics object the evaluator does.
    fn lint_of(catalog: &Value) -> LintReport {
        let rules = default_rules();
        let rule_refs: Vec<&dyn crate::lint::tool_descriptions::Rule> =
            rules.iter().map(|r| r.as_ref()).collect();
        lint_catalog(catalog, &rule_refs)
    }

    #[test]
    fn metrics_object_exposes_aggregate_and_per_tool_scores() {
        let catalog = mixed_catalog();
        let report = score_catalog(&catalog);
        let obj = metrics_object(&report, &lint_of(&catalog));
        // The aggregate targets resolve to numbers.
        assert!(resolve_target(&obj, "min_score").is_number());
        assert!(resolve_target(&obj, "mean_score").is_number());
        // The per-tool target resolves by name.
        assert!(resolve_target(&obj, "tool[\"search\"].score").is_number());
        assert!(resolve_target(&obj, "tool[\"x\"].score").is_number());
        // The lint counts resolve as numbers too.
        assert!(resolve_target(&obj, "critical_count").is_number());
        assert!(resolve_target(&obj, "warning_count").is_number());
    }

    #[test]
    fn empty_catalog_aggregates_are_null() {
        let catalog = json!({ "tools": [] });
        let report = score_catalog(&catalog);
        let obj = metrics_object(&report, &lint_of(&catalog));
        assert_eq!(resolve_target(&obj, "min_score"), Value::Null);
        assert_eq!(resolve_target(&obj, "mean_score"), Value::Null);
        // No tools means no findings: both counts are zero, not null.
        assert_eq!(resolve_target(&obj, "critical_count"), json!(0));
        assert_eq!(resolve_target(&obj, "warning_count"), json!(0));
    }

    #[test]
    fn default_expect_floors_min_mean_and_critical_count() {
        let defaults = default_expect();
        let targets: Vec<&str> = defaults.iter().map(|a| a.target.as_str()).collect();
        assert_eq!(targets, vec!["min_score", "mean_score", "critical_count"]);
    }

    #[test]
    fn a_bare_tool_drags_the_min_below_the_default_floor() {
        // The "x" tool is undocumented, so min_score is far below 0.50 and the
        // default min_score gate fails.
        let outcome = evaluate(&check(Vec::new()), &mixed_catalog());
        assert!(!outcome.passed, "{outcome:?}");
        assert!(outcome.failure.unwrap().contains("min_score"));
    }

    #[test]
    fn a_well_documented_catalog_passes_the_defaults() {
        let catalog = json!({
            "tools": [
                {
                    "name": "search",
                    "description": "Search the corpus and return the top matching documents with scores.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "query": { "type": "string", "description": "The search query string to match against documents." }
                        }
                    },
                    "outputSchema": { "type": "object" },
                    "annotations": {}
                },
                {
                    "name": "fetch_document",
                    "description": "Fetch one document by id and return its full text content.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "id": { "type": "string", "description": "The unique document identifier to fetch." }
                        }
                    },
                    "outputSchema": { "type": "object" },
                    "annotations": {}
                }
            ]
        });
        let outcome = evaluate(&check(Vec::new()), &catalog);
        assert!(outcome.passed, "{outcome:?}");
    }

    #[test]
    fn a_critical_lint_finding_fails_the_default_critical_count_gate() {
        // DESC-003 is a critical: the description equals the tool name. The
        // name is 24 chars (past the DESC-001 floor) and the tool is otherwise
        // documented, so every TDQS signal scores 1.0 and min/mean pass; the
        // critical_count default (<= 0) is what fails the gate.
        let catalog = json!({
            "tools": [
                {
                    "name": "summarize_the_documents",
                    "description": "summarize_the_documents",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "id": { "type": "string", "description": "The document id to summarize and return." }
                        }
                    },
                    "outputSchema": { "type": "object" },
                    "annotations": {}
                }
            ]
        });
        let outcome = evaluate(&check(Vec::new()), &catalog);
        assert!(!outcome.passed, "{outcome:?}");
        assert!(outcome.failure.unwrap().contains("critical_count"));
    }

    #[test]
    fn power_user_can_gate_one_tool_by_name() {
        // Gate only the well-written tool; it should clear a 0.6 floor even
        // though the catalog's min (the bare tool) would not.
        let expect = vec![metric_assertion(
            "tool[\"search\"].score",
            json!({ "minimum": 0.6 }),
        )];
        let outcome = evaluate(&check(expect), &mixed_catalog());
        assert!(outcome.passed, "{outcome:?}");
    }
}
