//! Strict input-schema lint rules (WOR-1241).
//!
//! An under-constrained `inputSchema` lets malformed input through to the
//! server, which the runtime-fault taxonomy (arXiv:2603.05637) ties to a class
//! of parameter and type-validation faults. These rules catch that statically,
//! before a fuzz sweep finds it at runtime. Each rule inspects one tool's
//! `inputSchema` and emits a [`SchemaFinding`] with a stable `SCH-NNN` id.
//!
//! [`tighten`] is the autofix: it returns a copy of a schema with the
//! mechanical constraints added (`additionalProperties: false` and a `required`
//! listing every declared property). It never invents a type, so a property
//! with no `type` stays a finding rather than a guess.

use serde_json::{Map, Value};

use super::Severity;

/// One strict-schema finding from one rule against one tool.
#[derive(Debug, Clone, PartialEq)]
pub struct SchemaFinding {
    /// Tool name from the catalog.
    pub tool_name: String,
    /// Stable rule ID, `SCH-001` through `SCH-004`.
    pub rule_id: &'static str,
    /// Severity of the finding.
    pub severity: Severity,
    /// Human-readable explanation.
    pub message: String,
}

/// Aggregate report from linting every tool's `inputSchema` in a catalog.
#[derive(Debug, Clone, Default)]
pub struct SchemaLintReport {
    /// Findings in tool-then-rule order.
    pub findings: Vec<SchemaFinding>,
    /// Number of tools examined.
    pub tools_checked: usize,
}

impl SchemaLintReport {
    /// Count of findings at the [`Severity::Critical`] level.
    pub fn critical_count(&self) -> usize {
        self.findings
            .iter()
            .filter(|f| f.severity == Severity::Critical)
            .count()
    }

    /// Count of findings at the [`Severity::Warning`] level.
    pub fn warning_count(&self) -> usize {
        self.findings
            .iter()
            .filter(|f| f.severity == Severity::Warning)
            .count()
    }
}

/// Lint every tool's `inputSchema` in a `tools/list` catalog.
pub fn lint_catalog(catalog: &Value) -> SchemaLintReport {
    let mut report = SchemaLintReport::default();
    let tools = catalog.get("tools").and_then(Value::as_array);
    for tool in tools.into_iter().flatten() {
        report.tools_checked += 1;
        let name = tool
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or("<unnamed>")
            .to_string();
        if let Some(schema) = tool.get("inputSchema") {
            analyze_schema(&name, schema, &mut report.findings);
        }
    }
    report
}

/// Run the four rules against one tool's input schema.
fn analyze_schema(tool: &str, schema: &Value, out: &mut Vec<SchemaFinding>) {
    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
        return;
    };
    if properties.is_empty() {
        return;
    }
    let finding = |rule_id, severity, message: String| SchemaFinding {
        tool_name: tool.to_string(),
        rule_id,
        severity,
        message,
    };

    // SCH-001: no property is marked required.
    let required_empty = schema
        .get("required")
        .and_then(Value::as_array)
        .map(|r| r.is_empty())
        .unwrap_or(true);
    if required_empty {
        out.push(finding(
            "SCH-001",
            Severity::Warning,
            "no properties are marked `required`; the server cannot rely on any argument being present".to_string(),
        ));
    }

    // SCH-002: additionalProperties is not false.
    if schema.get("additionalProperties") != Some(&Value::Bool(false)) {
        out.push(finding(
            "SCH-002",
            Severity::Warning,
            "`additionalProperties` is not false; unexpected fields are accepted silently"
                .to_string(),
        ));
    }

    // SCH-003 (critical) and SCH-004 (warning), per property.
    for (name, prop) in properties {
        let typed = prop.get("type").is_some() || prop.get("enum").is_some();
        if !typed {
            out.push(finding(
                "SCH-003",
                Severity::Critical,
                format!("property `{name}` declares neither `type` nor `enum`, so any value is accepted"),
            ));
        }
        if prop.get("type").and_then(Value::as_str) == Some("string")
            && prop.get("maxLength").is_none()
        {
            out.push(finding(
                "SCH-004",
                Severity::Warning,
                format!("string property `{name}` has no `maxLength`; input size is unbounded"),
            ));
        }
        if prop.get("type").and_then(Value::as_str) == Some("array")
            && prop.get("maxItems").is_none()
        {
            out.push(finding(
                "SCH-004",
                Severity::Warning,
                format!("array property `{name}` has no `maxItems`; input size is unbounded"),
            ));
        }
    }
}

