//! Shared YAML emission helpers for the per-tool stub generator and the
//! whole-suite generator.
//!
//! Why this exists: both generators must render synthesized args, synthesis
//! notes, and the output-schema conformance case with byte-identical rules,
//! otherwise `generate stubs --check` and `generate suite --update` would
//! disagree about what "regenerated" output looks like.

use serde_json::{Map, Value};

use super::synthesis::SynthesisResult;

/// Render the shared two-line preamble every generator emits: the
/// schema language-server hint plus a "Generated by ..." provenance line so a
/// reader knows which command produced the file and that it is theirs to edit.
pub(crate) fn render_header(generated_by: &str) -> String {
    let mut s = String::new();
    s.push_str("# yaml-language-server: $schema=https://mcptest.sh/schema/v1.json\n");
    s.push_str("#\n");
    s.push_str(&format!("# Generated by `{generated_by}`. Edit freely.\n"));
    s.push_str("#\n");
    s
}

/// Render the `servers:` block the generated suites reference. A single server
/// keyed by `server_name` with a placeholder command, so the document is
/// runnable as written once the command (or `url:`) is filled in.
pub(crate) fn render_servers_block(server_name: &str) -> String {
    let mut s = String::new();
    s.push_str("servers:\n");
    s.push_str(&format!("  {server_name}:\n"));
    s.push_str("    # Point this at your server. Replace with `url:` for an HTTP target.\n");
    s.push_str("    command: [\"./your-server\"]\n\n");
    s
}

/// Render a JSON value as a single-line YAML scalar. YAML is a JSON
/// superset, so compact JSON renders arrays and objects cleanly; strings are
/// double-quoted with the two characters YAML needs escaped inside a
/// double-quoted scalar.
pub(crate) fn yaml_scalar(value: &Value) -> String {
    match value {
        Value::Null => "null".to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
        Value::Array(_) | Value::Object(_) => value.to_string(),
    }
}

/// Render the `args:` block for a test case, indented two levels under the
/// test entry. An empty map renders the inline `{}` form.
pub(crate) fn render_args_block(args: &Map<String, Value>) -> String {
    if args.is_empty() {
        return "    args: {}\n".to_string();
    }
    let mut s = String::from("    args:\n");
    for (key, value) in args {
        s.push_str(&format!("      {key}: {}\n", yaml_scalar(value)));
    }
    s
}

/// Render the shared `exec_policy::REVIEW_MARKER` as YAML comments at
/// test-entry indentation. Lives here so the per-tool stub generator and the
/// probe tier place byte-identical marker blocks above destructive tools'
/// tests, keeping `--check` baselines stable across both paths.
pub(crate) fn render_review_marker() -> String {
    crate::exec_policy::REVIEW_MARKER
        .lines()
        .map(|line| format!("  {line}\n"))
        .collect()
}

/// Render synthesis notes as YAML comments at test-entry indentation, so a
/// reader sees the caveat right next to the case it applies to.
pub(crate) fn render_notes(notes: &[String]) -> String {
    notes
        .iter()
        .map(|n| format!("  # note: {}\n", comment_safe(n)))
        .collect()
}

/// Render the comment that replaces a test we could not generate. The name
/// is spelled out so a `grep` for the would-be test still finds the reason.
pub(crate) fn render_skipped_case(test_name: &str, reason: &str) -> String {
    format!(
        "  # Skipped \"{}\": {}\n\n",
        comment_safe(test_name),
        comment_safe(reason)
    )
}

