//! Conformance checks shared across the OSS engine.
//!
//! Two surfaces live here today:
//!
//! - [`check_output_schema_conformance`]: the rule SCHEMA-006 check
//!   that validates a tool's live `structuredContent` against its
//!   declared `outputSchema`. Both Anthropic programmatic tool
//!   calling and Cloudflare Code Mode project MCP tools into a typed
//!   code API that consumes the structured output, so a tool whose
//!   result drifts from its declared schema silently breaks the
//!   generated binding.
//! - [`tier`]: SDK-tier scoring rule (SEP-2484). Aggregates a run's
//!   MUST / SHOULD pass counts into a Tier 1 / Tier 2 / Tier 3 / Fail
//!   verdict so a CI badge can summarize a conformance run in one
//!   letter.

pub mod client_probe;
pub mod corpus;
pub mod probe;
pub mod releases;
pub mod tier;

pub use client_probe::{
    client_handshake_probes, client_security_probes, ClientConformanceEvidence,
    ClientSecurityEvidence, SAMPLING_INJECTION_CANARY,
};
pub use corpus::{
    check_ids, latest_available, list_versions_embedded, list_versions_in_dir, load, load_from_dir,
    load_from_embedded, resolve_corpus_dir, CorpusError, CorpusSource, LoadedCorpus, Requirement,
    SepEntry, EMBEDDED_CORPUS,
};
pub use probe::{
    cancellation_probes, capability_probes, extended_probes, must_tally, resource_error_probes,
    tally_by_level, universal_probes, CancellationObservation, ConformanceEvidence, ProbeOutcome,
    ProbeResult, ResourceReadObservation, PER_SEP_PROBE_CHECK_IDS,
};
pub use releases::{ref_for as ref_for_release, Release, KNOWN_RELEASES};
pub use tier::{score_tier, Tier, TierInput};

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::matchers::{schema, AssertionDiff};
use crate::Error;

/// Rule ID for the structured-output conformance check (ADR 0015, SCHEMA family).
///
/// Enforced in code by [`check_output_schema_conformance`] rather than through
/// the compliance corpus DSL: the check needs the tool's own declared
/// `outputSchema` at runtime and validates a live `structuredContent` result
/// against it, which the single request/response corpus assertions cannot
/// express. This mirrors how the SEC and DESC families also carry catalogue
/// IDs while running in code.
pub const OUTPUT_SCHEMA_RULE_ID: &str = "SCHEMA-006";

/// Outcome of the structured-output conformance check for one tool call.
///
/// Produced only when the tool declares an `outputSchema`; see
/// [`check_output_schema_conformance`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutputSchemaConformance {
    /// The rule this finding belongs to. Always [`OUTPUT_SCHEMA_RULE_ID`].
    pub rule_id: String,
    /// True when the structured result satisfied the declared schema.
    pub pass: bool,
    /// Per-error diffs from schema validation. Empty when `pass` is true.
    pub diffs: Vec<AssertionDiff>,
    /// One-line, reporter-ready reason for the outcome.
    pub detail: String,
}

impl OutputSchemaConformance {
    fn passed(detail: impl Into<String>) -> Self {
        Self {
            rule_id: OUTPUT_SCHEMA_RULE_ID.to_string(),
            pass: true,
            diffs: Vec::new(),
            detail: detail.into(),
        }
    }

    fn failed(detail: impl Into<String>, diffs: Vec<AssertionDiff>) -> Self {
        Self {
            rule_id: OUTPUT_SCHEMA_RULE_ID.to_string(),
            pass: false,
            diffs,
            detail: detail.into(),
        }
    }
}

