//! Agent-loop eval renderer for `mcptest generate eval` and the
//! `scaffold_eval` agent verb.
//!
//! Why this exists: happy-path tool tests ([`super::suite`]) call tools
//! directly; they never check whether a model can pick the right tool and use
//! it from a natural-language prompt. This module turns a tool catalog into an
//! `agents:` block: one agent test per tool, with a synthesized prompt, a
//! tool-selection assertion, and judged eval metrics (task completion, tool
//! use, argument correctness, and a rubric), so an author starts from a
//! complete behavioral eval instead of a blank file.

use serde_json::{json, Value};

use super::emit;

/// Default model a generated eval points at. One model keeps the starter file
/// short; an author swaps it or adds a `models:` matrix.
pub const DEFAULT_EVAL_MODEL: &str = "claude-sonnet-4-5";

/// Options that steer [`render_eval_suite`].
#[derive(Debug, Clone)]
pub struct EvalOptions {
    /// Server key the generated agent tests reference, and the name under the
    /// emitted `servers:` block.
    pub server_name: String,
    /// Model identifier written to each agent test's `model:` field.
    pub model: String,
}

impl Default for EvalOptions {
    fn default() -> Self {
        Self {
            server_name: "server".to_string(),
            model: DEFAULT_EVAL_MODEL.to_string(),
        }
    }
}

/// Render an agent-loop eval suite from a `tools/list` tools array.
///
/// One `agents:` entry per named tool: a prompt synthesized from the tool's
/// name and description, a `tool_calls[0].name` assertion that the agent picks
/// the tool, and the judged `eval` metrics. Tools without a `name` are skipped
/// so a partially malformed catalog still renders a valid document.
///
/// ```
/// use mcptest_core::stub_gen::{render_eval_suite, EvalOptions};
/// use serde_json::json;
///
/// let tools = vec![json!({ "name": "get_weather", "description": "Look up weather." })];
/// let yaml = render_eval_suite(&tools, &EvalOptions::default());
/// assert!(yaml.contains("mcp_task_completion:"));
/// ```
pub fn render_eval_suite(tools: &[Value], opts: &EvalOptions) -> String {
    let mut out = emit::render_header("mcptest generate eval");
    out.push_str("# One agent-loop eval per tool: a prompt, a tool-selection check, and\n");
    out.push_str("# judged metrics. Tune the prompts and thresholds to your task.\n\n");
    out.push_str(&emit::render_servers_block(&opts.server_name));

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

    let body: String = named.iter().map(|t| render_agent_case(t, opts)).collect();
    if body.is_empty() {
        out.push_str("agents: []\n");
    } else {
        out.push_str("agents:\n");
        out.push_str(&body);
    }
    out
}

/// Render one agent eval case for a tool.
fn render_agent_case(tool: &Value, opts: &EvalOptions) -> String {
    let name = tool.get("name").and_then(Value::as_str).unwrap_or("");
    let prompt = synthesize_prompt(name, tool);
    let server = &opts.server_name;

    let mut s = String::new();
    s.push_str(&format!(
        "  - name: \"{name}: agent selects and uses the tool\"\n"
    ));
    s.push_str(&format!(
        "    model: {}\n",
        emit::yaml_scalar(&json!(opts.model))
    ));
    s.push_str(&format!("    servers: [{server}]\n"));
    s.push_str(&format!(
        "    prompt: {}\n",
        emit::yaml_scalar(&json!(prompt))
    ));
    s.push_str("    max_turns: 4\n");
    s.push_str("    expect:\n");
    s.push_str("      - target: tool_calls[0].name\n");
    s.push_str("        matcher:\n");
    s.push_str(&format!(
        "          exact: {}\n",
        emit::yaml_scalar(&json!(name))
    ));
    s.push_str(&format!(
        "        message: \"the agent should select the {name} tool\"\n"
    ));
    s.push_str("    eval:\n");
    s.push_str("      mcp_task_completion:\n");
    s.push_str("        threshold: 0.6\n");
    s.push_str("      mcp_use:\n");
    s.push_str("        threshold: 0.6\n");
    s.push_str("      argument_correctness: {}\n");
    s.push_str("      rubric:\n");
    s.push_str("        threshold: 0.7\n");
    s.push_str("        criteria:\n");
    s.push_str("          - name: accomplishes the task\n");
    s.push_str(&format!(
        "            description: {}\n",
        emit::yaml_scalar(&json!(format!(
            "The final reply accomplishes what the prompt asked, using the {name} tool."
        )))
    ));
    s.push('\n');
    s
}

/// Build a natural-language prompt that should drive the agent to the tool.
/// Uses the tool's declared description when present so the intent matches the
/// tool's purpose; the name alone otherwise.
fn synthesize_prompt(name: &str, tool: &Value) -> String {
    let description = tool
        .get("description")
        .and_then(Value::as_str)
        .map(one_line)
        .filter(|d| !d.is_empty());
    match description {
        Some(d) => {
            format!("Use the available tools to accomplish this task: {d} Then report the result.")
        }
        None => format!("Use the `{name}` tool to accomplish its purpose, then report the result."),
    }
}

/// Flatten a description to one line so it renders as a single YAML scalar.
fn one_line(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

#[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:#?}");
    }

    fn weather() -> Value {
        json!({ "name": "get_weather", "description": "Look up the current weather for a city." })
    }

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

    #[test]
    fn emits_one_eval_per_tool_with_metrics_and_a_selection_check() {
        let yaml = render_eval_suite(&[weather()], &EvalOptions::default());
        assert!(yaml.contains("get_weather: agent selects and uses the tool"));
        assert!(yaml.contains("target: tool_calls[0].name"));
        assert!(yaml.contains("mcp_task_completion:"));
        assert!(yaml.contains("argument_correctness: {}"));
        assert!(yaml.contains("rubric:"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn uses_the_description_in_the_prompt() {
        let yaml = render_eval_suite(&[weather()], &EvalOptions::default());
        assert!(yaml.contains("Look up the current weather for a city."));
        validate_against_v1(&yaml);
    }

    #[test]
    fn tool_without_name_is_skipped() {
        let tools = vec![json!({ "description": "no name" }), weather()];
        let yaml = render_eval_suite(&tools, &EvalOptions::default());
        assert!(yaml.contains("get_weather"));
        validate_against_v1(&yaml);
    }

    #[test]
    fn rendering_is_deterministic() {
        let first = render_eval_suite(&[weather()], &EvalOptions::default());
        let second = render_eval_suite(&[weather()], &EvalOptions::default());
        assert_eq!(first, second);
    }
}
