//! Negative-path JSON-RPC error conformance (WOR-1239).
//!
//! A robust server rejects a bad request with a well-formed JSON-RPC error
//! rather than accepting it silently, crashing, or hanging. The MCP runtime
//! fault taxonomies (arXiv:2606.05339, arXiv:2603.05637) catalog these as
//! recurring faults. This module defines a small, taxonomy-keyed probe set and
//! the rule for whether each probe's outcome met the contract. The probe
//! generation and the classification are pure; the live dispatch lives in the
//! executor.

use serde_json::{json, Map, Value};

/// The metrics-object key prefix the runner resolves these targets under.
pub const METRICS_PREFIX: &str = "negative_path";

/// The synthesized tool name used by the unknown-tool probe.
pub const UNKNOWN_TOOL_NAME: &str = "__mcptest_nonexistent_tool__";

/// One negative-path probe: a class of bad request keyed to a fault taxonomy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeKind {
    /// Call a tool that does not exist. Expect a method-not-found-class error.
    UnknownTool,
    /// Omit a required argument. Expect an invalid-params-class error.
    MissingRequired,
    /// Send a wrong-typed argument. Expect an invalid-params-class error.
    WrongType,
    /// Send an unexpected field. Expect rejection when additionalProperties is
    /// false.
    ExtraField,
    /// Send an oversized argument. Expect an error or a result within the
    /// timeout, never a hang.
    Oversized,
}

impl ProbeKind {
    /// The full set of probes, in a stable order.
    pub const ALL: [ProbeKind; 5] = [
        ProbeKind::UnknownTool,
        ProbeKind::MissingRequired,
        ProbeKind::WrongType,
        ProbeKind::ExtraField,
        ProbeKind::Oversized,
    ];

    /// The stable lower-case label used in YAML `checks:` and reports.
    pub fn label(self) -> &'static str {
        match self {
            ProbeKind::UnknownTool => "unknown_tool",
            ProbeKind::MissingRequired => "missing_required",
            ProbeKind::WrongType => "wrong_type",
            ProbeKind::ExtraField => "extra_field",
            ProbeKind::Oversized => "oversized",
        }
    }

    /// Parse a label back into a probe kind.
    pub fn from_label(label: &str) -> Option<ProbeKind> {
        ProbeKind::ALL.into_iter().find(|k| k.label() == label)
    }

    /// The taxonomy id and citation this probe maps to.
    pub fn taxonomy_id(self) -> &'static str {
        match self {
            ProbeKind::UnknownTool => "FAULT-PROTO-UNKNOWN-METHOD (2606.05339)",
            ProbeKind::MissingRequired => "FAULT-SCHEMA-MISSING-REQUIRED (2603.05637)",
            ProbeKind::WrongType => "FAULT-SCHEMA-TYPE-MISMATCH (2603.05637)",
            ProbeKind::ExtraField => "FAULT-SCHEMA-UNEXPECTED-FIELD (2603.05637)",
            ProbeKind::Oversized => "FAULT-INPUT-OVERSIZED (2606.05339)",
        }
    }
}

/// How the server responded to a probe, abstracted from the transport.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseClass {
    /// A JSON-RPC error or a tool-level error result. The server rejected it.
    Rejected,
    /// A normal successful result. The server accepted the bad input.
    Accepted,
    /// The transport dropped or the server crashed.
    Crashed,
    /// The call did not return within the timeout.
    Hung,
}

/// Whether a probe's response class met the negative-path contract.
///
/// Most probes require an explicit rejection; the oversized probe only requires
/// that the call returns at all (a rejection or an acceptance), never a hang or
/// a crash.
pub fn probe_passed(kind: ProbeKind, class: ResponseClass) -> bool {
    match kind {
        ProbeKind::Oversized => matches!(class, ResponseClass::Rejected | ResponseClass::Accepted),
        _ => class == ResponseClass::Rejected,
    }
}

/// The request a probe sends: the tool to call and its arguments. `None` when
/// the probe does not apply to the tool (for example a missing-required probe
/// on a tool with no required arguments).
#[derive(Debug, Clone, PartialEq)]
pub struct ProbeRequest {
    /// The tool name to call (the real tool, or the synthesized unknown name).
    pub tool: String,
    /// The (bad) arguments to send.
    pub args: Value,
}

