//! Conformance-suite renderer for `mcptest generate conformance` and the
//! `scaffold_conformance` agent verb.
//!
//! Why this exists: the happy-path suite renderer ([`super::suite`]) covers
//! tool behavior, not protocol conformance. To "completely test" a server an
//! author also needs the protocol-level checks (does `initialize` advertise
//! capabilities, does each advertised list method answer). This module turns a
//! server's advertised capabilities into a `compliance:` block bound to a spec
//! revision, emitting exactly the checks the server claims to support so the
//! generated suite runs without hand-pruning checks for absent capabilities.

use serde_json::Value;

use super::emit;

/// MCP spec revision a generated conformance suite pins by default.
///
/// Recorded under `compliance.spec_version` so mcptest can warn on a
/// server/suite mismatch. The value is one of the schema's accepted revisions;
/// callers override it through [`ConformanceOptions::spec_version`].
pub const DEFAULT_SPEC_VERSION: &str = "v2025-06-18";

/// Options that steer [`render_conformance_suite`].
#[derive(Debug, Clone)]
pub struct ConformanceOptions {
    /// Server key the generated checks reference, and the name under the
    /// emitted `servers:` block, so the suite is runnable as written.
    pub server_name: String,
    /// MCP spec revision recorded under `compliance.spec_version`.
    pub spec_version: String,
}

impl Default for ConformanceOptions {
    fn default() -> Self {
        Self {
            server_name: "server".to_string(),
            spec_version: DEFAULT_SPEC_VERSION.to_string(),
        }
    }
}

/// Render a conformance suite from a server's advertised `capabilities` object.
///
/// `capabilities` is the object an `initialize` response returns. The renderer
/// always emits the `initialize` handshake check, then one list-method check
/// per advertised capability (`tools`, `resources`, `prompts`), so the suite
/// asks only for methods the server claims. The checks land in the object form
/// of the `compliance:` block with the spec revision pinned.
///
/// ```
/// use mcptest_core::stub_gen::{render_conformance_suite, ConformanceOptions};
/// use serde_json::json;
///
/// let caps = json!({ "tools": {}, "resources": {} });
/// let yaml = render_conformance_suite(&caps, &ConformanceOptions::default());
/// assert!(yaml.contains("check: \"initialize\""));
/// assert!(yaml.contains("check: \"tools/list\""));
/// ```
pub fn render_conformance_suite(capabilities: &Value, opts: &ConformanceOptions) -> String {
    let server = &opts.server_name;
    let mut out = emit::render_header("mcptest generate conformance");
    out.push_str("# Protocol-level conformance checks for the capabilities this server\n");
    out.push_str("# advertised. Add checks for capabilities you add later.\n\n");
    out.push_str(&emit::render_servers_block(server));

    out.push_str("compliance:\n");
    out.push_str(&format!(
        "  # MCP spec revision these checks target. mcptest warns on a mismatch.\n  spec_version: \"{}\"\n",
        opts.spec_version
    ));
    out.push_str("  tests:\n");

    out.push_str(&render_check(
        server,
        "CONF-001 initialize advertises capabilities",
        "initialize",
        "result.capabilities",
        "object",
        "initialize must return a capabilities object",
    ));
    if has_capability(capabilities, "tools") {
        out.push_str(&render_check(
            server,
            "CONF-002 tools/list returns the tool catalog",
            "tools/list",
            "result.tools",
            "array",
            "a server advertising tools must answer tools/list with an array",
        ));
    }
    if has_capability(capabilities, "resources") {
        out.push_str(&render_check(
            server,
            "CONF-003 resources/list returns the resource catalog",
            "resources/list",
            "result.resources",
            "array",
            "a server advertising resources must answer resources/list with an array",
        ));
    }
    if has_capability(capabilities, "prompts") {
        out.push_str(&render_check(
            server,
            "CONF-004 prompts/list returns the prompt catalog",
            "prompts/list",
            "result.prompts",
            "array",
            "a server advertising prompts must answer prompts/list with an array",
        ));
    }
    out
}

/// True when the capabilities object declares `key`. MCP advertises a
/// capability as a present (often empty) sub-object, so presence of the key,
/// not its contents, is the signal.
fn has_capability(capabilities: &Value, key: &str) -> bool {
    capabilities.get(key).is_some()
}

/// Render one compliance check entry: a method call plus a `schema:` assertion
/// that the expected result field has the right JSON type.
fn render_check(
    server: &str,
    name: &str,
    check: &str,
    target: &str,
    json_type: &str,
    message: &str,
) -> String {
    let mut s = String::new();
    s.push_str(&format!("    - name: \"{name}\"\n"));
    s.push_str(&format!("      server: {server}\n"));
    s.push_str(&format!("      check: \"{check}\"\n"));
    s.push_str("      expect:\n");
    s.push_str(&format!("        - target: {target}\n"));
    s.push_str("          matcher:\n");
    s.push_str("            schema:\n");
    s.push_str(&format!("              type: {json_type}\n"));
    s.push_str(&format!("          message: \"{message}\"\n"));
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::{json, Value};
    use std::path::{Path, PathBuf};

    fn workspace_root() -> PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(Path::parent)
            .expect("workspace root above crates/mcptest-core")
            .to_path_buf()
    }

    fn validate_against_v1(yaml: &str) {
        let schema_path = workspace_root().join("schemas").join("v1.json");
        let raw = std::fs::read_to_string(&schema_path)
            .unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
        let schema: Value = serde_json::from_str(&raw).expect("v1.json is valid JSON");
        let validator = jsonschema::validator_for(&schema).expect("schema compiles");
        let as_yaml: serde_yaml::Value = serde_yaml::from_str(yaml).expect("suite parses");
        let as_json: Value = serde_json::to_value(as_yaml).expect("YAML converts to JSON");
        let errors: Vec<String> = validator
            .iter_errors(&as_json)
            .map(|e| e.to_string())
            .collect();
        assert!(errors.is_empty(), "suite should validate, got: {errors:#?}");
    }

    #[test]
    fn always_emits_the_initialize_check() {
        let yaml = render_conformance_suite(&json!({}), &ConformanceOptions::default());
        assert!(yaml.contains("check: \"initialize\""));
        assert!(yaml.contains("CONF-001"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn emits_a_check_only_for_advertised_capabilities() {
        let caps = json!({ "tools": {}, "prompts": {} });
        let yaml = render_conformance_suite(&caps, &ConformanceOptions::default());
        assert!(yaml.contains("check: \"tools/list\""));
        assert!(yaml.contains("check: \"prompts/list\""));
        assert!(
            !yaml.contains("resources/list"),
            "no resources capability, so no resources/list check"
        );
        validate_against_v1(&yaml);
    }

    #[test]
    fn pins_the_spec_version() {
        let opts = ConformanceOptions {
            spec_version: "v2025-03-26".to_string(),
            ..ConformanceOptions::default()
        };
        let yaml = render_conformance_suite(&json!({ "tools": {} }), &opts);
        assert!(yaml.contains("spec_version: \"v2025-03-26\""));
        validate_against_v1(&yaml);
    }

    #[test]
    fn rendering_is_deterministic() {
        let caps = json!({ "tools": {}, "resources": {}, "prompts": {} });
        let first = render_conformance_suite(&caps, &ConformanceOptions::default());
        let second = render_conformance_suite(&caps, &ConformanceOptions::default());
        assert_eq!(first, second);
        validate_against_v1(&first);
    }
}
