//! `mcptest generate suite` standalone scaffolder.
//!
//! `generate stubs` emits one YAML file per tool. This command emits one
//! self-contained suite document: a `servers:` block, a multi-model matrix,
//! and a valid/edge/violation triple per tool. The output validates against
//! `schemas/v1.json` so it runs as written.
//!
//! The pure renderer lives in [`mcptest_core::stub_gen::suite`], sharing the
//! schema-aware argument synthesis with `generate stubs`; this module reads
//! the tool catalog from a file (deterministic, CI-friendly), invokes the
//! renderer, and routes the result to stdout, a fresh file, or an additive
//! merge into a hand-authored suite. Live `tools/list` introspection is the
//! follow-up; reading from a file keeps v1 reproducible.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use mcptest_core::stub_gen::DEFAULT_MODELS;
use mcptest_core::stub_gen::{render_suite, SuiteOptions};
use serde_json::Value;

/// Plain-data arguments for `mcptest generate suite`.
///
/// Held as its own struct so the CLI dispatcher fills it from clap flags and
/// hands the populated value to [`run_suite`]. A future ticket can add a
/// `url` field for live introspection without churning this signature.
#[derive(Debug, Clone)]
pub struct GenerateSuiteArgs {
    /// Path to the file the declared tool list is read from.
    pub from_config: PathBuf,
    /// Server key the generated tests reference.
    pub server_name: String,
    /// Models for the matrix. `None` falls back to the built-in default list.
    pub models: Option<Vec<String>>,
    /// Emit a boundary edge case per tool unless `false`.
    pub include_edge: bool,
    /// Emit a schema-violation case per tool unless `false`.
    pub include_violation: bool,
    /// Emit an output-schema conformance case for tools that declare an
    /// `outputSchema` unless `false` (`--no-output-schema-tests`).
    pub include_output_schema_tests: bool,
    /// Probe tier (`--probe`): also emit deterministic boundary and negative
    /// probe cases per tool, with the documented default seed and cap.
    pub probe: bool,
    /// Write the suite here instead of stdout.
    pub output: Option<PathBuf>,
    /// Merge into the suite at this path, preserving hand-authored tests.
    pub update: Option<PathBuf>,
    /// Suppress progress lines. Honors the global `--quiet` flag.
    pub quiet: bool,
}

/// Synchronous entry point for `mcptest generate suite <flags>`.
///
/// Reads the tool catalog, renders the suite, then writes to stdout, a fresh
/// file, or merges into an existing suite. Returns the desired exit code: 0
/// on success, 1 on a read or parse failure.
pub fn run_suite(args: GenerateSuiteArgs) -> Result<i32> {
    let tools = match read_tools(&args.from_config) {
        Ok(t) => t,
        Err(err) => {
            eprintln!("mcptest generate suite: {err:#}");
            return Ok(1);
        }
    };
    let opts = SuiteOptions {
        server_name: args.server_name.clone(),
        models: args
            .models
            .clone()
            .unwrap_or_else(|| DEFAULT_MODELS.iter().map(|m| m.to_string()).collect()),
        include_edge: args.include_edge,
        include_violation: args.include_violation,
        include_output_schema_tests: args.include_output_schema_tests,
        probe: args
            .probe
            .then(mcptest_core::stub_gen::ProbeOptions::default),
    };
    let generated = render_suite(&tools, &opts);

    if let Some(path) = &args.update {
        return update_mode(&args, path, &generated);
    }
    if let Some(path) = &args.output {
        fs::write(path, &generated)
            .with_context(|| format!("failed to write {}", path.display()))?;
        if !args.quiet {
            println!("Generated {}", path.display());
        }
        return Ok(0);
    }
    print!("{generated}");
    Ok(0)
}

/// Read the declared tool list from a config/snapshot file.
///
/// Accepts a `tools/list` JSON snapshot (`{"tools": [...]}`), a bare JSON
/// tools array, or a YAML mock manifest (`mock_server.tools[]`). The shapes
/// converge on a `Vec<Value>` so the renderer does not care which produced
/// it. Shared with the coverage scaffolders ([`super::generate_coverage`])
/// that read the same catalog shapes.
pub(crate) fn read_tools(path: &Path) -> Result<Vec<Value>> {
    let value = read_config_value(path)?;
    extract_tools(&value).ok_or_else(|| {
        anyhow!(
            "{} has no tools: expected `tools[]`, a tools array, or `mock_server.tools[]`",
            path.display()
        )
    })
}

