//! Live dispatch for negative-path JSON-RPC error conformance (WOR-1239).
//!
//! The probe set and the pass rule live in [`crate::eval::negative_path`]. This
//! module owns the I/O half: it builds each probe's bad request, issues it with
//! a per-call timeout, maps the response onto a
//! [`crate::eval::negative_path::ResponseClass`], and gates the report. A
//! rejection (a JSON-RPC error or a tool-level error result) is the pass; a
//! silent acceptance, a crash, or a hang is a finding.

use std::time::Duration;

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

use crate::eval::negative_path::{
    build_request, default_expect, metrics_object, probe_passed, NegativePathReport, ProbeResult,
    ResponseClass,
};
use crate::protocol::{Client, ProtocolError};
use crate::suite::{Assertion, NegativePathSpec};

use super::eval_metric_assertion;

/// Per-probe timeout. Negative-path calls should fail fast; a slower one is a
/// hang.
const PROBE_TIMEOUT_MS: u64 = 5000;

/// Run the selected negative-path probes against a tool and gate the report.
pub(super) async fn run_negative_path(
    client: &Client,
    tool: &str,
    base_args: &Value,
    spec: &NegativePathSpec,
) -> Result<(), String> {
    let input_schema = fetch_input_schema(client, tool, base_args).await;
    let mut results = Vec::new();
    for kind in &spec.checks {
        let Some(request) = build_request(*kind, tool, base_args, &input_schema) else {
            continue;
        };
        let class = dispatch(client, &request.tool, &request.args).await;
        let passed = probe_passed(*kind, class);
        results.push(ProbeResult {
            probe: kind.label(),
            taxonomy_id: kind.taxonomy_id(),
            passed,
            detail: if passed {
                String::new()
            } else {
                format!(
                    "{} returned {:?}, expected a rejection",
                    kind.label(),
                    class
                )
            },
        });
    }
    evaluate_gate(&NegativePathReport { results }, &spec.expect)
}

/// Issue one probe call and classify how the server responded.
async fn dispatch(client: &Client, tool: &str, args: &Value) -> ResponseClass {
    let params = json!({ "name": tool, "arguments": args });
    let fut = client.request("tools/call".to_string(), params);
    match tokio::time::timeout(Duration::from_millis(PROBE_TIMEOUT_MS), fut).await {
        Err(_elapsed) => ResponseClass::Hung,
        Ok(Ok(value)) => {
            if value
                .get("isError")
                .and_then(Value::as_bool)
                .unwrap_or(false)
            {
                ResponseClass::Rejected
            } else {
                ResponseClass::Accepted
            }
        }
        Ok(Err(err)) => classify_error(&err),
    }
}

/// Map a protocol error onto a response class. A JSON-RPC error is a rejection.
fn classify_error(err: &ProtocolError) -> ResponseClass {
    match err {
        ProtocolError::ServerError { .. } => ResponseClass::Rejected,
        ProtocolError::RequestTimeout { .. } => ResponseClass::Hung,
        _ => ResponseClass::Crashed,
    }
}

/// Read the tool's `inputSchema` from `tools/list`, inferring a minimal schema
/// 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 required).
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 {
            let ty = match value {
                Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
                Value::Number(_) => "number",
                Value::Bool(_) => "boolean",
                Value::Array(_) => "array",
                Value::Object(_) => "object",
                _ => "string",
            };
            properties.insert(key.clone(), json!({ "type": ty }));
            required.push(json!(key));
        }
    }
    json!({ "type": "object", "required": required, "properties": properties })
}

/// Apply the gate: the default `negative_path.failures <= 0`, or the explicit
/// `expect:` assertions over the `negative_path.*` targets.
fn evaluate_gate(report: &NegativePathReport, expect: &[Assertion]) -> Result<(), String> {
    let metrics = metrics_object(report);
    let assertions: Vec<Assertion> = if expect.is_empty() {
        default_expect()
    } else {
        expect.to_vec()
    };
    for assertion in &assertions {
        if let Err(reason) = eval_metric_assertion(&metrics, assertion) {
            let failed: Vec<String> = report
                .results
                .iter()
                .filter(|r| !r.passed)
                .map(|r| format!("{} [{}]: {}", r.probe, r.taxonomy_id, r.detail))
                .collect();
            return Err(format!(
                "negative-path assertion on `{}` failed: {reason}; findings: {}",
                assertion.target,
                failed.join("; ")
            ));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::eval::negative_path::ProbeResult;

    fn report(pairs: &[(&'static str, bool)]) -> NegativePathReport {
        NegativePathReport {
            results: pairs
                .iter()
                .map(|(probe, passed)| ProbeResult {
                    probe,
                    taxonomy_id: "tax",
                    passed: *passed,
                    detail: if *passed {
                        String::new()
                    } else {
                        "accepted".into()
                    },
                })
                .collect(),
        }
    }

    #[test]
    fn default_gate_passes_when_every_probe_rejected() {
        assert!(evaluate_gate(&report(&[("unknown_tool", true)]), &[]).is_ok());
    }

    #[test]
    fn default_gate_fails_and_names_the_finding() {
        let err = evaluate_gate(&report(&[("wrong_type", false)]), &[]).expect_err("a failure");
        assert!(err.contains("wrong_type"), "{err}");
        assert!(err.contains("negative_path.failures"), "{err}");
    }

    #[test]
    fn server_error_is_a_rejection_and_timeout_is_a_hang() {
        assert_eq!(
            classify_error(&ProtocolError::ServerError {
                code: -32601,
                message: "method not found".into(),
                data: None,
            }),
            ResponseClass::Rejected
        );
        assert_eq!(
            classify_error(&ProtocolError::request_timeout(1, 5000)),
            ResponseClass::Hung
        );
    }
}
