//! Whole-suite renderer for `mcptest generate suite`.
//!
//! Why this exists: the per-tool stubs and the single-document suite must
//! synthesize arguments with the same schema-aware rules, otherwise the two
//! generators would disagree about what a happy-path call looks like. This
//! module renders the suite shape (`servers:` block, multi-model matrix,
//! valid/edge/violation cases per tool plus the output-schema conformance
//! case) on top of [`super::synthesis`]. It superseded, and WOR-1277
//! retired, the fixed-table renderer that used to live in `gen.rs`.

use serde_json::{json, Map, Value};

use super::tool_stubs::{input_schema_of, output_schema_of};
use super::{emit, synthesis};

/// Default multi-model matrix written into every generated suite.
///
/// The list spans the four auto-detected provider families so an agent-style
/// suite is multi-model from the first run. Callers override it through
/// [`SuiteOptions::models`] when they care about a specific lineup. The
/// strings land under the top-level `model_compatibility:` key, which
/// reporters read to label per-model results.
pub const DEFAULT_MODELS: &[&str] = &[
    "claude-sonnet-4-5",
    "gpt-5",
    "gemini-2.5-pro",
    "mistral-large-latest",
];

/// Options that steer [`render_suite`].
///
/// The defaults emit the full set: a valid-arg case, an edge case, a
/// schema-violation case, and an output-schema conformance case per tool,
/// plus the [`DEFAULT_MODELS`] matrix.
#[derive(Debug, Clone)]
pub struct SuiteOptions {
    /// Server key the generated tests reference. Also the name under the
    /// emitted `servers:` block, so the suite is runnable as written.
    pub server_name: String,
    /// Model identifiers written to the `model_compatibility:` matrix.
    pub models: Vec<String>,
    /// Emit a boundary-value edge case per tool when `true`.
    pub include_edge: bool,
    /// Emit a schema-violation case per tool when `true`.
    pub include_violation: bool,
    /// Emit the output-schema conformance case for tools that declare an
    /// `outputSchema`. On by default; `--no-output-schema-tests` clears it.
    pub include_output_schema_tests: bool,
    /// Probe tier (WOR-1260): also emit boundary and negative probe cases
    /// per tool when set. `None` (the default) keeps output byte-identical
    /// to pre-probe releases so existing baselines do not drift.
    pub probe: Option<super::probe::ProbeOptions>,
}

impl Default for SuiteOptions {
    fn default() -> Self {
        Self {
            server_name: "server".to_string(),
            models: DEFAULT_MODELS.iter().map(|m| m.to_string()).collect(),
            include_edge: true,
            include_violation: true,
            include_output_schema_tests: true,
            probe: None,
        }
    }
}

/// Render a starter YAML suite from a `tools/list` tools array.
///
/// Arguments are synthesized schema-aware (enums, formats, bounds, local
/// `$ref`, `allOf`/`anyOf`/`oneOf`); unsatisfiable tools keep their negative
/// cases and get a skip comment instead of a happy-path test. Tools without
/// a `name` are skipped so a partially malformed catalog still renders.
///
/// ```
/// use mcptest_core::stub_gen::{render_suite, SuiteOptions};
/// use serde_json::json;
///
/// let tools = vec![json!({
///     "name": "echo",
///     "inputSchema": {
///         "type": "object",
///         "properties": { "msg": { "type": "string" } },
///         "required": ["msg"]
///     }
/// })];
/// let yaml = render_suite(&tools, &SuiteOptions::default());
/// assert!(yaml.contains("tool: echo"));
/// ```
pub fn render_suite(tools: &[Value], opts: &SuiteOptions) -> String {
    let mut out = String::new();
    out.push_str("# yaml-language-server: $schema=https://mcptest.sh/schema/v1.json\n");
    out.push_str("#\n");
    out.push_str("# Generated by `mcptest generate suite`. Edit freely.\n");
    out.push_str("#\n");
    out.push_str("# Each tool gets a valid-arg test, a boundary test, and a\n");
    out.push_str("# schema-violation test. The model_compatibility matrix below is a\n");
    out.push_str("# placeholder: add or drop identifiers to match the models you run.\n\n");

    out.push_str(&render_servers(&opts.server_name));
    out.push_str(&render_model_matrix(&opts.models));

    let named: Vec<&Value> = tools
        .iter()
        .filter(|tool| {
            tool.get("name")
                .and_then(Value::as_str)
                .is_some_and(|n| !n.is_empty())
        })
        .collect();

    let body: String = named
        .iter()
        .map(|tool| render_tool_cases(tool, opts))
        .collect();
    // `tools:` followed by nothing but comments parses as null and the
    // schema demands an array, so the key gets the explicit `[]` form when
    // no tool produced a test entry (empty catalog, or every case skipped).
    let has_entry = body
        .lines()
        .any(|line| line.trim_start().starts_with("- name:"));
    if has_entry {
        out.push_str("tools:\n");
    } else {
        out.push_str("tools: []\n");
    }
    out.push_str(&body);
    out
}

fn render_servers(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
}

