//! Live conformance probe: turn what a server actually does at the handshake
//! into real MUST pass/fail, so `conformance run --server` reports a server
//! result rather than a corpus-coverage count.
//!
//! The corpus requirements key on opaque per-SEP check ids that are not
//! executable on their own (WOR-1382), so this v1 does not try to probe every
//! row. It probes the universal MCP facts every server must get right: the
//! `initialize` handshake completed, a protocol version was negotiated (and
//! matches the target when one is requested), the server advertised
//! capabilities, and `tools/list` is served. Those are real MUST checks; the
//! rest of the corpus is reported as not-yet-probed rather than counted as
//! passed.
//!
//! The split mirrors the security lane: a thin live step captures
//! [`ConformanceEvidence`], and the analysis here is a pure, deterministic
//! function over it, so the probe logic is unit-tested offline and the live
//! capture can replay from a cassette.

use serde::Serialize;

/// Observable handshake facts captured from a live (or replayed) server.
///
/// The universal probes ([`universal_probes`]) read the first four fields. The
/// per-SEP probes (WOR-1404) read the remaining capability-scoped fields, each
/// `None`/absent when the relevant capability was not advertised so the check
/// is reported as not-applicable rather than failed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConformanceEvidence {
    /// Whether the `initialize` handshake completed without error.
    pub initialize_ok: bool,
    /// The protocol version the server negotiated, if any (the
    /// `protocolVersion` from the initialize result).
    pub negotiated_version: Option<String>,
    /// Whether the server advertised a `capabilities` object at initialize.
    pub capabilities_advertised: bool,
    /// Whether a `tools/list` request returned a result without a JSON-RPC
    /// error. `None` when the server advertised no `tools` capability, so the
    /// tools-list check is not applicable rather than failed.
    pub tools_list_ok: Option<bool>,
    /// Whether `resources/list` returned a result without a JSON-RPC error.
    /// `None` when the server advertised no `resources` capability.
    pub resources_list_ok: Option<bool>,
    /// Whether `prompts/list` returned a result without a JSON-RPC error.
    /// `None` when the server advertised no `prompts` capability.
    pub prompts_list_ok: Option<bool>,
    /// What the server returned for a `resources/read` of a deliberately
    /// non-existent URI. `None` when the server advertised no `resources`
    /// capability, so the SEP-2164 checks do not apply.
    pub bogus_resource_read: Option<ResourceReadObservation>,
    /// How the server reacted to stray `notifications/cancelled` messages.
    /// `None` when the handshake did not complete, so cancellation could not
    /// be probed.
    pub cancellation: Option<CancellationObservation>,
}

/// How a server reacted to `notifications/cancelled` for request ids it has no
/// in-flight work for, the input to the cancellation-resilience probes. The MCP
/// cancellation utility requires a server to ignore a cancellation for an
/// unknown or already-completed request id; a server that hangs or drops the
/// connection instead (the real Codex bug) is non-conformant.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CancellationObservation {
    /// Whether the connection still answered a follow-up request after a
    /// `notifications/cancelled` for a request id the client never issued.
    pub healthy_after_unknown_cancel: bool,
    /// Whether the connection still answered a follow-up request after a
    /// `notifications/cancelled` for a request that had already completed.
    /// `None` when no request completed cleanly enough to reuse its id (for
    /// example a server that does not answer `ping`), so the check is reported
    /// as not-probed rather than failed.
    pub completed_cancel: Option<bool>,
}

/// What a server returned for a `resources/read` of a non-existent URI, the
/// input to the SEP-2164 resource-error probes.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceReadObservation {
    /// Whether the response was a JSON-RPC error rather than a result.
    pub is_error: bool,
    /// The JSON-RPC error code, when the response was an error.
    pub error_code: Option<i64>,
    /// Whether the response was a *result* carrying an empty `contents` array
    /// (the SEP-2164 MUST-NOT case).
    pub empty_contents: bool,
}

/// Verdict for one probed check.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case", tag = "result")]
pub enum ProbeResult {
    /// The server satisfied the check.
    Passed,
    /// The server violated the check; carries a one-line reason.
    Failed {
        /// Human-readable reason the check failed.
        reason: String,
    },
}

