//! Schema-driven input fuzzing for tool calls (WOR-1238).
//!
//! Why this module exists: `faults:` injects transport faults (hang, wedged,
//! slow) into an agent run, and the evil-server fixture hardens a client. Neither
//! feeds malformed input to the server under test. The MCP runtime-fault
//! taxonomies (arXiv:2606.05339, arXiv:2603.05637) isolate parameter and
//! type-validation faults as a recurring server failure mode. This module
//! derives malformed, boundary, and type-confused argument cases from a tool's
//! `inputSchema` and checks the server fails cleanly: a well-formed JSON-RPC
//! error or a valid result, never a crash, a hang, or a leaked internal detail.
//!
//! The generator here is pure and deterministic: the same schema and seed yield
//! the same case set, so a fuzz run is reproducible and CI-bounded. The live
//! dispatch and the per-call timeout live in the executor; this module owns case
//! generation, outcome classification, and the report-plus-target shape that
//! mirrors [`crate::eval::metamorphic`].

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

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

/// Default number of cases when a `fuzz:` block omits `cases:`.
pub const DEFAULT_CASES: usize = 64;

/// Default per-call timeout when a `fuzz:` block omits `max_call_ms:`.
pub const DEFAULT_MAX_CALL_MS: u64 = 2000;

/// One generated fuzz case: a human-readable label and the arguments to send.
#[derive(Debug, Clone, PartialEq)]
pub struct FuzzCase {
    /// Stable label naming the mutation, for example `omit_required:query`.
    pub label: String,
    /// The (malformed) arguments to send for this case.
    pub args: Value,
}

/// How one case came back from the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FuzzClass {
    /// The server returned a valid result or a clean JSON-RPC error. Good.
    Clean,
    /// The transport dropped or the server died (a panic, a closed pipe).
    Crash,
    /// The call did not return within the per-call timeout.
    Hang,
    /// The server returned a malformed or unparseable JSON-RPC envelope.
    ProtocolViolation,
}

/// The outcome of one dispatched case: its class and whether the response leaked
/// an internal detail (a stack trace, a file path, a secret-shaped token).
#[derive(Debug, Clone, PartialEq)]
pub struct CaseResult {
    /// The case label this outcome belongs to.
    pub label: String,
    /// How the call came back.
    pub class: FuzzClass,
    /// True when the response text matched a leak heuristic.
    pub leaked: bool,
}

/// Builds the fuzz cases for a tool from its `inputSchema` and a base argument
/// object, deterministically seeded.
///
/// Cases are enumerated in a stable order (structural cases first, then one
/// group per declared property), then, when there are more than `max_cases`, a
/// seed-dependent window is taken so a smaller budget still varies with the
/// seed. The same schema, base, seed, and budget always yield the same cases.
pub fn generate_cases(
    input_schema: &Value,
    base_args: &Value,
    seed: u64,
    max_cases: usize,
) -> Vec<FuzzCase> {
    let base = baseline(input_schema, base_args);
    let mut cases = vec![
        FuzzCase {
            label: "args_not_object".to_string(),
            args: json!("mcptest-fuzz-not-an-object"),
        },
        FuzzCase {
            label: "args_empty_object".to_string(),
            args: json!({}),
        },
    ];
    for (name, prop) in properties(input_schema) {
        property_cases(
            &base,
            name,
            prop,
            is_required(input_schema, name),
            &mut cases,
        );
    }
    if input_schema.get("additionalProperties") == Some(&Value::Bool(false)) {
        cases.push(FuzzCase {
            label: "unexpected_field".to_string(),
            args: insert_field(&base, "__mcptest_unexpected__", json!(true)),
        });
    }
    apply_budget(cases, seed, max_cases.max(1))
}