fn render_model_matrix(models: &[String]) -> String {
    if models.is_empty() {
        return String::new();
    }
    let mut s = String::new();
    s.push_str("# Multi-model matrix placeholder. Reporters label results per model.\n");
    s.push_str("model_compatibility:\n");
    for model in models {
        s.push_str(&format!("  - {}\n", emit::yaml_scalar(&json!(model))));
    }
    s.push('\n');
    s
}

/// Render every case for one tool: synthesis notes, the valid case (or its
/// skip comment), the optional edge and violation cases, and the
/// output-schema conformance section.
fn render_tool_cases(tool: &Value, opts: &SuiteOptions) -> String {
    // The filter in render_suite guarantees the name; an empty fallback would
    // never render but keeps this total without an unwrap.
    let name = tool.get("name").and_then(Value::as_str).unwrap_or("");
    let server = &opts.server_name;
    let input_schema = input_schema_of(tool);
    let happy = synthesis::synthesize_args(&input_schema);

    let mut s = String::new();
    s.push_str(&emit::render_notes(&happy.notes));
    match happy.value.as_ref().and_then(Value::as_object) {
        Some(args) => {
            s.push_str(&render_valid_case(name, server, args));
            if opts.include_edge {
                s.push_str(&render_edge_case(name, server, &input_schema, args));
            }
            if opts.include_violation {
                if let Some(case) = render_violation_case(name, server, &input_schema, args) {
                    s.push_str(&case);
                }
            }
        }
        None => s.push_str(&emit::render_skipped_case(
            &format!("{name}: valid arguments"),
            "the input schema constraints are unsatisfiable (see notes above)",
        )),
    }
    if opts.include_output_schema_tests {
        s.push_str(&emit::render_output_schema_section(
            name,
            server,
            &happy,
            output_schema_of(tool).as_ref(),
        ));
    }
    if let Some(probe_opts) = &opts.probe {
        let destructive = crate::exec_policy::classify_descriptor(tool)
            == crate::exec_policy::ToolClass::Destructive;
        s.push_str(&super::probe::render_probe_section(
            name,
            server,
            &input_schema,
            &happy,
            destructive,
            probe_opts,
        ));
    }
    s
}

fn render_valid_case(tool: &str, server: &str, args: &Map<String, Value>) -> String {
    let mut s = String::new();
    s.push_str(&format!("  - name: \"{tool}: valid arguments\"\n"));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {tool}\n"));
    s.push_str(&emit::render_args_block(args));
    s.push_str("    expect:\n");
    s.push_str("      - target: result\n");
    s.push_str("        matcher:\n");
    s.push_str("          schema:\n");
    s.push_str("            type: object\n");
    s.push_str("        message: \"result should be a structured tool result\"\n\n");
    s
}

fn render_edge_case(
    tool: &str,
    server: &str,
    input_schema: &Value,
    happy_args: &Map<String, Value>,
) -> String {
    let mut args = Map::new();
    for (name, prop) in synthesis::required_properties(input_schema) {
        let fallback = happy_args.get(&name).cloned().unwrap_or(json!("sample"));
        args.insert(name, boundary_value(&prop, fallback));
    }
    let mut s = String::new();
    s.push_str(&format!("  - name: \"{tool}: boundary edge case\"\n"));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {tool}\n"));
    s.push_str(&emit::render_args_block(&args));
    s.push_str("    expect:\n");
    s.push_str("      - target: result\n");
    s.push_str("        matcher:\n");
    s.push_str("          schema:\n");
    s.push_str("            type: object\n");
    s.push_str("        message: \"boundary values should still return a structured result\"\n\n");
    s
}

/// Sit on the declared edge of a property where one exists; otherwise reuse
/// the happy-path value so the case still runs.
fn boundary_value(prop: &Value, fallback: Value) -> Value {
    match prop.get("type").and_then(Value::as_str) {
        Some("integer") | Some("number") => prop
            .get("minimum")
            .cloned()
            .or_else(|| prop.get("maximum").cloned())
            .unwrap_or_else(|| json!(0)),
        Some("string") => match prop.get("minLength").and_then(Value::as_u64) {
            Some(0) | None => json!(""),
            Some(n) => json!("x".repeat(n as usize)),
        },
        Some("array") => json!([]),
        Some("boolean") => json!(false),
        _ => fallback,
    }
}