impl ProbeResult {
    /// True when the check passed.
    pub fn passed(&self) -> bool {
        matches!(self, ProbeResult::Passed)
    }
}

/// One probed conformance check and its verdict.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProbeOutcome {
    /// Stable id of the probed check, for example `initialize-handshake`.
    pub check: &'static str,
    /// Normative level. All universal probes are MUST in v1.
    pub level: &'static str,
    /// What the server did.
    #[serde(flatten)]
    pub result: ProbeResult,
}

/// Run the universal MUST probes over captured evidence.
///
/// `target_version`, when non-empty, is the protocol version the run requested;
/// the negotiated version must match it. An empty `target_version` only checks
/// that *some* version was negotiated. The result is deterministic for a given
/// evidence record.
pub fn universal_probes(target_version: &str, evidence: &ConformanceEvidence) -> Vec<ProbeOutcome> {
    let mut out = Vec::new();

    out.push(ProbeOutcome {
        check: "initialize-handshake",
        level: "MUST",
        result: if evidence.initialize_ok {
            ProbeResult::Passed
        } else {
            ProbeResult::Failed {
                reason: "the initialize handshake did not complete".to_string(),
            }
        },
    });

    out.push(ProbeOutcome {
        check: "protocol-version-negotiated",
        level: "MUST",
        result: match &evidence.negotiated_version {
            None => ProbeResult::Failed {
                reason: "the server negotiated no protocol version".to_string(),
            },
            Some(v) if !target_version.is_empty() && v != target_version => ProbeResult::Failed {
                reason: format!("server negotiated {v}, the run targeted {target_version}"),
            },
            Some(_) => ProbeResult::Passed,
        },
    });

    out.push(ProbeOutcome {
        check: "capabilities-advertised",
        level: "MUST",
        result: if evidence.capabilities_advertised {
            ProbeResult::Passed
        } else {
            ProbeResult::Failed {
                reason: "the initialize result advertised no capabilities object".to_string(),
            }
        },
    });

    // tools/list is a MUST only for a server that advertised the tools
    // capability; when it did not, the check is not applicable and is omitted
    // rather than counted as a pass or a fail.
    if let Some(ok) = evidence.tools_list_ok {
        out.push(ProbeOutcome {
            check: "tools-list-served",
            level: "MUST",
            result: if ok {
                ProbeResult::Passed
            } else {
                ProbeResult::Failed {
                    reason: "the server advertised tools but tools/list returned an error"
                        .to_string(),
                }
            },
        });
    }

    out
}

/// MUST passed / MUST total over a set of probed outcomes, for the tier input.
pub fn must_tally(outcomes: &[ProbeOutcome]) -> (u32, u32) {
    let total = outcomes.len() as u32;
    let passed = outcomes.iter().filter(|o| o.result.passed()).count() as u32;
    (passed, total)
}

/// Tally outcomes split by normative level: `(must_passed, must_total,
/// should_passed, should_total)`. MAY outcomes are ignored, mirroring the tier
/// rule where MAY checks never gate the badge. Used by `conformance run` to
/// feed both the MUST and SHOULD tier inputs once per-SEP probes (which carry
/// SHOULD checks) join the universal MUSTs.
pub fn tally_by_level(outcomes: &[ProbeOutcome]) -> (u32, u32, u32, u32) {
    let mut must = (0u32, 0u32);
    let mut should = (0u32, 0u32);
    for o in outcomes {
        let bucket = match o.level {
            "MUST" => &mut must,
            "SHOULD" => &mut should,
            _ => continue,
        };
        bucket.1 += 1;
        if o.result.passed() {
            bucket.0 += 1;
        }
    }
    (must.0, must.1, should.0, should.1)
}

/// Corpus `check` ids the per-SEP probes implement, so `conformance check-ids`
/// can mark them `[x]` and the run can report which corpus rows it actually
/// verified. Synthetic capability-consistency ids are not corpus ids and are
/// not listed here.
pub const PER_SEP_PROBE_CHECK_IDS: &[&str] = &["sep-2164-no-empty-contents", "sep-2164-error-code"];

