//! Pure check functions backing the [`super::invariants::INVARIANTS`] set.
//!
//! Each function is one spec-derived property over a captured session. They
//! live here, separate from the [`super::invariants`] type definitions and the
//! registry, so neither module crosses the size guideline. Every function is a
//! pure read over the capture and reports failures through the shared
//! [`AssertionResult`] shape.

use serde_json::Value;

use super::invariants::{CapturedSession, Exchange};
use crate::matchers::{AssertionDiff, AssertionResult, MatcherKind};

/// Build a failing [`AssertionResult`] with a single note diff. Centralized so
/// every invariant reports failures in the same shape.
fn fail(note: impl Into<String>) -> AssertionResult {
    AssertionResult::failed(
        MatcherKind::Exact,
        AssertionDiff {
            path: String::new(),
            expected: None,
            actual: None,
            note: Some(note.into()),
        },
    )
}

/// True when the envelope carries an `error` member (a JSON-RPC error).
fn is_error_response(response: &Value) -> bool {
    response.get("error").is_some()
}

/// INV-001: `initialize` must be the first request the client sends. MCP
/// clients send no pre-handshake notifications, so the first method captured
/// has to be `initialize`.
pub(crate) fn initialize_first(session: &CapturedSession) -> AssertionResult {
    let Some(first) = session.exchanges.iter().find_map(Exchange::method) else {
        return fail("session captured no client requests; initialize never sent");
    };
    if first == "initialize" {
        AssertionResult::passed(MatcherKind::Exact)
    } else {
        fail(format!(
            "first client method was `{first}`, but initialize must precede every other request"
        ))
    }
}

/// INV-002: the client must not send the `notifications/initialized`
/// notification before the `initialize` response arrives. Ordering is read from
/// position in the captured exchange list.
pub(crate) fn initialized_after_response(session: &CapturedSession) -> AssertionResult {
    let init_pos = session
        .exchanges
        .iter()
        .position(|e| e.method() == Some("initialize") && e.response.is_some());
    let initialized_pos = session
        .exchanges
        .iter()
        .position(|e| e.method() == Some("notifications/initialized"));
    match (init_pos, initialized_pos) {
        // No initialized notification captured: nothing to order, trivially holds.
        (_, None) => AssertionResult::passed(MatcherKind::Exact),
        (None, Some(_)) => {
            fail("initialized notification sent without a completed initialize response")
        }
        (Some(init), Some(done)) if done > init => AssertionResult::passed(MatcherKind::Exact),
        (Some(_), Some(_)) => fail("initialized notification sent before the initialize response"),
    }
}

/// INV-003: a server that answers a `*/list` method with a result must have
/// advertised the matching capability at `initialize`.
pub(crate) fn tools_capability_attested(session: &CapturedSession) -> AssertionResult {
    for method in ["tools/list", "resources/list", "prompts/list"] {
        let capability = method.split('/').next().unwrap_or(method);
        let answered = session
            .exchanges_for(method)
            .iter()
            .any(|e| e.response.as_ref().is_some_and(|r| !is_error_response(r)));
        if answered && !session.advertised(capability) {
            return fail(format!(
                "server answered {method} but did not advertise the `{capability}` capability"
            ));
        }
    }
    AssertionResult::passed(MatcherKind::Exact)
}

/// INV-004: a server that advertises a capability must not then error on every
/// call to it. Advertising a feature and failing every call is the violation
/// capability negotiation is meant to prevent.
pub(crate) fn advertised_capability_usable(session: &CapturedSession) -> AssertionResult {
    for (capability, methods) in [
        ("tools", ["tools/list", "tools/call"]),
        ("resources", ["resources/list", "resources/read"]),
        ("prompts", ["prompts/list", "prompts/get"]),
    ] {
        if !session.advertised(capability) {
            continue;
        }
        let mut answered = 0usize;
        let mut errored = 0usize;
        for method in methods {
            for exchange in session.exchanges_for(method) {
                let Some(response) = exchange.response.as_ref() else {
                    continue;
                };
                answered += 1;
                if is_error_response(response) {
                    errored += 1;
                }
            }
        }
        if answered > 0 && answered == errored {
            return fail(format!(
                "server advertised `{capability}` but every one of {answered} call(s) returned an error"
            ));
        }
    }
    AssertionResult::passed(MatcherKind::Exact)
}

/// INV-005: a successful `tools/call` result carries either a `content` array
/// or a `structuredContent` object, and a boolean `isError` when present.
pub(crate) fn tool_result_shape(session: &CapturedSession) -> AssertionResult {
    for exchange in session.exchanges_for("tools/call") {
        let Some(response) = exchange.response.as_ref() else {
            continue;
        };
        if is_error_response(response) {
            continue;
        }
        let Some(result) = response.get("result") else {
            return fail("tools/call response carried neither result nor error");
        };
        let has_content = result.get("content").is_some_and(Value::is_array);
        let has_structured = result
            .get("structuredContent")
            .is_some_and(Value::is_object);
        if !has_content && !has_structured {
            return fail(
                "tools/call result has neither a content array nor a structuredContent object",
            );
        }
        if let Some(is_error) = result.get("isError") {
            if !is_error.is_boolean() {
                return fail("tools/call result has a non-boolean isError field");
            }
        }
    }
    AssertionResult::passed(MatcherKind::Exact)
}

/// INV-006: every JSON-RPC error envelope carries an integer `code` and a
/// string `message`, per the JSON-RPC 2.0 contract the MCP spec inherits.
pub(crate) fn error_envelope_shape(session: &CapturedSession) -> AssertionResult {
    for exchange in &session.exchanges {
        let Some(response) = exchange.response.as_ref() else {
            continue;
        };
        let Some(error) = response.get("error") else {
            continue;
        };
        let code_ok = error.get("code").is_some_and(Value::is_i64);
        let message_ok = error.get("message").is_some_and(Value::is_string);
        if !code_ok || !message_ok {
            let method = exchange.method().unwrap_or("(unknown)");
            return fail(format!(
                "error envelope for `{method}` is missing an integer code or a string message"
            ));
        }
    }
    AssertionResult::passed(MatcherKind::Exact)
}

/// INV-007: a method-not-found error must use JSON-RPC code -32601, the code
/// the spec mandates for that case. Only error responses that say "method not
/// found" are inspected; other error classes are covered by INV-006.
pub(crate) fn unknown_method_code(session: &CapturedSession) -> AssertionResult {
    const METHOD_NOT_FOUND: i64 = -32601;
    for exchange in &session.exchanges {
        let Some(response) = exchange.response.as_ref() else {
            continue;
        };
        let Some(error) = response.get("error") else {
            continue;
        };
        let says_not_found = error
            .get("message")
            .and_then(Value::as_str)
            .is_some_and(|m| m.to_ascii_lowercase().contains("method not found"));
        if !says_not_found {
            continue;
        }
        let code = error.get("code").and_then(Value::as_i64);
        if code != Some(METHOD_NOT_FOUND) {
            let method = exchange.method().unwrap_or("(unknown)");
            return fail(format!(
                "`{method}` reported method-not-found but used code {code:?} instead of {METHOD_NOT_FOUND}"
            ));
        }
    }
    AssertionResult::passed(MatcherKind::Exact)
}