/// Read and parse a catalog/snapshot file to a JSON [`Value`], picking JSON or
/// YAML by extension. Shared by [`read_tools`] and the coverage scaffolders so
/// every generator accepts the same file shapes.
pub(crate) fn read_config_value(path: &Path) -> Result<Value> {
    let body = fs::read_to_string(path)
        .with_context(|| format!("failed to read source {}", path.display()))?;
    let ext = path
        .extension()
        .and_then(|s| s.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    match ext.as_str() {
        "yml" | "yaml" => {
            let yaml: serde_yaml::Value = serde_yaml::from_str(&body)
                .with_context(|| format!("failed to parse YAML {}", path.display()))?;
            serde_json::to_value(yaml).context("YAML value converts to JSON")
        }
        _ => serde_json::from_str(&body)
            .with_context(|| format!("failed to parse JSON {}", path.display())),
    }
}

/// Pull a tools array out of any of the accepted container shapes.
fn extract_tools(value: &Value) -> Option<Vec<Value>> {
    if let Some(arr) = value.as_array() {
        return Some(arr.clone());
    }
    if let Some(arr) = value.get("tools").and_then(Value::as_array) {
        return Some(arr.clone());
    }
    value
        .get("mock_server")
        .and_then(|m| m.get("tools"))
        .and_then(Value::as_array)
        .cloned()
}

/// Merge the generated suite into an existing file without clobbering
/// hand-authored tests. We keep every key of the existing document, then walk
/// the generated `tools[]` and append only entries whose `name` is not already
/// present. Existing entries are never rewritten, so local edits survive.
fn update_mode(args: &GenerateSuiteArgs, path: &Path, generated: &str) -> Result<i32> {
    let existing_raw = fs::read_to_string(path)
        .with_context(|| format!("failed to read existing suite {}", path.display()))?;
    let mut existing: serde_yaml::Value = serde_yaml::from_str(&existing_raw)
        .with_context(|| format!("failed to parse existing suite {}", path.display()))?;
    let fresh: serde_yaml::Value =
        serde_yaml::from_str(generated).context("generated suite parses")?;

    let existing_names = tool_names(&existing);
    let fresh_tools = fresh
        .get("tools")
        .and_then(serde_yaml::Value::as_sequence)
        .cloned()
        .unwrap_or_default();

    let mut appended = 0usize;
    let mut merged_tools = existing
        .get("tools")
        .and_then(serde_yaml::Value::as_sequence)
        .cloned()
        .unwrap_or_default();
    for test in fresh_tools {
        match test_name(&test) {
            Some(name) if !existing_names.contains(&name) => {
                merged_tools.push(test);
                appended += 1;
            }
            _ => {}
        }
    }

    set_tools(&mut existing, merged_tools)?;
    let rendered = serde_yaml::to_string(&existing).context("serialize merged suite")?;
    fs::write(path, &rendered).with_context(|| format!("failed to write {}", path.display()))?;
    if !args.quiet {
        println!(
            "Merged into {}: {appended} new test(s) appended, existing tests preserved",
            path.display()
        );
    }
    Ok(0)
}

fn tool_names(suite: &serde_yaml::Value) -> Vec<String> {
    suite
        .get("tools")
        .and_then(serde_yaml::Value::as_sequence)
        .map(|seq| seq.iter().filter_map(test_name).collect())
        .unwrap_or_default()
}

fn test_name(test: &serde_yaml::Value) -> Option<String> {
    test.get("name")
        .and_then(serde_yaml::Value::as_str)
        .map(str::to_string)
}

fn set_tools(suite: &mut serde_yaml::Value, tools: Vec<serde_yaml::Value>) -> Result<()> {
    let map = suite
        .as_mapping_mut()
        .ok_or_else(|| anyhow!("existing suite is not a YAML mapping"))?;
    map.insert(
        serde_yaml::Value::String("tools".to_string()),
        serde_yaml::Value::Sequence(tools),
    );
    Ok(())
}

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

    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
        let p = dir.join(name);
        fs::write(&p, body).unwrap();
        p
    }

    #[test]
    fn reads_tools_from_tools_list_json_snapshot() {
        let dir = tempdir().unwrap();
        let p = write(
            dir.path(),
            "catalog.json",
            r#"{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}"#,
        );
        let tools = read_tools(&p).unwrap();
        assert_eq!(tools.len(), 1);
        assert_eq!(tools[0]["name"], "echo");
    }

    #[test]
    fn reads_tools_from_bare_json_array() {
        let dir = tempdir().unwrap();
        let p = write(dir.path(), "arr.json", r#"[{"name":"ping"}]"#);
        let tools = read_tools(&p).unwrap();
        assert_eq!(tools[0]["name"], "ping");
    }

    #[test]
    fn reads_tools_from_yaml_mock_manifest() {
        let dir = tempdir().unwrap();
        let p = write(
            dir.path(),
            "mock.yaml",
            "mock_server:\n  tools:\n    - name: search\n",
        );
        let tools = read_tools(&p).unwrap();
        assert_eq!(tools[0]["name"], "search");
    }

    #[test]
    fn read_tools_errors_on_missing_tools() {
        let dir = tempdir().unwrap();
        let p = write(dir.path(), "empty.json", r#"{"servers":{}}"#);
        assert!(read_tools(&p).is_err());
    }

    fn args_for(from: PathBuf, update: Option<PathBuf>) -> GenerateSuiteArgs {
        GenerateSuiteArgs {
            from_config: from,
            server_name: "server".to_string(),
            models: None,
            include_edge: true,
            include_violation: true,
            include_output_schema_tests: true,
            probe: false,
            output: None,
            update,
            quiet: true,
        }
    }

    #[test]
    fn output_mode_writes_suite_to_file() {
        let dir = tempdir().unwrap();
        let src = write(
            dir.path(),
            "catalog.json",
            &json!({"tools":[{"name":"echo","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}},"required":["msg"]}}]}).to_string(),
        );
        let out = dir.path().join("suite.yml");
        let mut args = args_for(src, None);
        args.output = Some(out.clone());
        let code = run_suite(args).unwrap();
        assert_eq!(code, 0);
        let body = fs::read_to_string(&out).unwrap();
        assert!(body.contains("tool: echo"));
        assert!(body.contains("model_compatibility:"));
    }

    #[test]
    fn update_mode_preserves_hand_authored_test_and_appends_new() {
        let dir = tempdir().unwrap();
        // The catalog declares two tools.
        let src = write(
            dir.path(),
            "catalog.json",
            &json!({"tools":[
                {"name":"echo","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}},"required":["msg"]}},
                {"name":"ping","inputSchema":{"type":"object"}}
            ]}).to_string(),
        );
        // The existing suite already has a hand-authored echo test with a
        // matching name that must survive untouched.
        let existing = "\
servers:
  server:
    command: [\"./server\"]
tools:
  - name: \"echo: valid arguments\"
    server: server
    tool: echo
    args:
      msg: \"hand authored, do not clobber\"
    expect:
      - target: result
        matcher:
          contains: ok
";
        let suite = write(dir.path(), "suite.yml", existing);
        let args = args_for(src, Some(suite.clone()));
        let code = run_suite(args).unwrap();
        assert_eq!(code, 0);

        let merged = fs::read_to_string(&suite).unwrap();
        // The hand-authored body survived.
        assert!(merged.contains("hand authored, do not clobber"));
        // Only one echo: valid arguments test, not a duplicate.
        let occurrences = merged.matches("echo: valid arguments").count();
        assert_eq!(occurrences, 1, "should not duplicate existing test name");
        // New ping tests were appended.
        assert!(merged.contains("ping: valid arguments"));
    }

    #[test]
    fn update_mode_round_trips_to_valid_yaml() {
        let dir = tempdir().unwrap();
        let src = write(
            dir.path(),
            "catalog.json",
            &json!({"tools":[{"name":"ping","inputSchema":{"type":"object"}}]}).to_string(),
        );
        let suite = write(
            dir.path(),
            "suite.yml",
            "servers:\n  server:\n    command: [\"./server\"]\ntools: []\n",
        );
        run_suite(args_for(src, Some(suite.clone()))).unwrap();
        let merged = fs::read_to_string(&suite).unwrap();
        let parsed: serde_yaml::Value = serde_yaml::from_str(&merged).unwrap();
        assert!(parsed.get("tools").is_some());
    }
}