/// Capability-consistency probes: a server that advertises a list capability
/// must serve the matching list endpoint. This generalizes the universal
/// `tools-list-served` MUST to `resources` and `prompts`. The probes carry
/// synthetic ids (not corpus ids) and apply only when the capability was
/// advertised, so a server that omits a capability is not penalized.
pub fn capability_probes(evidence: &ConformanceEvidence) -> Vec<ProbeOutcome> {
    let mut out = Vec::new();
    if let Some(ok) = evidence.resources_list_ok {
        out.push(list_outcome("resources-list-served", "resources", ok));
    }
    if let Some(ok) = evidence.prompts_list_ok {
        out.push(list_outcome("prompts-list-served", "prompts", ok));
    }
    out
}

fn list_outcome(check: &'static str, namespace: &str, ok: bool) -> ProbeOutcome {
    ProbeOutcome {
        check,
        level: "MUST",
        result: if ok {
            ProbeResult::Passed
        } else {
            ProbeResult::Failed {
                reason: format!(
                    "the server advertised {namespace} but {namespace}/list returned an error"
                ),
            }
        },
    }
}

/// SEP-2164 resource-error probes over a `resources/read` of a non-existent
/// URI. Applicable only when the server advertised the `resources` capability
/// (so `bogus_resource_read` is present).
///
/// - `sep-2164-no-empty-contents` (MUST): a non-existent resource MUST NOT come
///   back as a result carrying an empty `contents` array; the server must error.
/// - `sep-2164-error-code` (SHOULD): that error SHOULD use `-32602`
///   (Invalid Params), the standard JSON-RPC code for a missing resource.
pub fn resource_error_probes(evidence: &ConformanceEvidence) -> Vec<ProbeOutcome> {
    let Some(obs) = &evidence.bogus_resource_read else {
        return Vec::new();
    };
    let mut out = Vec::new();

    out.push(ProbeOutcome {
        check: "sep-2164-no-empty-contents",
        level: "MUST",
        result: if obs.empty_contents {
            ProbeResult::Failed {
                reason: "a non-existent resources/read returned an empty contents array \
                         instead of an error"
                    .to_string(),
            }
        } else {
            ProbeResult::Passed
        },
    });

    out.push(ProbeOutcome {
        check: "sep-2164-error-code",
        level: "SHOULD",
        result: if obs.error_code == Some(-32602) {
            ProbeResult::Passed
        } else if obs.is_error {
            ProbeResult::Failed {
                reason: format!(
                    "a non-existent resources/read errored with {:?}, not the standard -32602",
                    obs.error_code
                ),
            }
        } else {
            ProbeResult::Failed {
                reason: "a non-existent resources/read did not return a JSON-RPC error".to_string(),
            }
        },
    });

    out
}

/// Cancellation-resilience probes (MUST). The MCP cancellation utility (stable
/// since 2024-11-05) requires a server to ignore a `notifications/cancelled` for
/// a request id it has no in-flight work for, rather than hang or drop the
/// connection. These verify the server stayed responsive after a stray
/// cancellation; they apply to any server, so they carry synthetic ids.
pub fn cancellation_probes(evidence: &ConformanceEvidence) -> Vec<ProbeOutcome> {
    let Some(c) = &evidence.cancellation else {
        return Vec::new();
    };
    let mut out = vec![ProbeOutcome {
        check: "cancellation-unknown-id-ignored",
        level: "MUST",
        result: if c.healthy_after_unknown_cancel {
            ProbeResult::Passed
        } else {
            ProbeResult::Failed {
                reason: "the server stopped responding after a notifications/cancelled \
                         for an unknown request id"
                    .to_string(),
            }
        },
    }];
    if let Some(healthy) = c.completed_cancel {
        out.push(ProbeOutcome {
            check: "cancellation-completed-id-ignored",
            level: "MUST",
            result: if healthy {
                ProbeResult::Passed
            } else {
                ProbeResult::Failed {
                    reason: "the server stopped responding after a notifications/cancelled \
                             for an already-completed request"
                        .to_string(),
                }
            },
        });
    }
    out
}

