//! Live dispatch for the schema-driven input fuzzer (WOR-1238).
//!
//! The pure case generator, the report, and the gate live in
//! [`crate::eval::fuzz`]. This module owns the I/O half: it fetches the tool's
//! `inputSchema`, issues each generated case with a per-call timeout, classifies
//! how the call came back ([`crate::protocol::ProtocolError`] to
//! [`FuzzClass`]), and folds the outcomes into a gated report. A clean
//! JSON-RPC error counts as a pass (the server rejected bad input); a dropped
//! transport, a timeout, a malformed envelope, or a leaked internal detail
//! counts as a finding.

use std::time::Duration;

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

use crate::eval::fuzz::{
    generate_cases, leak_detector, CaseResult, FuzzClass, FuzzGate, FuzzReport, FuzzSummary,
};
use crate::protocol::{Client, ProtocolError};
use crate::suite::{Assertion, FuzzSpec};

use super::eval_metric_assertion;

/// Fuzz one tool for a suite `fuzz:` block: dispatch the cases and gate.
pub(super) async fn run_fuzz(
    client: &Client,
    tool: &str,
    base_args: &Value,
    spec: &FuzzSpec,
) -> Result<(), String> {
    let report = fuzz_tool(
        client,
        tool,
        base_args,
        spec.seed,
        spec.cases,
        spec.max_call_ms,
    )
    .await;
    evaluate_fuzz_gate(&report, &spec.expect)
}

/// Fuzz one tool and return the raw report (no gate). Shared by the suite
/// `fuzz:` block and the `mcptest fuzz` subcommand. Reads the tool's
/// `inputSchema` from `tools/list`, generates the cases, and dispatches each
/// with a per-call timeout.
pub async fn fuzz_tool(
    client: &Client,
    tool: &str,
    base_args: &Value,
    seed: u64,
    cases: usize,
    max_call_ms: u64,
) -> FuzzReport {
    let input_schema = fetch_input_schema(client, tool, base_args).await;
    let generated = generate_cases(&input_schema, base_args, seed, cases);
    let mut results = Vec::with_capacity(generated.len());
    for case in &generated {
        let params = json!({ "name": tool, "arguments": case.args });
        let (class, leaked) = dispatch_case(client, params, max_call_ms).await;
        results.push(CaseResult {
            label: case.label.clone(),
            class,
            leaked,
        });
    }
    FuzzReport::from_results(&results)
}

/// Read the tool's `inputSchema` from `tools/list`, falling back to a schema
/// inferred from the base arguments when the listing is unavailable.
async fn fetch_input_schema(client: &Client, tool: &str, base_args: &Value) -> Value {
    if let Ok(value) = client.request("tools/list".to_string(), json!({})).await {
        let found = value
            .get("tools")
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
            .find(|t| t.get("name").and_then(Value::as_str) == Some(tool))
            .and_then(|t| t.get("inputSchema").cloned());
        if let Some(schema) = found {
            return schema;
        }
    }
    infer_schema(base_args)
}

/// Build a minimal object schema from the base arguments: every key is a
/// required property whose declared type matches the base value.
fn infer_schema(base_args: &Value) -> Value {
    let mut properties = Map::new();
    let mut required = Vec::new();
    if let Some(obj) = base_args.as_object() {
        for (key, value) in obj {
            properties.insert(key.clone(), json!({ "type": json_type_name(value) }));
            required.push(json!(key));
        }
    }
    json!({
        "type": "object",
        "required": required,
        "properties": properties,
    })
}

/// JSON Schema type name for a concrete value.
fn json_type_name(value: &Value) -> &'static str {
    match value {
        Value::String(_) => "string",
        Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
        Value::Number(_) => "number",
        Value::Bool(_) => "boolean",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
        Value::Null => "null",
    }
}

/// Issue one case with a per-call timeout and classify the outcome.
async fn dispatch_case(client: &Client, params: Value, max_call_ms: u64) -> (FuzzClass, bool) {
    let fut = client.request("tools/call".to_string(), params);
    match tokio::time::timeout(Duration::from_millis(max_call_ms.max(1)), fut).await {
        Err(_elapsed) => (FuzzClass::Hang, false),
        Ok(Ok(value)) => (FuzzClass::Clean, leak_in_result(&value)),
        Ok(Err(err)) => classify_error(&err),
    }
}

/// A leak only counts on an error-flagged result; a normal successful result is
/// not a leak surface even if its text happens to match a needle.
fn leak_in_result(value: &Value) -> bool {
    let is_error = value
        .get("isError")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    is_error && leak_detector(&value.to_string())
}

/// Map a protocol error onto a fuzz class plus a leak flag. A clean JSON-RPC
/// error is the server rejecting bad input, so it is a pass.
fn classify_error(err: &ProtocolError) -> (FuzzClass, bool) {
    match err {
        ProtocolError::ServerError { message, .. } => (FuzzClass::Clean, leak_detector(message)),
        ProtocolError::RequestTimeout { .. } => (FuzzClass::Hang, false),
        ProtocolError::MalformedMessage(m) => (FuzzClass::ProtocolViolation, leak_detector(m)),
        ProtocolError::Json(_) => (FuzzClass::ProtocolViolation, false),
        ProtocolError::TransportClosed(m) | ProtocolError::Transport(m) => {
            (FuzzClass::Crash, leak_detector(m))
        }
        ProtocolError::Aborted { .. } => (FuzzClass::Crash, false),
        ProtocolError::ProtocolVersionMismatch(_) => (FuzzClass::ProtocolViolation, false),
    }
}