/// Cases for one declared property: omit (when required), wrong type, null, and
/// a size boundary appropriate to the declared type.
fn property_cases(base: &Value, name: &str, prop: &Value, required: bool, out: &mut Vec<FuzzCase>) {
    if required {
        out.push(FuzzCase {
            label: format!("omit_required:{name}"),
            args: remove_field(base, name),
        });
    }
    out.push(FuzzCase {
        label: format!("wrong_type:{name}"),
        args: insert_field(base, name, wrong_typed(prop)),
    });
    out.push(FuzzCase {
        label: format!("null:{name}"),
        args: insert_field(base, name, Value::Null),
    });
    match declared_type(prop) {
        Some("string") => out.push(FuzzCase {
            label: format!("oversize_string:{name}"),
            args: insert_field(base, name, json!("A".repeat(10_000))),
        }),
        Some("array") => out.push(FuzzCase {
            label: format!("oversize_array:{name}"),
            args: insert_field(base, name, Value::Array(vec![json!(0); 1000])),
        }),
        Some("integer") | Some("number") => out.push(FuzzCase {
            label: format!("extreme_number:{name}"),
            args: insert_field(base, name, json!(i64::MAX)),
        }),
        _ => {}
    }
    // Targeted semantic probes run on top of the generic negatives above, so an
    // ambiguous parameter still gets the structural fallback. Gated on
    // confidence to keep weak guesses out of the case set.
    if let Some(inf) = crate::eval::semantic_types::infer(name, prop) {
        if crate::eval::semantic_types::is_confident(&inf) {
            for (suffix, value) in crate::eval::semantic_types::targeted_invalids(inf.semantic_type)
            {
                out.push(FuzzCase {
                    label: format!("semantic:{suffix}:{name}"),
                    args: insert_field(base, name, value),
                });
            }
        }
    }
}

/// A heuristic for an internal detail leaking in a server message.
///
/// Conservative on purpose: it matches stack traces, source locations, and
/// secret-shaped tokens, not ordinary error prose, so a clean
/// "missing required argument" message does not trip it.
pub fn leak_detector(text: &str) -> bool {
    let lower = text.to_lowercase();
    const NEEDLES: &[&str] = &[
        "panicked at",
        "stack backtrace",
        "traceback (most recent call last)",
        "rust_backtrace",
        ".rs:",
        ".py\",",
        "at /home/",
        "-----begin",
        "api_key=",
        "secret_key",
        "bearer ey",
    ];
    NEEDLES.iter().any(|n| lower.contains(n))
}

/// Aggregate outcome of a fuzz sweep.
#[derive(Debug, Clone, PartialEq)]
pub struct FuzzReport {
    /// Total cases dispatched.
    pub cases_run: usize,
    /// Cases that crashed the server or dropped the transport.
    pub crashes: usize,
    /// Cases that did not return within the per-call timeout.
    pub hangs: usize,
    /// Cases that returned a malformed JSON-RPC envelope.
    pub protocol_violations: usize,
    /// Cases whose response leaked an internal detail.
    pub leaks: usize,
    /// One short line per non-clean case, for the failure report.
    pub findings: Vec<String>,
}

impl FuzzReport {
    /// Folds the per-case outcomes into the aggregate report.
    pub fn from_results(results: &[CaseResult]) -> Self {
        let mut report = FuzzReport {
            cases_run: results.len(),
            crashes: 0,
            hangs: 0,
            protocol_violations: 0,
            leaks: 0,
            findings: Vec::new(),
        };
        for result in results {
            match result.class {
                FuzzClass::Clean => {}
                FuzzClass::Crash => report.crashes += 1,
                FuzzClass::Hang => report.hangs += 1,
                FuzzClass::ProtocolViolation => report.protocol_violations += 1,
            }
            if result.leaked {
                report.leaks += 1;
            }
            if result.class != FuzzClass::Clean || result.leaked {
                report.findings.push(format!(
                    "{} -> {:?} (leak: {})",
                    result.label, result.class, result.leaked
                ));
            }
        }
        report
    }

    /// True when no case crashed, hung, violated the protocol, or leaked.
    pub fn clean(&self) -> bool {
        self.crashes == 0 && self.hangs == 0 && self.protocol_violations == 0 && self.leaks == 0
    }
}

/// Gate for a fuzz report. The default fails when any case crashed, hung,
/// violated the protocol, or leaked an internal detail.
#[derive(Debug, Clone, Default)]
pub struct FuzzGate;

impl FuzzGate {
    /// Returns true when the report passes the default gate.
    pub fn passes(&self, report: &FuzzReport) -> bool {
        report.clean()
    }
}

/// Scorecard-ready summary derived from a [`FuzzReport`].
///
/// Mirrors [`crate::eval::metamorphic::MetamorphicSummary`]: a small struct
/// plus `resolve_target` so reporters treat the `fuzz.*` targets uniformly.
#[derive(Debug, Clone, PartialEq)]
pub struct FuzzSummary {
    /// Value for `fuzz.cases_run`.
    pub cases_run: usize,
    /// Value for `fuzz.crashes`.
    pub crashes: usize,
    /// Value for `fuzz.hangs`.
    pub hangs: usize,
    /// Value for `fuzz.protocol_violations`.
    pub protocol_violations: usize,
    /// Value for `fuzz.leaks`.
    pub leaks: usize,
    /// Value for the boolean target `fuzz.gate_passed`.
    pub gate_passed: bool,
}