/// Build the request for a probe against a tool, or `None` when inapplicable.
pub fn build_request(
    kind: ProbeKind,
    tool: &str,
    base_args: &Value,
    input_schema: &Value,
) -> Option<ProbeRequest> {
    let base = base_object(base_args, input_schema);
    match kind {
        ProbeKind::UnknownTool => Some(ProbeRequest {
            tool: UNKNOWN_TOOL_NAME.to_string(),
            args: Value::Object(base),
        }),
        ProbeKind::MissingRequired => {
            let field = first_required(input_schema)?;
            let mut args = base;
            args.remove(&field);
            Some(ProbeRequest {
                tool: tool.to_string(),
                args: Value::Object(args),
            })
        }
        ProbeKind::WrongType => {
            let (name, prop) = first_typed_property(input_schema)?;
            let mut args = base;
            args.insert(name, wrong_typed(&prop));
            Some(ProbeRequest {
                tool: tool.to_string(),
                args: Value::Object(args),
            })
        }
        ProbeKind::ExtraField => {
            let mut args = base;
            args.insert("__mcptest_unexpected__".to_string(), json!(true));
            Some(ProbeRequest {
                tool: tool.to_string(),
                args: Value::Object(args),
            })
        }
        ProbeKind::Oversized => {
            let mut args = base;
            let field = first_string_property(input_schema)
                .or_else(|| first_required(input_schema))
                .unwrap_or_else(|| "input".to_string());
            args.insert(field, json!("A".repeat(100_000)));
            Some(ProbeRequest {
                tool: tool.to_string(),
                args: Value::Object(args),
            })
        }
    }
}

/// The outcome of one probe: whether it passed and a short detail.
#[derive(Debug, Clone, PartialEq)]
pub struct ProbeResult {
    /// The probe label.
    pub probe: &'static str,
    /// The taxonomy id and citation.
    pub taxonomy_id: &'static str,
    /// Whether the contract was met.
    pub passed: bool,
    /// Short detail, populated on failure.
    pub detail: String,
}

/// Aggregate report over the probes that ran.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NegativePathReport {
    /// One result per probe that ran (probes that did not apply are skipped).
    pub results: Vec<ProbeResult>,
}

impl NegativePathReport {
    /// Number of probes that ran.
    pub fn checks_run(&self) -> usize {
        self.results.len()
    }

    /// Number of probes that failed the contract.
    pub fn failures(&self) -> usize {
        self.results.iter().filter(|r| !r.passed).count()
    }

    /// True when every probe that ran met the contract.
    pub fn clean(&self) -> bool {
        self.failures() == 0
    }
}

/// Build the metrics object the `negative_path.*` assertions resolve against.
pub fn metrics_object(report: &NegativePathReport) -> Value {
    json!({
        "negative_path": {
            "checks_run": report.checks_run(),
            "failures": report.failures(),
            "gate_passed": if report.clean() { 1 } else { 0 },
        }
    })
}

/// The default gate when a `negative_path:` block declares no `expect:`: no
/// probe failed the contract (`negative_path.failures <= 0`).
pub fn default_expect() -> Vec<crate::suite::Assertion> {
    use crate::suite::Matcher;
    vec![crate::suite::Assertion {
        target: "negative_path.failures".to_string(),
        matcher: Matcher::Schema(json!({ "maximum": 0 })),
        weight: 1.0,
        name: None,
        transform: None,
    }]
}

// --- helpers ---

/// The base object to perturb: the test's args, else a placeholder built from
/// the schema's required fields.
fn base_object(base_args: &Value, input_schema: &Value) -> Map<String, Value> {
    if let Value::Object(map) = base_args {
        if !map.is_empty() {
            return map.clone();
        }
    }
    let mut out = Map::new();
    for field in required_fields(input_schema) {
        let placeholder = input_schema
            .get("properties")
            .and_then(|p| p.get(&field))
            .map(placeholder_for)
            .unwrap_or_else(|| json!("x"));
        out.insert(field, placeholder);
    }
    out
}

/// A type-appropriate placeholder for a property schema.
fn placeholder_for(prop: &Value) -> Value {
    match prop.get("type").and_then(Value::as_str) {
        Some("integer") | Some("number") => json!(1),
        Some("boolean") => json!(true),
        Some("array") => json!([]),
        Some("object") => json!({}),
        _ => json!("x"),
    }
}

/// The schema's `required` list as owned strings.
fn required_fields(input_schema: &Value) -> Vec<String> {
    input_schema
        .get("required")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|v| v.as_str().map(str::to_string))
        .collect()
}

/// The first required field, if any.
fn first_required(input_schema: &Value) -> Option<String> {
    required_fields(input_schema).into_iter().next()
}

/// The first property declaring a `type`, with its schema.
fn first_typed_property(input_schema: &Value) -> Option<(String, Value)> {
    input_schema
        .get("properties")
        .and_then(Value::as_object)
        .into_iter()
        .flatten()
        .find(|(_, p)| p.get("type").is_some())
        .map(|(k, p)| (k.clone(), p.clone()))
}

