//! Client/host conformance probes (WOR-1365).
//!
//! The mirror image of [`probe`](crate::conformance::probe): instead of probing
//! a server, the client lane stands mcptest up as an adversarial server and
//! points a client-under-test at it, then scores what the client did during the
//! handshake. The split is the same as the server lane: a thin live harness in
//! the CLI captures [`ClientConformanceEvidence`], and the analysis here is a
//! pure, deterministic function over it, so the verdict logic stays
//! offline-testable.
//!
//! v1 probes the universal client facts every MCP client must get right: it
//! opens with an `initialize` request carrying a `protocolVersion` (and,
//! ideally, `clientInfo` and a `capabilities` object), it sends
//! `notifications/initialized` once the server answers, and it responds to a
//! server-initiated `ping`. Richer client behaviors (cancellation timing, MRTR
//! `requestState` echo, server-request windowing) build on this in follow-ups.

use super::probe::{ProbeOutcome, ProbeResult};

/// The canary string the adversarial server injects into a `sampling/createMessage`
/// request. A client that echoes it back blindly executed server-controlled
/// sampling content; a hardened host gates or declines, so the canary never
/// reaches the reply. Fixed and distinctive so a substring match is unambiguous.
pub const SAMPLING_INJECTION_CANARY: &str = "MCPTEST-CANARY-7f3a91";

/// Observable facts about a client-under-test, captured by the live harness.
///
/// Every field is something the adversarial server can observe directly from
/// the wire. The booleans default to `false` (the client did not do it), so a
/// client that never connects scores an honest all-fail rather than a panic.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientConformanceEvidence {
    /// The client's first message was an `initialize` request.
    pub sent_initialize_first: bool,
    /// The `initialize` params carried a `protocolVersion`.
    pub protocol_version_present: bool,
    /// The `initialize` params carried a `clientInfo` object with a `name`.
    pub client_info_present: bool,
    /// The `initialize` params carried a `capabilities` object.
    pub capabilities_present: bool,
    /// The client sent `notifications/initialized` after the server answered
    /// the `initialize` request.
    pub sent_initialized_notification: bool,
    /// Whether the client answered a server-initiated `ping` with a result.
    /// `None` when the handshake never completed far enough to send the ping,
    /// so the check is reported as not-probed rather than failed.
    pub responded_to_ping: Option<bool>,
}

/// Observable security facts about a client-under-test, captured by the live
/// harness when it acts as an adversarial server.
///
/// Separate from [`ClientConformanceEvidence`] so a security failure is scored
/// and reported apart from the universal handshake conformance. It captures the
/// families that are deterministically observable over stdio: whether the
/// client resists a server-injected `sampling/createMessage`, and whether it
/// re-lists tools after the server mutates its catalog mid-session (WOR-1471).
/// The remaining host-security families (consent UX, OAuth/SSRF transport, HTTP
/// session hijack) are not observable over the stdio JSON-RPC channel and are
/// documented as non-automatable rather than faked.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientSecurityEvidence {
    /// The harness sent the client a `sampling/createMessage` carrying the
    /// injection canary. `false` when the handshake never got far enough to
    /// probe it, so the check is reported as not-probed rather than failed.
    pub sampling_probed: bool,
    /// Whether the client's reply to the injected sampling request echoed the
    /// canary. `Some(true)` means it blindly returned attacker-chosen output;
    /// `Some(false)` means it declined, errored, stayed silent, or replied
    /// without the canary; `None` when sampling was not probed.
    pub sampling_echoed_canary: Option<bool>,
    /// The harness mutated its catalog mid-session (emitted a
    /// `notifications/tools/list_changed` after the handshake). `false` when the
    /// handshake never got far enough to probe it, so the check is reported as
    /// not-probed rather than failed (WOR-1471).
    pub list_changed_probed: bool,
    /// Whether the client re-issued `tools/list` after the mid-session
    /// list-changed notification. `Some(true)` means it re-listed (it would
    /// catch a rug-pulled tool catalog); `Some(false)` means it ignored the
    /// notification and kept its stale catalog; `None` when the list-changed
    /// was not probed.
    pub relisted_after_change: Option<bool>,
    /// The harness sent the client a malformed (non-JSON) frame mid-session to
    /// test resilience. `false` when the handshake never got far enough to
    /// probe it, so the check is reported as not-probed rather than failed.
    pub malformed_probed: bool,
    /// Whether the client kept working after the malformed frame (it answered a
    /// follow-up ping). `Some(true)` means it tolerated the garbage and made
    /// progress; `Some(false)` means it stalled or died; `None` when the
    /// malformed frame was not probed.
    pub survived_malformed: Option<bool>,
}

