//! Generate per-tool YAML test stubs from a `tools/list` response.
//!
//! Why this exists: a fresh mcptest project against an unknown server should
//! not start from a blank YAML file. Given the server's catalog we can emit a
//! sensible default: one happy-path call per tool (arguments synthesized to
//! satisfy the input schema via [`super::synthesis`]), one
//! missing-required-arg case, one type-mismatch case per required argument,
//! and, when the tool declares an `outputSchema`, a conformance test for its
//! structured result. The developer then edits the stub to taste.

use serde_json::Value;
use thiserror::Error;

use super::{emit, synthesis};

/// One generated YAML stub for a single tool.
///
/// The YAML is rendered, not parsed. Callers usually write each stub to disk
/// under a path derived from `tool_name`.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolStub {
    /// Name of the tool the stub targets.
    pub tool_name: String,
    /// Rendered YAML document for this tool.
    pub yaml: String,
}

/// Reasons stub generation can fail.
///
/// Both variants point at a malformed catalog. A well-formed empty catalog
/// (`{"tools": []}`) returns an empty vector, not an error.
#[derive(Debug, Error)]
pub enum StubGenError {
    /// The catalog has no `tools` array at the top level.
    #[error("catalog missing tools[]")]
    NoToolsArray,
    /// A tool entry is missing its `name` field.
    #[error("tool entry missing name")]
    MissingName,
}

/// Knobs for [`generate_with`].
///
/// `ToolTest` requires a `server:` key (a name into the suite's `servers`
/// map), so the generator has to know which server name to write. Held as a
/// struct, mirroring [`super::suite::SuiteOptions`], so a future flag can land
/// without churning the call signature.
#[derive(Debug, Clone)]
pub struct StubOptions {
    /// Server key the generated tests reference, also the name written under
    /// the emitted `servers:` block so each stub is runnable as written.
    pub server_name: String,
    /// 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 `--check` baselines do not drift.
    pub probe: Option<super::probe::ProbeOptions>,
}

impl Default for StubOptions {
    fn default() -> Self {
        Self {
            server_name: "server".to_string(),
            include_output_schema_tests: true,
            probe: None,
        }
    }
}

/// Generate per-tool YAML stubs from a `tools/list` response.
///
/// Convenience wrapper over [`generate_with`] using the default server name.
/// Returns one YAML document per tool, each schema-valid against
/// `schemas/v1.json`.
pub fn generate(catalog: &Value) -> Result<Vec<ToolStub>, StubGenError> {
    generate_with(catalog, &StubOptions::default())
}

/// Generate per-tool YAML stubs from a `tools/list` response.
///
/// Returns one YAML document per tool. Each is a standalone, schema-valid
/// suite: a `servers:` block plus a happy-path test, edge cases
/// (missing-required-arg, type-mismatch), and an output-schema conformance
/// test when the tool declares one. The `server:` key on every test
/// references [`StubOptions::server_name`].
pub fn generate_with(catalog: &Value, opts: &StubOptions) -> Result<Vec<ToolStub>, StubGenError> {
    let tools = catalog
        .get("tools")
        .and_then(Value::as_array)
        .ok_or(StubGenError::NoToolsArray)?;

    let mut stubs = Vec::with_capacity(tools.len());
    for tool in tools {
        let name = tool
            .get("name")
            .and_then(Value::as_str)
            .ok_or(StubGenError::MissingName)?
            .to_string();
        let input_schema = input_schema_of(tool);
        let output_schema = output_schema_of(tool);
        let destructive = crate::exec_policy::classify_descriptor(tool)
            == crate::exec_policy::ToolClass::Destructive;
        let yaml = render_stub(
            &name,
            opts,
            &input_schema,
            output_schema.as_ref(),
            destructive,
        );
        stubs.push(ToolStub {
            tool_name: name,
            yaml,
        });
    }
    Ok(stubs)
}

/// Read the input schema, accepting both the wire spelling (`inputSchema`)
/// and the snake_case used by mock manifests (`input_schema`).
pub(crate) fn input_schema_of(tool: &Value) -> Value {
    tool.get("inputSchema")
        .or_else(|| tool.get("input_schema"))
        .cloned()
        .unwrap_or(Value::Null)
}

/// Read the declared output schema, accepting both spellings like
/// [`input_schema_of`]. `None` means the tool declares no output contract.
pub(crate) fn output_schema_of(tool: &Value) -> Option<Value> {
    tool.get("outputSchema")
        .or_else(|| tool.get("output_schema"))
        .cloned()
}