/// The first property declaring `type: string`.
fn first_string_property(input_schema: &Value) -> Option<String> {
    input_schema
        .get("properties")
        .and_then(Value::as_object)
        .into_iter()
        .flatten()
        .find(|(_, p)| p.get("type").and_then(Value::as_str) == Some("string"))
        .map(|(k, _)| k.clone())
}

/// A value of the wrong type for a property schema.
fn wrong_typed(prop: &Value) -> Value {
    match prop.get("type").and_then(Value::as_str) {
        Some("string") => json!(12345),
        Some("integer") | Some("number") => json!("not-a-number"),
        Some("boolean") => json!("maybe"),
        Some("array") => json!({ "not": "an array" }),
        Some("object") => json!([1, 2, 3]),
        _ => json!(true),
    }
}

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

    fn schema() -> Value {
        json!({
            "type": "object",
            "required": ["query"],
            "additionalProperties": false,
            "properties": {
                "query": { "type": "string" },
                "limit": { "type": "integer" }
            }
        })
    }

    #[test]
    fn most_probes_require_an_explicit_rejection() {
        assert!(probe_passed(
            ProbeKind::UnknownTool,
            ResponseClass::Rejected
        ));
        assert!(!probe_passed(
            ProbeKind::UnknownTool,
            ResponseClass::Accepted
        ));
        assert!(!probe_passed(
            ProbeKind::MissingRequired,
            ResponseClass::Hung
        ));
        assert!(!probe_passed(ProbeKind::WrongType, ResponseClass::Crashed));
    }

    #[test]
    fn the_oversized_probe_accepts_a_rejection_or_an_acceptance() {
        assert!(probe_passed(ProbeKind::Oversized, ResponseClass::Accepted));
        assert!(probe_passed(ProbeKind::Oversized, ResponseClass::Rejected));
        assert!(!probe_passed(ProbeKind::Oversized, ResponseClass::Hung));
        assert!(!probe_passed(ProbeKind::Oversized, ResponseClass::Crashed));
    }

    #[test]
    fn unknown_tool_probe_calls_a_synthesized_name() {
        let req = build_request(ProbeKind::UnknownTool, "search", &json!({}), &schema()).unwrap();
        assert_eq!(req.tool, UNKNOWN_TOOL_NAME);
    }

    #[test]
    fn missing_required_probe_drops_the_required_field() {
        let req =
            build_request(ProbeKind::MissingRequired, "search", &json!({}), &schema()).unwrap();
        assert_eq!(req.tool, "search");
        assert!(req.args.get("query").is_none());
    }

    #[test]
    fn missing_required_probe_is_skipped_without_a_required_field() {
        let loose = json!({ "type": "object", "properties": { "q": { "type": "string" } } });
        assert!(build_request(ProbeKind::MissingRequired, "search", &json!({}), &loose).is_none());
    }

    #[test]
    fn wrong_type_probe_sends_a_mismatched_value() {
        let req = build_request(ProbeKind::WrongType, "search", &json!({}), &schema()).unwrap();
        // Whichever typed property was chosen now holds a value of the wrong
        // JSON type: the string `query` becomes a number, or the integer `limit`
        // becomes a string. Order-independent so it does not depend on the map.
        let query_wrong = req.args.get("query").map(Value::is_number).unwrap_or(false);
        let limit_wrong = req.args.get("limit").map(Value::is_string).unwrap_or(false);
        assert!(
            query_wrong || limit_wrong,
            "a typed property was given a wrong-typed value: {}",
            req.args
        );
    }

    #[test]
    fn extra_field_probe_adds_an_unexpected_key() {
        let req = build_request(ProbeKind::ExtraField, "search", &json!({}), &schema()).unwrap();
        assert_eq!(req.args["__mcptest_unexpected__"], json!(true));
    }

    #[test]
    fn report_counts_and_gates() {
        let report = NegativePathReport {
            results: vec![
                ProbeResult {
                    probe: "unknown_tool",
                    taxonomy_id: "x",
                    passed: true,
                    detail: String::new(),
                },
                ProbeResult {
                    probe: "wrong_type",
                    taxonomy_id: "y",
                    passed: false,
                    detail: "accepted".into(),
                },
            ],
        };
        assert_eq!(report.checks_run(), 2);
        assert_eq!(report.failures(), 1);
        assert!(!report.clean());
        let metrics = metrics_object(&report);
        assert_eq!(metrics["negative_path"]["failures"], json!(1));
        assert_eq!(metrics["negative_path"]["gate_passed"], json!(0));
    }

    #[test]
    fn from_label_round_trips_every_probe() {
        for kind in ProbeKind::ALL {
            assert_eq!(ProbeKind::from_label(kind.label()), Some(kind));
        }
        assert!(ProbeKind::from_label("nope").is_none());
    }
}