/// Check that a tool call's `structuredContent` conforms to the tool's declared
/// `outputSchema`.
///
/// `tool_def` is the tool's entry from a `tools/list` response; `call_result`
/// is the `result` object of the matching `tools/call` response. Returns `None`
/// when the tool declares no `outputSchema`, because the check does not apply.
/// When a schema is declared:
///
/// - a missing `structuredContent` is a failure, since a tool that declares an
///   `outputSchema` is expected to return conforming `structuredContent`;
/// - a declared schema that is not valid JSON Schema is a failure (the tool
///   shipped a broken contract), reported rather than panicked;
/// - otherwise the result passes only when `structuredContent` validates.
///
/// ```
/// use serde_json::json;
/// use mcptest_core::conformance::check_output_schema_conformance;
///
/// let tool = json!({
///     "name": "get_count",
///     "outputSchema": { "type": "object", "required": ["count"],
///                       "properties": { "count": { "type": "integer" } } }
/// });
/// let good = json!({ "structuredContent": { "count": 3 } });
/// assert!(check_output_schema_conformance(&tool, &good).unwrap().pass);
///
/// let bad = json!({ "structuredContent": { "count": "three" } });
/// assert!(!check_output_schema_conformance(&tool, &bad).unwrap().pass);
/// ```
pub fn check_output_schema_conformance(
    tool_def: &Value,
    call_result: &Value,
) -> Option<OutputSchemaConformance> {
    let output_schema = tool_def.get("outputSchema")?;

    let Some(structured) = call_result.get("structuredContent") else {
        return Some(OutputSchemaConformance::failed(
            "tool declares an outputSchema but the result has no structuredContent",
            Vec::new(),
        ));
    };

    match schema::evaluate(output_schema, structured) {
        Ok(result) if result.pass => Some(OutputSchemaConformance::passed(
            "structuredContent conforms to the declared outputSchema",
        )),
        Ok(result) => Some(OutputSchemaConformance::failed(
            "structuredContent does not conform to the declared outputSchema",
            result.diffs,
        )),
        Err(Error::InvalidSchema(msg)) => Some(OutputSchemaConformance::failed(
            format!("declared outputSchema is not valid JSON Schema: {msg}"),
            Vec::new(),
        )),
        Err(other) => Some(OutputSchemaConformance::failed(
            format!("could not validate structuredContent: {other}"),
            Vec::new(),
        )),
    }
}

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

    fn weather_tool() -> Value {
        json!({
            "name": "get_weather",
            "description": "Return the current weather for a city.",
            "outputSchema": {
                "type": "object",
                "required": ["temperature_c", "conditions"],
                "properties": {
                    "temperature_c": { "type": "number" },
                    "conditions": { "type": "string" }
                }
            }
        })
    }

    #[test]
    fn tool_without_output_schema_is_not_applicable() {
        let tool = json!({ "name": "noop", "description": "does nothing" });
        let result = json!({ "structuredContent": { "anything": true } });
        assert!(check_output_schema_conformance(&tool, &result).is_none());
    }

    #[test]
    fn conformant_structured_content_passes() {
        let result =
            json!({ "structuredContent": { "temperature_c": 18, "conditions": "cloudy" } });
        let finding =
            check_output_schema_conformance(&weather_tool(), &result).expect("schema is declared");
        assert!(finding.pass, "diffs: {:?}", finding.diffs);
        assert_eq!(finding.rule_id, OUTPUT_SCHEMA_RULE_ID);
        assert!(finding.diffs.is_empty());
    }

    #[test]
    fn drifted_structured_content_fails_with_diffs() {
        // temperature_c is a string, so it violates the declared number type.
        let result =
            json!({ "structuredContent": { "temperature_c": "eighteen", "conditions": "cloudy" } });
        let finding =
            check_output_schema_conformance(&weather_tool(), &result).expect("schema is declared");
        assert!(!finding.pass);
        assert!(!finding.diffs.is_empty(), "expected schema diffs, got none");
        assert!(finding.diffs.iter().any(|d| d.path == "/temperature_c"));
    }

    #[test]
    fn declared_schema_but_missing_structured_content_fails() {
        let result = json!({ "content": [{ "type": "text", "text": "18C" }] });
        let finding =
            check_output_schema_conformance(&weather_tool(), &result).expect("schema is declared");
        assert!(!finding.pass);
        assert!(finding.detail.contains("no structuredContent"));
    }

    #[test]
    fn invalid_output_schema_is_reported_not_panicked() {
        let tool = json!({ "name": "broken", "outputSchema": { "type": 42 } });
        let result = json!({ "structuredContent": {} });
        let finding = check_output_schema_conformance(&tool, &result).expect("schema is declared");
        assert!(!finding.pass);
        assert!(finding.detail.contains("not valid JSON Schema"));
    }

    #[test]
    fn finding_round_trips_through_json() {
        let result = json!({ "structuredContent": { "temperature_c": "nope", "conditions": "x" } });
        let finding =
            check_output_schema_conformance(&weather_tool(), &result).expect("schema is declared");
        let encoded = serde_json::to_string(&finding).expect("serialize");
        let decoded: OutputSchemaConformance = serde_json::from_str(&encoded).expect("deserialize");
        assert_eq!(decoded, finding);
    }
}