fn render_stub(
    name: &str,
    opts: &StubOptions,
    input_schema: &Value,
    output_schema: Option<&Value>,
    destructive: bool,
) -> String {
    let server = &opts.server_name;
    let happy = synthesis::synthesize_args(input_schema);
    let mut out = String::new();
    out.push_str("# Auto-generated by mcptest. Edit freely.\n\n");
    out.push_str(&render_servers(server));
    out.push_str("tools:\n");

    // Destructive tools (WOR-1285) carry the shared review marker above
    // their tests, matching scaffold, probe, and suite rendering: the stub
    // is generated, never auto-executed, and a human reads the warning
    // before the first run.
    if destructive {
        out.push_str(&emit::render_review_marker());
    }
    out.push_str(&emit::render_notes(&happy.notes));
    match happy.value.as_ref().and_then(Value::as_object) {
        Some(args) => out.push_str(&render_happy_path(name, server, args)),
        None => out.push_str(&emit::render_skipped_case(
            &format!("{name}: happy path with required args"),
            "the input schema constraints are unsatisfiable (see notes above)",
        )),
    }
    out.push_str(&render_missing_required(name, server));
    for (arg, prop) in synthesis::required_properties(input_schema) {
        if let Some(wrong) = synthesis::mismatch_value(&prop, input_schema) {
            out.push_str(&render_type_mismatch(name, server, &arg, &wrong));
        }
    }
    if opts.include_output_schema_tests {
        out.push_str(&emit::render_output_schema_section(
            name,
            server,
            &happy,
            output_schema,
        ));
    }
    if let Some(probe_opts) = &opts.probe {
        out.push_str(&super::probe::render_probe_section(
            name,
            server,
            input_schema,
            &happy,
            destructive,
            probe_opts,
        ));
    }
    out
}