impl FuzzSummary {
    /// Folds a report and gate into the assertable summary.
    pub fn from_report(report: &FuzzReport, gate: &FuzzGate) -> Self {
        Self {
            cases_run: report.cases_run,
            crashes: report.crashes,
            hangs: report.hangs,
            protocol_violations: report.protocol_violations,
            leaks: report.leaks,
            gate_passed: gate.passes(report),
        }
    }

    /// Resolves a dotted target like `fuzz.crashes` to a number.
    pub fn resolve_target(&self, target: &str) -> Option<f64> {
        let suffix = target.strip_prefix("fuzz.")?;
        let value = match suffix {
            "cases_run" => self.cases_run as f64,
            "crashes" => self.crashes as f64,
            "hangs" => self.hangs as f64,
            "protocol_violations" => self.protocol_violations as f64,
            "leaks" => self.leaks as f64,
            "gate_passed" => return Some(if self.gate_passed { 1.0 } else { 0.0 }),
            _ => return None,
        };
        Some(value)
    }
}

// --- generation helpers ---

/// Build the valid-ish baseline arguments the mutations perturb.
fn baseline(input_schema: &Value, base_args: &Value) -> Value {
    if let Value::Object(map) = base_args {
        if !map.is_empty() {
            return base_args.clone();
        }
    }
    let mut out = Map::new();
    for (name, prop) in properties(input_schema) {
        out.insert(name.to_string(), placeholder(prop));
    }
    Value::Object(out)
}

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

/// A value of the wrong type for a declared property.
fn wrong_typed(prop: &Value) -> Value {
    match declared_type(prop) {
        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),
    }
}

/// The declared `type` of a property schema, if a single string.
fn declared_type(prop: &Value) -> Option<&str> {
    prop.get("type").and_then(Value::as_str)
}

/// Iterate `(name, schema)` over a tool schema's `properties` map.
fn properties(input_schema: &Value) -> impl Iterator<Item = (&str, &Value)> {
    input_schema
        .get("properties")
        .and_then(Value::as_object)
        .into_iter()
        .flat_map(|m| m.iter().map(|(k, v)| (k.as_str(), v)))
}

/// True when `name` appears in the schema's `required` list.
fn is_required(input_schema: &Value, name: &str) -> bool {
    input_schema
        .get("required")
        .and_then(Value::as_array)
        .map(|arr| arr.iter().any(|v| v.as_str() == Some(name)))
        .unwrap_or(false)
}

/// Clone `base` and set `name` to `value`, treating a non-object base as empty.
fn insert_field(base: &Value, name: &str, value: Value) -> Value {
    let mut map = base.as_object().cloned().unwrap_or_default();
    map.insert(name.to_string(), value);
    Value::Object(map)
}

/// Clone `base` without the `name` field.
fn remove_field(base: &Value, name: &str) -> Value {
    let mut map = base.as_object().cloned().unwrap_or_default();
    map.remove(name);
    Value::Object(map)
}