/// Run the client-security probes over captured evidence.
///
/// Deterministic for a given evidence record. It emits two SHOULD probes, each
/// only when its behavior was actually probed:
/// `client-resists-sampling-injection` (a client that echoes the
/// [`SAMPLING_INJECTION_CANARY`] fails it) and `client-re-lists-after-change`
/// (a client that ignores a mid-session list-changed and keeps its stale
/// catalog fails it, the cross-server-shadowing / rug-pull threat). Both are
/// SHOULDs (not MUSTs) so a lapse lowers the tier rather than failing the lane
/// outright, matching the spec's guidance.
pub fn client_security_probes(evidence: &ClientSecurityEvidence) -> Vec<ProbeOutcome> {
    let mut out = Vec::new();
    if evidence.sampling_probed {
        let resisted = evidence.sampling_echoed_canary != Some(true);
        out.push(should(
            "client-resists-sampling-injection",
            resisted,
            "the client echoed a server-injected canary in its sampling reply \
             (it executed server-controlled sampling content without gating)",
        ));
    }
    if evidence.list_changed_probed {
        let relisted = evidence.relisted_after_change == Some(true);
        out.push(should(
            "client-re-lists-after-change",
            relisted,
            "the client did not re-list tools after a mid-session \
             notifications/tools/list_changed, so a rug-pulled or shadowing \
             catalog (SEC-010..013 cross-server shadowing / rug pull) would go \
             unnoticed",
        ));
    }
    if evidence.malformed_probed {
        let survived = evidence.survived_malformed == Some(true);
        out.push(should(
            "client-survives-malformed-message",
            survived,
            "the client stopped responding after a malformed (non-JSON) server \
             frame, so a flaky or hostile server could wedge it instead of the \
             client tolerating the bad frame and continuing",
        ));
    }
    out
}

/// Build a MUST outcome from a boolean observation.
fn must(check: &'static str, ok: bool, reason: &str) -> ProbeOutcome {
    ProbeOutcome {
        check,
        level: "MUST",
        result: outcome(ok, reason),
    }
}

/// Build a SHOULD outcome from a boolean observation.
fn should(check: &'static str, ok: bool, reason: &str) -> ProbeOutcome {
    ProbeOutcome {
        check,
        level: "SHOULD",
        result: outcome(ok, reason),
    }
}

fn outcome(ok: bool, reason: &str) -> ProbeResult {
    if ok {
        ProbeResult::Passed
    } else {
        ProbeResult::Failed {
            reason: reason.to_string(),
        }
    }
}