/// Render the `servers:` block. `ToolTest.server` is a key into this map, and
/// the document-level schema requires a `servers:` map, so each standalone
/// stub carries its own. The comment nudges the user to point it at a real
/// target before running.
fn render_servers(server: &str) -> String {
    let mut s = String::new();
    s.push_str("servers:\n");
    s.push_str(&format!("  {server}:\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_happy_path(name: &str, server: &str, args: &serde_json::Map<String, Value>) -> String {
    let mut s = String::new();
    s.push_str(&format!(
        "  - name: \"{name}: happy path with required args\"\n"
    ));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {name}\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_missing_required(name: &str, server: &str) -> String {
    let mut s = String::new();
    s.push_str(&format!(
        "  - name: \"{name}: rejects missing required arg\"\n"
    ));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {name}\n"));
    s.push_str("    args: {}\n");
    s.push_str(&render_error_expectation());
    s
}

fn render_type_mismatch(name: &str, server: &str, arg: &str, wrong: &Value) -> String {
    let mut s = String::new();
    s.push_str(&format!(
        "  - name: \"{name}: rejects type-mismatched {arg}\"\n"
    ));
    s.push_str(&format!("    server: {server}\n"));
    s.push_str(&format!("    tool: {name}\n"));
    s.push_str("    args:\n");
    s.push_str(&format!("      {arg}: {}\n", emit::yaml_scalar(wrong)));
    s.push_str(&render_error_expectation());
    s
}

/// Render the `expect:` assertion array shared by the two negative cases.
///
/// A bad call should surface a tool error, so we assert `result.isError`
/// equals true, mirroring `gen.rs`'s violation case. This is the array short
/// form, so budget keys like `max_duration_ms` (only valid on the Expectation
/// block) are deliberately left out.
fn render_error_expectation() -> String {
    let mut s = String::new();
    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 bad call should surface an error\"\n\n");
    s
}

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

    fn parse_yaml(yaml: &str) -> serde_yaml::Value {
        serde_yaml::from_str(yaml).expect("generated YAML must parse")
    }

    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("stub 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(), "stub should validate, got: {errors:#?}");
    }

    #[test]
    fn every_generated_stub_validates_against_v1_schema() {
        let catalog = json!({
            "tools": [
                {
                    "name": "search",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "query": { "type": "string" },
                            "limit": { "type": "integer" }
                        },
                        "required": ["query", "limit"]
                    }
                },
                {
                    "name": "ping",
                    "inputSchema": { "type": "object", "properties": {}, "required": [] }
                }
            ]
        });
        let stubs = generate(&catalog).unwrap();
        assert_eq!(stubs.len(), 2);
        for stub in &stubs {
            validate_against_v1(&stub.yaml);
        }
    }

    #[test]
    fn custom_server_name_lands_on_every_test_and_the_servers_block() {
        let catalog = json!({
            "tools": [{
                "name": "echo",
                "inputSchema": {
                    "type": "object",
                    "properties": { "msg": { "type": "string" } },
                    "required": ["msg"]
                }
            }]
        });
        let opts = StubOptions {
            server_name: "fs".to_string(),
            ..StubOptions::default()
        };
        let stubs = generate_with(&catalog, &opts).unwrap();
        assert!(stubs[0].yaml.contains("servers:\n  fs:"));
        assert!(stubs[0].yaml.contains("server: fs"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn generate_from_empty_catalog_returns_empty_vec() {
        let catalog = json!({"tools": []});
        let stubs = generate(&catalog).expect("empty catalog is valid");
        assert!(stubs.is_empty());
    }

    #[test]
    fn generate_skips_when_no_tools_field() {
        let catalog = json!({});
        let err = generate(&catalog).expect_err("missing tools[] is an error");
        assert!(matches!(err, StubGenError::NoToolsArray));
    }

    #[test]
    fn generate_skips_tool_missing_name() {
        let catalog = json!({"tools": [{"inputSchema": {}}]});
        let err = generate(&catalog).expect_err("missing name is an error");
        assert!(matches!(err, StubGenError::MissingName));
    }

    #[test]
    fn generate_one_tool_produces_one_stub_with_happy_path() {
        let catalog = json!({
            "tools": [{
                "name": "ping",
                "inputSchema": {"type": "object", "properties": {}, "required": []}
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert_eq!(stubs.len(), 1);
        assert_eq!(stubs[0].tool_name, "ping");
        assert!(stubs[0]
            .yaml
            .contains("ping: happy path with required args"));
        let parsed = parse_yaml(&stubs[0].yaml);
        assert!(parsed.get("tools").is_some());
    }

    #[test]
    fn generate_includes_missing_required_arg_test() {
        let catalog = json!({
            "tools": [{
                "name": "echo",
                "inputSchema": {
                    "type": "object",
                    "properties": {"msg": {"type": "string"}},
                    "required": ["msg"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0].yaml.contains("rejects missing required arg"));
        // A missing-required call asserts the tool surfaces an error.
        assert!(stubs[0].yaml.contains("target: result.isError"));
        let _ = parse_yaml(&stubs[0].yaml);
    }

    #[test]
    fn generate_includes_type_mismatch_test_per_required_arg() {
        let catalog = json!({
            "tools": [{
                "name": "add",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "a": {"type": "integer"},
                        "b": {"type": "integer"}
                    },
                    "required": ["a", "b"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0].yaml.contains("rejects type-mismatched a"));
        assert!(stubs[0].yaml.contains("rejects type-mismatched b"));
        let _ = parse_yaml(&stubs[0].yaml);
    }

    #[test]
    fn string_arg_uses_sample_minimal_value() {
        let catalog = json!({
            "tools": [{
                "name": "say",
                "inputSchema": {
                    "type": "object",
                    "properties": {"text": {"type": "string"}},
                    "required": ["text"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0].yaml.contains("text: \"sample\""));
    }

    #[test]
    fn enum_arg_uses_first_value() {
        let catalog = json!({
            "tools": [{
                "name": "set_mode",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "mode": {"type": "string", "enum": ["fast", "slow"]}
                    },
                    "required": ["mode"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0].yaml.contains("mode: \"fast\""));
    }

    #[test]
    fn format_aware_arg_uses_a_plausible_uri() {
        let catalog = json!({
            "tools": [{
                "name": "fetch",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "url": {"type": "string", "format": "uri"}
                    },
                    "required": ["url"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0]
            .yaml
            .contains("url: \"https://example.com/resource\""));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn bool_arg_type_mismatch_is_string() {
        let catalog = json!({
            "tools": [{
                "name": "toggle",
                "inputSchema": {
                    "type": "object",
                    "properties": {"on": {"type": "boolean"}},
                    "required": ["on"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        // Happy path uses literal true, mismatch line uses the string "true".
        assert!(stubs[0].yaml.contains("on: true\n"));
        assert!(stubs[0].yaml.contains("on: \"true\"\n"));
        let _ = parse_yaml(&stubs[0].yaml);
    }

    #[test]
    fn untyped_required_arg_gets_no_type_mismatch_case() {
        let catalog = json!({
            "tools": [{
                "name": "loose",
                "inputSchema": {
                    "type": "object",
                    "properties": { "anything": { "description": "untyped" } },
                    "required": ["anything"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        // Nothing concrete to violate, so no fabricated mismatch case.
        assert!(!stubs[0].yaml.contains("rejects type-mismatched anything"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn tool_without_input_schema_gets_empty_args_and_a_note() {
        let catalog = json!({ "tools": [{ "name": "bare" }] });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0].yaml.contains("args: {}"));
        assert!(stubs[0]
            .yaml
            .contains("# note: tool declares no input schema"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn contradictory_constraints_skip_the_happy_path_with_a_comment() {
        let catalog = json!({
            "tools": [{
                "name": "broken",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "s": { "type": "string", "minLength": 9, "maxLength": 2 }
                    },
                    "required": ["s"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0].yaml.contains("# Skipped"));
        assert!(!stubs[0].yaml.contains("happy path with required args\"\n"));
        assert!(stubs[0]
            .yaml
            .contains("minLength 9 is greater than maxLength 2"));
        // The negative cases still make the stub a runnable document.
        assert!(stubs[0].yaml.contains("rejects missing required arg"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn output_schema_tool_gets_a_conformance_test() {
        let catalog = json!({
            "tools": [{
                "name": "forecast",
                "inputSchema": {
                    "type": "object",
                    "properties": { "city": { "type": "string" } },
                    "required": ["city"]
                },
                "outputSchema": {
                    "type": "object",
                    "required": ["temp_c"],
                    "properties": { "temp_c": { "type": "number" } }
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0]
            .yaml
            .contains("forecast: response conforms to declared output schema"));
        assert!(stubs[0].yaml.contains("target: result.structuredContent"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn output_schema_tests_can_be_disabled() {
        let catalog = json!({
            "tools": [{
                "name": "forecast",
                "inputSchema": { "type": "object" },
                "outputSchema": { "type": "object" }
            }]
        });
        let opts = StubOptions {
            include_output_schema_tests: false,
            ..StubOptions::default()
        };
        let stubs = generate_with(&catalog, &opts).unwrap();
        assert!(!stubs[0].yaml.contains("conforms to declared output schema"));
        assert!(!stubs[0].yaml.contains("declares no output schema"));
    }

    #[test]
    fn tool_without_output_schema_gets_the_nudge_comment() {
        let catalog = json!({
            "tools": [{ "name": "ping", "inputSchema": { "type": "object" } }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(stubs[0]
            .yaml
            .contains("declares no output schema, consider adding one"));
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn generated_yaml_round_trips_for_mixed_catalog() {
        let catalog = json!({
            "tools": [
                {
                    "name": "search",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "query": {"type": "string"},
                            "limit": {"type": "integer"},
                            "filters": {"type": "object"},
                            "tags": {"type": "array"}
                        },
                        "required": ["query", "limit"]
                    }
                },
                {
                    "name": "noop",
                    "inputSchema": {"type": "object", "properties": {}, "required": []}
                }
            ]
        });
        let stubs = generate(&catalog).unwrap();
        assert_eq!(stubs.len(), 2);
        for stub in &stubs {
            let _ = parse_yaml(&stub.yaml);
        }
    }

    #[test]
    fn destructive_named_tool_stub_carries_the_review_marker_above_its_tests() {
        let catalog = json!({
            "tools": [{
                "name": "delete_note",
                "inputSchema": {
                    "type": "object",
                    "properties": { "id": { "type": "string" } },
                    "required": ["id"]
                }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        let yaml = &stubs[0].yaml;
        assert!(yaml.contains("# review before first run"), "got: {yaml}");
        // The marker sits above the first test entry, not after it.
        let marker_pos = yaml.find("# review before first run").unwrap();
        let first_test_pos = yaml.find("- name:").unwrap();
        assert!(marker_pos < first_test_pos, "marker must precede the tests");
        validate_against_v1(yaml);
    }

    #[test]
    fn destructive_hint_annotated_tool_stub_carries_the_review_marker() {
        let catalog = json!({
            "tools": [{
                "name": "apply_change",
                "annotations": { "destructiveHint": true },
                "inputSchema": { "type": "object", "properties": {} }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(
            stubs[0].yaml.contains("# review before first run"),
            "annotation-classified destructive tool must get the marker, got: {}",
            stubs[0].yaml
        );
        validate_against_v1(&stubs[0].yaml);
    }

    #[test]
    fn read_only_tool_stub_has_no_review_marker() {
        let catalog = json!({
            "tools": [{
                "name": "get_note",
                "inputSchema": { "type": "object", "properties": {} }
            }]
        });
        let stubs = generate(&catalog).unwrap();
        assert!(
            !stubs[0].yaml.contains("# review before first run"),
            "read-only tool must not get the marker, got: {}",
            stubs[0].yaml
        );
    }

    #[test]
    fn destructive_stub_generation_is_deterministic() {
        let catalog = json!({
            "tools": [{
                "name": "delete_note",
                "inputSchema": {
                    "type": "object",
                    "properties": { "id": { "type": "string" } },
                    "required": ["id"]
                }
            }]
        });
        let first = generate(&catalog).unwrap();
        let second = generate(&catalog).unwrap();
        assert_eq!(first, second, "marker rendering must not introduce drift");
    }

    #[test]
    fn generation_is_deterministic_for_the_same_catalog() {
        let catalog = json!({
            "tools": [{
                "name": "search",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "query": { "type": "string" },
                        "limit": { "type": "integer", "minimum": 1 }
                    },
                    "required": ["query", "limit"]
                },
                "outputSchema": { "type": "object", "required": ["hits"] }
            }]
        });
        let first = generate(&catalog).unwrap();
        let second = generate(&catalog).unwrap();
        assert_eq!(first, second, "same catalog must produce identical YAML");
    }
}