/// Take a seed-dependent window of `max_cases` when there are more, else all.
fn apply_budget(mut cases: Vec<FuzzCase>, seed: u64, max_cases: usize) -> Vec<FuzzCase> {
    if cases.len() <= max_cases {
        return cases;
    }
    let start = (seed as usize) % cases.len();
    cases.rotate_left(start);
    cases.truncate(max_cases);
    cases
}

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

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

    #[test]
    fn generation_is_deterministic_for_a_seed() {
        let s = schema();
        let a = generate_cases(&s, &json!({}), 1729, 100);
        let b = generate_cases(&s, &json!({}), 1729, 100);
        let labels_a: Vec<&str> = a.iter().map(|c| c.label.as_str()).collect();
        let labels_b: Vec<&str> = b.iter().map(|c| c.label.as_str()).collect();
        assert_eq!(labels_a, labels_b);
    }

    #[test]
    fn includes_an_omit_required_case() {
        let cases = generate_cases(&schema(), &json!({}), 1, 100);
        let omit = cases
            .iter()
            .find(|c| c.label == "omit_required:query")
            .expect("omit-required case present");
        assert!(omit.args.get("query").is_none());
    }

    #[test]
    fn includes_a_wrong_type_case_with_a_mismatched_value() {
        let cases = generate_cases(&schema(), &json!({}), 1, 100);
        let wrong = cases
            .iter()
            .find(|c| c.label == "wrong_type:query")
            .expect("wrong-type case present");
        // query is declared a string, so the wrong-typed value is a number.
        assert!(wrong.args["query"].is_number());
    }

    #[test]
    fn includes_an_unexpected_field_case_when_additional_properties_false() {
        let cases = generate_cases(&schema(), &json!({}), 1, 100);
        assert!(cases.iter().any(|c| c.label == "unexpected_field"));
    }

    #[test]
    fn budget_caps_the_case_count_and_varies_with_seed() {
        let s = schema();
        let few_a = generate_cases(&s, &json!({}), 0, 3);
        let few_b = generate_cases(&s, &json!({}), 5, 3);
        assert_eq!(few_a.len(), 3);
        let labels_a: Vec<&str> = few_a.iter().map(|c| c.label.as_str()).collect();
        let labels_b: Vec<&str> = few_b.iter().map(|c| c.label.as_str()).collect();
        assert_ne!(
            labels_a, labels_b,
            "a different seed selects a different window"
        );
    }

    #[test]
    fn leak_detector_flags_a_stack_trace_and_ignores_clean_prose() {
        assert!(leak_detector("thread 'main' panicked at src/lib.rs:42"));
        assert!(leak_detector("Traceback (most recent call last):"));
        assert!(!leak_detector("missing required argument `query`"));
    }

    #[test]
    fn report_counts_classes_and_leaks() {
        let results = vec![
            CaseResult {
                label: "a".into(),
                class: FuzzClass::Clean,
                leaked: false,
            },
            CaseResult {
                label: "b".into(),
                class: FuzzClass::Crash,
                leaked: false,
            },
            CaseResult {
                label: "c".into(),
                class: FuzzClass::Hang,
                leaked: false,
            },
            CaseResult {
                label: "d".into(),
                class: FuzzClass::Clean,
                leaked: true,
            },
        ];
        let report = FuzzReport::from_results(&results);
        assert_eq!(report.cases_run, 4);
        assert_eq!(report.crashes, 1);
        assert_eq!(report.hangs, 1);
        assert_eq!(report.leaks, 1);
        assert!(!report.clean());
        assert!(!FuzzGate.passes(&report));
    }

    #[test]
    fn gate_passes_on_an_all_clean_report() {
        let results = vec![CaseResult {
            label: "a".into(),
            class: FuzzClass::Clean,
            leaked: false,
        }];
        let report = FuzzReport::from_results(&results);
        assert!(FuzzGate.passes(&report));
    }

    #[test]
    fn email_param_yields_a_targeted_malformed_email_case() {
        let s = json!({
            "type": "object",
            "required": ["email"],
            "properties": { "email": { "type": "string" } }
        });
        let cases = generate_cases(&s, &json!({}), 1, 100);
        let semantic: Vec<&str> = cases
            .iter()
            .map(|c| c.label.as_str())
            .filter(|l| l.starts_with("semantic:"))
            .collect();
        assert!(
            semantic.iter().any(|l| l.contains("email")),
            "expected a semantic email case, got {semantic:?}"
        );
        let malformed = cases
            .iter()
            .find(|c| c.label == "semantic:malformed_email:email")
            .expect("malformed-email case present");
        assert!(
            malformed.args["email"].is_string(),
            "the malformed value keeps the string type"
        );
    }

    #[test]
    fn ambiguous_string_param_gets_only_generic_negatives() {
        let s = json!({
            "type": "object",
            "required": ["note"],
            "properties": { "note": { "type": "string" } }
        });
        let cases = generate_cases(&s, &json!({}), 1, 100);
        assert!(
            cases.iter().any(|c| c.label == "wrong_type:note"),
            "the generic negatives still run"
        );
        assert!(
            !cases.iter().any(|c| c.label.starts_with("semantic:")),
            "an ambiguous param produces no semantic cases"
        );
    }

    #[test]
    fn summary_resolves_fuzz_targets() {
        let report = FuzzReport::from_results(&[CaseResult {
            label: "a".into(),
            class: FuzzClass::Crash,
            leaked: false,
        }]);
        let summary = FuzzSummary::from_report(&report, &FuzzGate);
        assert_eq!(summary.resolve_target("fuzz.cases_run"), Some(1.0));
        assert_eq!(summary.resolve_target("fuzz.crashes"), Some(1.0));
        assert_eq!(summary.resolve_target("fuzz.gate_passed"), Some(0.0));
        assert!(summary.resolve_target("fuzz.unknown").is_none());
    }
}