/// All probes beyond the universal handshake set: capability consistency, the
/// SEP-2164 resource-error family, and cancellation resilience. Deterministic
/// over the captured evidence.
pub fn extended_probes(evidence: &ConformanceEvidence) -> Vec<ProbeOutcome> {
    let mut out = capability_probes(evidence);
    out.extend(resource_error_probes(evidence));
    out.extend(cancellation_probes(evidence));
    out
}

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

    fn clean() -> ConformanceEvidence {
        ConformanceEvidence {
            initialize_ok: true,
            negotiated_version: Some("2025-06-18".to_string()),
            capabilities_advertised: true,
            tools_list_ok: Some(true),
            ..ConformanceEvidence::default()
        }
    }

    #[test]
    fn a_clean_server_passes_every_universal_must() {
        let outcomes = universal_probes("2025-06-18", &clean());
        assert_eq!(outcomes.len(), 4);
        assert!(outcomes.iter().all(|o| o.result.passed()));
        assert_eq!(must_tally(&outcomes), (4, 4));
    }

    #[test]
    fn a_version_mismatch_fails_the_version_check() {
        let outcomes = universal_probes("2026-07-28", &clean());
        let version = outcomes
            .iter()
            .find(|o| o.check == "protocol-version-negotiated")
            .expect("version check present");
        assert!(!version.result.passed());
        assert_eq!(must_tally(&outcomes), (3, 4));
    }

    #[test]
    fn an_empty_target_only_requires_some_version() {
        // No target requested: any negotiated version passes.
        let outcomes = universal_probes("", &clean());
        assert_eq!(must_tally(&outcomes), (4, 4));
    }

    #[test]
    fn a_failed_handshake_fails_the_handshake_check() {
        let evidence = ConformanceEvidence {
            initialize_ok: false,
            ..clean()
        };
        let outcomes = universal_probes("", &evidence);
        let handshake = outcomes
            .iter()
            .find(|o| o.check == "initialize-handshake")
            .expect("handshake check present");
        assert!(!handshake.result.passed());
    }

    #[test]
    fn a_server_without_tools_omits_the_tools_list_check() {
        let evidence = ConformanceEvidence {
            tools_list_ok: None,
            ..clean()
        };
        let outcomes = universal_probes("", &evidence);
        assert!(outcomes.iter().all(|o| o.check != "tools-list-served"));
        // The remaining three universal MUSTs still tally.
        assert_eq!(must_tally(&outcomes), (3, 3));
    }

    #[test]
    fn a_tools_list_error_fails_when_tools_were_advertised() {
        let evidence = ConformanceEvidence {
            tools_list_ok: Some(false),
            ..clean()
        };
        let outcomes = universal_probes("", &evidence);
        assert_eq!(must_tally(&outcomes), (3, 4));
    }

    #[test]
    fn capability_probes_only_cover_advertised_capabilities() {
        // resources advertised and served, prompts not advertised.
        let evidence = ConformanceEvidence {
            resources_list_ok: Some(true),
            prompts_list_ok: None,
            ..clean()
        };
        let outcomes = capability_probes(&evidence);
        assert_eq!(outcomes.len(), 1);
        assert_eq!(outcomes[0].check, "resources-list-served");
        assert!(outcomes[0].result.passed());
    }

    #[test]
    fn an_advertised_resources_list_that_errors_fails() {
        let evidence = ConformanceEvidence {
            resources_list_ok: Some(false),
            ..clean()
        };
        let outcomes = capability_probes(&evidence);
        assert!(!outcomes[0].result.passed());
    }

    #[test]
    fn resource_error_probes_are_skipped_without_the_resources_capability() {
        // No bogus_resource_read captured: SEP-2164 does not apply.
        assert!(resource_error_probes(&clean()).is_empty());
    }

    #[test]
    fn a_proper_resource_not_found_error_passes_both_sep_2164_checks() {
        let evidence = ConformanceEvidence {
            bogus_resource_read: Some(ResourceReadObservation {
                is_error: true,
                error_code: Some(-32602),
                empty_contents: false,
            }),
            ..clean()
        };
        let outcomes = resource_error_probes(&evidence);
        assert_eq!(outcomes.len(), 2);
        assert!(outcomes.iter().all(|o| o.result.passed()));
    }

    #[test]
    fn empty_contents_for_a_missing_resource_fails_the_must() {
        let evidence = ConformanceEvidence {
            bogus_resource_read: Some(ResourceReadObservation {
                is_error: false,
                error_code: None,
                empty_contents: true,
            }),
            ..clean()
        };
        let outcomes = resource_error_probes(&evidence);
        let must = outcomes
            .iter()
            .find(|o| o.check == "sep-2164-no-empty-contents")
            .expect("no-empty-contents probed");
        assert!(!must.result.passed());
    }

    #[test]
    fn a_nonstandard_error_code_fails_only_the_should() {
        let evidence = ConformanceEvidence {
            bogus_resource_read: Some(ResourceReadObservation {
                is_error: true,
                error_code: Some(-32000),
                empty_contents: false,
            }),
            ..clean()
        };
        let outcomes = resource_error_probes(&evidence);
        let (mp, mt, sp, st) = tally_by_level(&outcomes);
        // The MUST (no empty contents) passes; the SHOULD (-32602) fails.
        assert_eq!((mp, mt), (1, 1));
        assert_eq!((sp, st), (0, 1));
    }

    #[test]
    fn tally_by_level_splits_must_and_should_and_ignores_may() {
        let outcomes = vec![
            ProbeOutcome {
                check: "a",
                level: "MUST",
                result: ProbeResult::Passed,
            },
            ProbeOutcome {
                check: "b",
                level: "SHOULD",
                result: ProbeResult::Failed { reason: "x".into() },
            },
            ProbeOutcome {
                check: "c",
                level: "MAY",
                result: ProbeResult::Passed,
            },
        ];
        assert_eq!(tally_by_level(&outcomes), (1, 1, 0, 1));
    }

    #[test]
    fn cancellation_probes_are_skipped_without_a_handshake() {
        // No cancellation evidence: the probes do not apply.
        assert!(cancellation_probes(&clean()).is_empty());
    }

    #[test]
    fn a_server_that_ignores_stray_cancellations_passes() {
        let evidence = ConformanceEvidence {
            cancellation: Some(CancellationObservation {
                healthy_after_unknown_cancel: true,
                completed_cancel: Some(true),
            }),
            ..clean()
        };
        let outcomes = cancellation_probes(&evidence);
        assert_eq!(outcomes.len(), 2);
        assert!(outcomes.iter().all(|o| o.result.passed()));
        assert!(outcomes.iter().all(|o| o.level == "MUST"));
    }

    #[test]
    fn the_completed_id_check_is_skipped_when_not_probeable() {
        // No request completed cleanly (e.g. a server with no `ping`): only the
        // unknown-id MUST is emitted.
        let evidence = ConformanceEvidence {
            cancellation: Some(CancellationObservation {
                healthy_after_unknown_cancel: true,
                completed_cancel: None,
            }),
            ..clean()
        };
        let outcomes = cancellation_probes(&evidence);
        assert_eq!(outcomes.len(), 1);
        assert_eq!(outcomes[0].check, "cancellation-unknown-id-ignored");
    }

    #[test]
    fn a_server_that_hangs_on_a_stray_cancellation_fails() {
        let evidence = ConformanceEvidence {
            cancellation: Some(CancellationObservation {
                healthy_after_unknown_cancel: false,
                completed_cancel: Some(true),
            }),
            ..clean()
        };
        let unknown = cancellation_probes(&evidence)
            .into_iter()
            .find(|o| o.check == "cancellation-unknown-id-ignored")
            .expect("unknown-id probe present");
        assert!(!unknown.result.passed());
    }

    #[test]
    fn every_advertised_per_sep_corpus_id_is_registered() {
        // The SEP-2164 ids the resource probes emit must appear in the
        // check-id registry so `conformance check-ids` marks them implemented.
        let evidence = ConformanceEvidence {
            bogus_resource_read: Some(ResourceReadObservation::default()),
            ..clean()
        };
        for outcome in resource_error_probes(&evidence) {
            assert!(
                PER_SEP_PROBE_CHECK_IDS.contains(&outcome.check),
                "{} is emitted but not registered",
                outcome.check
            );
        }
    }
}