/// Decide pass or fail for a fuzz report. With no `expect`, the default gate
/// fails on any crash, hang, protocol violation, or leak. With an `expect`
/// list, the `fuzz.*` targets are gated directly through the shared
/// metric-assert path.
fn evaluate_fuzz_gate(report: &FuzzReport, expect: &[Assertion]) -> Result<(), String> {
    if expect.is_empty() {
        if report.clean() {
            return Ok(());
        }
        return Err(fuzz_failure(report));
    }
    let summary = FuzzSummary::from_report(report, &FuzzGate);
    let metrics = json!({
        "fuzz": {
            "cases_run": summary.cases_run,
            "crashes": summary.crashes,
            "hangs": summary.hangs,
            "protocol_violations": summary.protocol_violations,
            "leaks": summary.leaks,
            "gate_passed": if summary.gate_passed { 1 } else { 0 },
        }
    });
    for assertion in expect {
        eval_metric_assertion(&metrics, assertion)
            .map_err(|m| format!("fuzz assertion on `{}` failed: {m}", assertion.target))?;
    }
    Ok(())
}

/// Render a default-gate failure summarizing the counts and a few findings.
fn fuzz_failure(report: &FuzzReport) -> String {
    format!(
        "fuzz gate failed over {} cases: {} crash(es), {} hang(s), {} protocol violation(s), {} leak(s); {}",
        report.cases_run,
        report.crashes,
        report.hangs,
        report.protocol_violations,
        report.leaks,
        report.findings.join("; ")
    )
}

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

    fn asrt(target: &str, matcher: crate::suite::Matcher) -> Assertion {
        Assertion {
            target: target.into(),
            matcher,
            weight: 1.0,
            name: None,
            transform: None,
        }
    }

    fn clean_report() -> FuzzReport {
        FuzzReport::from_results(&[CaseResult {
            label: "a".into(),
            class: FuzzClass::Clean,
            leaked: false,
        }])
    }

    fn crashing_report() -> FuzzReport {
        FuzzReport::from_results(&[CaseResult {
            label: "omit_required:query".into(),
            class: FuzzClass::Crash,
            leaked: false,
        }])
    }

    #[test]
    fn default_gate_passes_on_a_clean_report() {
        assert!(evaluate_fuzz_gate(&clean_report(), &[]).is_ok());
    }

    #[test]
    fn default_gate_fails_and_names_the_finding() {
        let err = evaluate_fuzz_gate(&crashing_report(), &[]).expect_err("a crash fails the gate");
        assert!(err.contains("crash"), "{err}");
        assert!(err.contains("omit_required:query"), "{err}");
    }

    #[test]
    fn explicit_expect_floor_gates_the_targets() {
        let expect = [asrt(
            "fuzz.crashes",
            crate::suite::Matcher::Schema(json!({ "maximum": 0 })),
        )];
        assert!(evaluate_fuzz_gate(&clean_report(), &expect).is_ok());
        let err = evaluate_fuzz_gate(&crashing_report(), &expect).expect_err("floor breach fails");
        assert!(err.contains("fuzz.crashes"), "{err}");
    }

    #[test]
    fn classify_maps_server_error_to_clean_and_timeout_to_hang() {
        let (class, leak) = classify_error(&ProtocolError::ServerError {
            code: -32602,
            message: "missing required argument `query`".into(),
            data: None,
        });
        assert_eq!(class, FuzzClass::Clean);
        assert!(!leak);
        let (class, _) = classify_error(&ProtocolError::request_timeout(1, 2000));
        assert_eq!(class, FuzzClass::Hang);
    }

    #[test]
    fn classify_flags_a_leak_in_a_server_error_message() {
        let (class, leak) = classify_error(&ProtocolError::ServerError {
            code: -32603,
            message: "thread 'main' panicked at src/tool.rs:88".into(),
            data: None,
        });
        assert_eq!(class, FuzzClass::Clean);
        assert!(leak, "a stack trace in the error message is a leak");
    }

    #[test]
    fn classify_maps_transport_closed_to_crash() {
        let (class, _) = classify_error(&ProtocolError::TransportClosed("pipe closed".into()));
        assert_eq!(class, FuzzClass::Crash);
    }

    #[test]
    fn infer_schema_marks_every_base_key_required_with_a_type() {
        let schema = infer_schema(&json!({ "query": "x", "limit": 3 }));
        assert_eq!(schema["type"], "object");
        assert_eq!(schema["properties"]["query"]["type"], "string");
        assert_eq!(schema["properties"]["limit"]["type"], "integer");
        let required: Vec<&str> = schema["required"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(Value::as_str)
            .collect();
        assert!(required.contains(&"query") && required.contains(&"limit"));
    }

    #[test]
    fn leak_in_result_ignores_a_successful_result() {
        // Even if the content mentions a `.rs:` path, a non-error result is not
        // treated as a leak surface.
        let ok = json!({ "content": [{ "type": "text", "text": "see src/x.rs:1" }] });
        assert!(!leak_in_result(&ok));
        let errored = json!({ "isError": true, "content": [{ "type": "text", "text": "panicked at src/x.rs:1" }] });
        assert!(leak_in_result(&errored));
    }
}