/// Return a tightened copy of an input schema: the mechanical fixes for SCH-001
/// and SCH-002, applied recursively to every object schema with `properties`.
///
/// It sets `additionalProperties: false` and adds a `required` array listing
/// every declared property (sorted) when none is present. It deliberately does
/// not invent a `type`, a `maxLength`, or a `maxItems`, since the right value is
/// the author's to choose; SCH-003 and SCH-004 stay as findings.
pub fn tighten(schema: &Value) -> Value {
    let Value::Object(map) = schema else {
        return schema.clone();
    };
    let mut out: Map<String, Value> = Map::new();
    for (key, value) in map {
        // Recurse into the property schemas so nested objects tighten too.
        if key == "properties" {
            if let Value::Object(props) = value {
                let tightened: Map<String, Value> =
                    props.iter().map(|(k, v)| (k.clone(), tighten(v))).collect();
                out.insert(key.clone(), Value::Object(tightened));
                continue;
            }
        }
        out.insert(key.clone(), value.clone());
    }

    if let Some(Value::Object(props)) = out.get("properties") {
        let has_props = !props.is_empty();
        if has_props {
            let required_present = out
                .get("required")
                .and_then(Value::as_array)
                .map(|r| !r.is_empty())
                .unwrap_or(false);
            if !required_present {
                let mut names: Vec<String> = props.keys().cloned().collect();
                names.sort();
                out.insert(
                    "required".to_string(),
                    Value::Array(names.into_iter().map(Value::String).collect()),
                );
            }
            out.insert("additionalProperties".to_string(), Value::Bool(false));
        }
    }
    Value::Object(out)
}

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

    fn catalog(input_schema: Value) -> Value {
        json!({ "tools": [{ "name": "search", "inputSchema": input_schema }] })
    }

    #[test]
    fn flags_missing_required_and_open_additional_properties() {
        let report = lint_catalog(&catalog(json!({
            "type": "object",
            "properties": { "query": { "type": "string", "maxLength": 200 } }
        })));
        let ids: Vec<&str> = report.findings.iter().map(|f| f.rule_id).collect();
        assert!(ids.contains(&"SCH-001"), "{ids:?}");
        assert!(ids.contains(&"SCH-002"), "{ids:?}");
    }

    #[test]
    fn flags_untyped_property_as_critical() {
        let report = lint_catalog(&catalog(json!({
            "type": "object",
            "required": ["arg"],
            "additionalProperties": false,
            "properties": { "arg": {} }
        })));
        assert_eq!(report.critical_count(), 1);
        assert!(report.findings.iter().any(|f| f.rule_id == "SCH-003"));
    }

    #[test]
    fn flags_unbounded_string_and_array() {
        let report = lint_catalog(&catalog(json!({
            "type": "object",
            "required": ["q", "tags"],
            "additionalProperties": false,
            "properties": {
                "q": { "type": "string" },
                "tags": { "type": "array", "items": { "type": "string" } }
            }
        })));
        let sch004 = report
            .findings
            .iter()
            .filter(|f| f.rule_id == "SCH-004")
            .count();
        assert_eq!(sch004, 2, "one for the string, one for the array");
    }

    #[test]
    fn a_fully_constrained_schema_is_clean() {
        let report = lint_catalog(&catalog(json!({
            "type": "object",
            "required": ["query"],
            "additionalProperties": false,
            "properties": { "query": { "type": "string", "maxLength": 200 } }
        })));
        assert!(report.findings.is_empty(), "{:?}", report.findings);
    }

    #[test]
    fn tighten_adds_required_and_closes_additional_properties() {
        let loose = json!({
            "type": "object",
            "properties": {
                "query": { "type": "string", "maxLength": 100 },
                "limit": { "type": "integer" }
            }
        });
        let tightened = tighten(&loose);
        assert_eq!(tightened["additionalProperties"], json!(false));
        assert_eq!(tightened["required"], json!(["limit", "query"]));
    }

    #[test]
    fn tighten_recurses_into_nested_object_properties() {
        let loose = json!({
            "type": "object",
            "properties": {
                "filter": {
                    "type": "object",
                    "properties": { "lang": { "type": "string", "maxLength": 5 } }
                }
            }
        });
        let tightened = tighten(&loose);
        assert_eq!(
            tightened["properties"]["filter"]["additionalProperties"],
            json!(false)
        );
        assert_eq!(
            tightened["properties"]["filter"]["required"],
            json!(["lang"])
        );
    }

    #[test]
    fn tighten_leaves_an_untyped_property_untyped() {
        let loose = json!({ "type": "object", "properties": { "arg": {} } });
        let tightened = tighten(&loose);
        // No type is invented; the property stays empty (SCH-003 stays a finding).
        assert_eq!(tightened["properties"]["arg"], json!({}));
    }

    #[test]
    fn tighten_keeps_an_existing_required_list() {
        let loose = json!({
            "type": "object",
            "required": ["query"],
            "properties": { "query": { "type": "string", "maxLength": 10 }, "limit": { "type": "integer" } }
        });
        let tightened = tighten(&loose);
        // The author's required list is preserved, not overwritten.
        assert_eq!(tightened["required"], json!(["query"]));
    }
}