/// Build the violation case: happy-path args with the first required typed
/// property swapped for a genuinely wrong-typed value. `None` when no
/// required property declares anything concrete to violate.
fn render_violation_case(
    tool: &str,
    server: &str,
    input_schema: &Value,
    happy_args: &Map<String, Value>,
) -> Option<String> {
    let mut args = happy_args.clone();
    let violated = synthesis::required_properties(input_schema)
        .into_iter()
        .find_map(|(name, prop)| {
            synthesis::mismatch_value(&prop, input_schema).map(|wrong| (name, wrong))
        })?;
    args.insert(violated.0, violated.1);

    let mut s = String::new();
    s.push_str(&format!("  - name: \"{tool}: rejects schema violation\"\n"));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {tool}\n"));
    s.push_str(&emit::render_args_block(&args));
    s.push_str("    expect:\n");
    s.push_str("      - target: result.isError\n");
    s.push_str("        matcher:\n");
    s.push_str("          exact: true\n");
    s.push_str("        message: \"a schema-violating call should surface an error\"\n\n");
    Some(s)
}

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

    fn workspace_root() -> PathBuf {
        // CARGO_MANIFEST_DIR is crates/mcptest-core; two parents up is root.
        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:#?}");
    }

    fn echo_tool() -> Value {
        json!({
            "name": "echo",
            "inputSchema": {
                "type": "object",
                "properties": { "msg": { "type": "string" } },
                "required": ["msg"]
            }
        })
    }

    #[test]
    fn empty_catalog_still_emits_runnable_skeleton() {
        let yaml = render_suite(&[], &SuiteOptions::default());
        let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
        assert!(parsed.get("servers").is_some());
        assert!(parsed.get("tools").is_some());
        validate_against_v1(&yaml);
    }

    #[test]
    fn emits_valid_edge_and_violation_cases_per_tool() {
        let yaml = render_suite(&[echo_tool()], &SuiteOptions::default());
        assert!(yaml.contains("echo: valid arguments"));
        assert!(yaml.contains("echo: boundary edge case"));
        assert!(yaml.contains("echo: rejects schema violation"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn valid_case_synthesizes_schema_aware_args() {
        let tool = json!({
            "name": "page",
            "inputSchema": {
                "type": "object",
                "properties": { "limit": { "type": "integer", "minimum": 5 } },
                "required": ["limit"]
            }
        });
        let yaml = render_suite(&[tool], &SuiteOptions::default());
        // The valid case sits on the declared minimum, not the legacy 1.
        assert!(yaml.contains("limit: 5"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn violation_case_swaps_in_a_wrong_typed_value() {
        let yaml = render_suite(&[echo_tool()], &SuiteOptions::default());
        assert!(yaml.contains("msg: 12345"));
        assert!(yaml.contains("target: result.isError"));
    }

    #[test]
    fn disabling_edge_and_violation_emits_only_valid_case() {
        let opts = SuiteOptions {
            include_edge: false,
            include_violation: false,
            ..SuiteOptions::default()
        };
        let yaml = render_suite(&[echo_tool()], &opts);
        assert!(yaml.contains("echo: valid arguments"));
        assert!(!yaml.contains("boundary edge case"));
        assert!(!yaml.contains("rejects schema violation"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn default_options_emit_the_model_matrix() {
        let yaml = render_suite(&[echo_tool()], &SuiteOptions::default());
        assert!(yaml.contains("model_compatibility:"));
        for model in DEFAULT_MODELS {
            assert!(yaml.contains(model), "missing model {model}");
        }
        validate_against_v1(&yaml);
    }

    #[test]
    fn tool_without_name_is_skipped() {
        let tools = vec![json!({ "inputSchema": {} }), echo_tool()];
        let yaml = render_suite(&tools, &SuiteOptions::default());
        assert!(yaml.contains("tool: echo"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn output_schema_tool_gets_a_conformance_case() {
        let tool = json!({
            "name": "forecast",
            "inputSchema": {
                "type": "object",
                "properties": { "city": { "type": "string" } },
                "required": ["city"]
            },
            "outputSchema": {
                "type": "object",
                "required": ["temp_c"],
                "properties": { "temp_c": { "type": "number" } }
            }
        });
        let yaml = render_suite(&[tool], &SuiteOptions::default());
        assert!(yaml.contains("forecast: response conforms to declared output schema"));
        assert!(yaml.contains("target: result.structuredContent"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn output_schema_cases_can_be_disabled() {
        let tool = json!({
            "name": "forecast",
            "inputSchema": { "type": "object" },
            "outputSchema": { "type": "object" }
        });
        let opts = SuiteOptions {
            include_output_schema_tests: false,
            ..SuiteOptions::default()
        };
        let yaml = render_suite(&[tool], &opts);
        assert!(!yaml.contains("conforms to declared output schema"));
        assert!(!yaml.contains("declares no output schema"));
    }

    #[test]
    fn tool_without_output_schema_gets_the_nudge_comment() {
        let yaml = render_suite(&[echo_tool()], &SuiteOptions::default());
        assert!(yaml.contains("declares no output schema, consider adding one"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn contradictory_tool_skips_valid_case_but_keeps_the_document_valid() {
        let tool = json!({
            "name": "broken",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "n": { "type": "integer", "minimum": 10, "maximum": 3 }
                },
                "required": ["n"]
            }
        });
        let yaml = render_suite(&[tool, echo_tool()], &SuiteOptions::default());
        assert!(yaml.contains("# Skipped \"broken: valid arguments\""));
        assert!(!yaml.contains("- name: \"broken: valid arguments\""));
        assert!(yaml.contains("echo: valid arguments"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn rendering_is_deterministic_for_the_same_catalog() {
        let tools = vec![echo_tool()];
        let first = render_suite(&tools, &SuiteOptions::default());
        let second = render_suite(&tools, &SuiteOptions::default());
        assert_eq!(first, second);
    }
}