/// Run the universal client handshake probes over captured evidence.
///
/// Deterministic for a given evidence record. The MUSTs gate the tier; the two
/// `clientInfo` / `capabilities` SHOULDs lower it without failing it. The
/// server-initiated ping MUST is emitted only when it was probeable.
pub fn client_handshake_probes(evidence: &ClientConformanceEvidence) -> Vec<ProbeOutcome> {
    let mut out = vec![
        must(
            "client-initialize-first",
            evidence.sent_initialize_first,
            "the client's first message was not an initialize request",
        ),
        must(
            "client-protocol-version",
            evidence.protocol_version_present,
            "the initialize request carried no protocolVersion",
        ),
        should(
            "client-info",
            evidence.client_info_present,
            "the initialize request carried no clientInfo.name",
        ),
        should(
            "client-capabilities",
            evidence.capabilities_present,
            "the initialize request advertised no capabilities object",
        ),
        must(
            "client-initialized-notification",
            evidence.sent_initialized_notification,
            "the client never sent notifications/initialized after initialize",
        ),
    ];
    if let Some(ok) = evidence.responded_to_ping {
        out.push(must(
            "client-responds-to-ping",
            ok,
            "the client did not respond to a server-initiated ping",
        ));
    }
    out
}

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

    fn conformant() -> ClientConformanceEvidence {
        ClientConformanceEvidence {
            sent_initialize_first: true,
            protocol_version_present: true,
            client_info_present: true,
            capabilities_present: true,
            sent_initialized_notification: true,
            responded_to_ping: Some(true),
        }
    }

    #[test]
    fn a_conformant_client_passes_every_must_and_should() {
        let outcomes = client_handshake_probes(&conformant());
        assert!(outcomes.iter().all(|o| o.result.passed()));
        // 4 MUST (init-first, version, initialized, ping) + 2 SHOULD.
        assert_eq!(tally_by_level(&outcomes), (4, 4, 2, 2));
    }

    #[test]
    fn a_client_that_never_connects_fails_the_handshake_musts() {
        let outcomes = client_handshake_probes(&ClientConformanceEvidence::default());
        let (mp, mt, _sp, _st) = tally_by_level(&outcomes);
        // The ping was not probeable, so only three MUSTs are emitted and all fail.
        assert_eq!((mp, mt), (0, 3));
    }

    #[test]
    fn a_missing_initialized_notification_fails_only_that_must() {
        let evidence = ClientConformanceEvidence {
            sent_initialized_notification: false,
            ..conformant()
        };
        let outcomes = client_handshake_probes(&evidence);
        let failed: Vec<&str> = outcomes
            .iter()
            .filter(|o| !o.result.passed())
            .map(|o| o.check)
            .collect();
        assert_eq!(failed, vec!["client-initialized-notification"]);
    }

    #[test]
    fn a_client_that_ignores_ping_fails_the_ping_must() {
        let evidence = ClientConformanceEvidence {
            responded_to_ping: Some(false),
            ..conformant()
        };
        let outcomes = client_handshake_probes(&evidence);
        let ping = outcomes
            .iter()
            .find(|o| o.check == "client-responds-to-ping")
            .expect("ping probed");
        assert!(!ping.result.passed());
    }

    #[test]
    fn an_unprobeable_ping_is_omitted_not_failed() {
        let evidence = ClientConformanceEvidence {
            responded_to_ping: None,
            ..conformant()
        };
        let outcomes = client_handshake_probes(&evidence);
        assert!(outcomes
            .iter()
            .all(|o| o.check != "client-responds-to-ping"));
    }

    #[test]
    fn missing_client_info_lowers_but_does_not_fail() {
        let evidence = ClientConformanceEvidence {
            client_info_present: false,
            ..conformant()
        };
        let outcomes = client_handshake_probes(&evidence);
        let (mp, mt, sp, st) = tally_by_level(&outcomes);
        // Every MUST still passes; one SHOULD now fails.
        assert_eq!((mp, mt), (4, 4));
        assert_eq!((sp, st), (1, 2));
    }

    #[test]
    fn a_client_that_echoes_the_canary_fails_the_security_should() {
        let evidence = ClientSecurityEvidence {
            sampling_probed: true,
            sampling_echoed_canary: Some(true),
            ..Default::default()
        };
        let outcomes = client_security_probes(&evidence);
        assert_eq!(outcomes.len(), 1);
        assert_eq!(outcomes[0].check, "client-resists-sampling-injection");
        assert_eq!(outcomes[0].level, "SHOULD");
        assert!(!outcomes[0].result.passed());
    }

    #[test]
    fn a_client_that_does_not_echo_the_canary_passes() {
        for echoed in [Some(false), None] {
            let evidence = ClientSecurityEvidence {
                sampling_probed: true,
                sampling_echoed_canary: echoed,
                ..Default::default()
            };
            let outcomes = client_security_probes(&evidence);
            assert_eq!(outcomes.len(), 1);
            assert!(outcomes[0].result.passed(), "echoed={echoed:?}");
        }
    }

    #[test]
    fn an_unprobed_sampling_emits_no_security_probe() {
        let outcomes = client_security_probes(&ClientSecurityEvidence::default());
        assert!(outcomes.is_empty());
    }

    #[test]
    fn a_client_that_ignores_list_changed_fails_the_relist_should() {
        let evidence = ClientSecurityEvidence {
            list_changed_probed: true,
            relisted_after_change: Some(false),
            ..Default::default()
        };
        let outcomes = client_security_probes(&evidence);
        let relist = outcomes
            .iter()
            .find(|o| o.check == "client-re-lists-after-change")
            .expect("re-list probed");
        assert_eq!(relist.level, "SHOULD");
        assert!(!relist.result.passed());
    }

    #[test]
    fn a_client_that_re_lists_after_change_passes() {
        let evidence = ClientSecurityEvidence {
            list_changed_probed: true,
            relisted_after_change: Some(true),
            ..Default::default()
        };
        let outcomes = client_security_probes(&evidence);
        let relist = outcomes
            .iter()
            .find(|o| o.check == "client-re-lists-after-change")
            .expect("re-list probed");
        assert!(relist.result.passed());
    }

    #[test]
    fn an_unprobed_list_changed_emits_no_relist_probe() {
        // Sampling probed but list-changed not: only the sampling probe fires.
        let evidence = ClientSecurityEvidence {
            sampling_probed: true,
            sampling_echoed_canary: Some(false),
            ..Default::default()
        };
        let outcomes = client_security_probes(&evidence);
        assert!(outcomes
            .iter()
            .all(|o| o.check != "client-re-lists-after-change"));
    }
}