/// Emit the output-schema conformance case for one tool, or the explanatory
/// comment when no test can be generated. Policy lives here so `generate
/// stubs` and `generate suite` cannot drift apart:
/// - no `outputSchema` declared: nudge comment, never a fabricated check;
/// - schema suggests binary content: out of scope, note instead;
/// - happy-path args unsatisfiable: skip comment (the call cannot be made);
/// - otherwise a test asserting `result.structuredContent` against the
///   declared schema with the `schema:` matcher.
pub(crate) fn render_output_schema_section(
    tool: &str,
    server: &str,
    happy: &SynthesisResult,
    output_schema: Option<&Value>,
) -> String {
    let Some(schema) = output_schema else {
        return format!(
            "  # note: tool `{tool}` declares no output schema, consider adding one\n\n"
        );
    };
    if !schema.is_object() {
        return format!(
            "  # note: tool `{tool}` declares a non-object output schema; no conformance test emitted\n\n"
        );
    }
    if suggests_binary(schema) {
        return format!(
            "  # note: tool `{tool}` output schema suggests binary content; conformance checks for binary payloads are out of scope\n\n"
        );
    }
    let Some(args) = happy.value.as_ref().and_then(Value::as_object) else {
        return render_skipped_case(
            &format!("{tool}: response conforms to declared output schema"),
            "happy-path arguments could not be synthesized",
        );
    };
    let mut s = String::new();
    s.push_str(&format!(
        "  - name: \"{tool}: response conforms to declared output schema\"\n"
    ));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {tool}\n"));
    s.push_str(&render_args_block(args));
    s.push_str("    expect:\n");
    s.push_str("      - target: result.structuredContent\n");
    s.push_str("        matcher:\n");
    s.push_str(&format!("          schema: {schema}\n"));
    s.push_str(
        "        message: \"structuredContent should conform to the declared outputSchema\"\n\n",
    );
    s
}

/// Heuristic for output schemas describing binary payloads (base64 blobs,
/// image bytes). A structural matcher on such content asserts nothing useful
/// and bloats the YAML, so the generators emit a note instead.
pub(crate) fn suggests_binary(schema: &Value) -> bool {
    match schema {
        Value::Object(map) => map.iter().any(|(key, value)| {
            key == "contentEncoding"
                || (key == "format" && matches!(value.as_str(), Some("binary" | "byte")))
                || suggests_binary(value)
        }),
        Value::Array(items) => items.iter().any(suggests_binary),
        _ => false,
    }
}

/// YAML comments must stay on one line; a note derived from a schema string
/// could otherwise smuggle a newline and corrupt the document.
fn comment_safe(text: &str) -> String {
    text.replace(['\n', '\r'], " ")
}

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

    fn happy_with(args: Value) -> SynthesisResult {
        SynthesisResult {
            value: Some(args),
            notes: Vec::new(),
        }
    }

    #[test]
    fn missing_output_schema_renders_the_nudge_comment() {
        let out = render_output_schema_section("ping", "server", &happy_with(json!({})), None);
        assert!(out.contains("declares no output schema, consider adding one"));
        assert!(!out.contains("- name:"));
    }

    #[test]
    fn binary_output_schema_renders_a_note_instead_of_a_test() {
        let schema = json!({
            "type": "object",
            "properties": { "blob": { "type": "string", "contentEncoding": "base64" } }
        });
        let out =
            render_output_schema_section("dump", "server", &happy_with(json!({})), Some(&schema));
        assert!(out.contains("binary content"));
        assert!(!out.contains("- name:"));
    }

    #[test]
    fn conformance_case_targets_structured_content_with_the_schema_matcher() {
        let schema = json!({ "type": "object", "required": ["temp"] });
        let out = render_output_schema_section(
            "forecast",
            "wx",
            &happy_with(json!({ "city": "Berlin" })),
            Some(&schema),
        );
        assert!(out.contains("forecast: response conforms to declared output schema"));
        assert!(out.contains("target: result.structuredContent"));
        assert!(
            out.contains("schema: {\"required\":[\"temp\"],\"type\":\"object\"}")
                || out.contains("schema: {\"type\":\"object\",\"required\":[\"temp\"]}")
        );
    }

    #[test]
    fn unsatisfiable_happy_path_skips_the_conformance_case() {
        let happy = SynthesisResult {
            value: None,
            notes: vec!["minLength 9 is greater than maxLength 2".to_string()],
        };
        let out = render_output_schema_section(
            "broken",
            "server",
            &happy,
            Some(&json!({ "type": "object" })),
        );
        assert!(out.contains("# Skipped"));
        assert!(!out.contains("- name:"));
    }

    #[test]
    fn notes_render_one_comment_line_each_with_newlines_flattened() {
        let out = render_notes(&["first".to_string(), "two\nlines".to_string()]);
        assert_eq!(out, "  # note: first\n  # note: two lines\n");
    }
}
