//! Handler for `mcptest tool-bench` (mutation-backed tool-description benchmark).
//!
//! Loads a benchmark spec (catalog + intents + mutations), runs the
//! deterministic benchmark in `mcptest_core::tool_bench`, and reports the
//! baseline selection accuracy (with a confidence interval), smell counts, and
//! the selection impact of each mutation. Optionally gates on a minimum
//! selection drop so a CI job can prove descriptions still drive selection.

use std::path::Path;

use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::Value;

use mcptest_core::tool_bench::{self, Intent, Mutation, ToolBenchReport};

use crate::cli::args::{ToolBenchArgs, ToolBenchCommand, ToolBenchRunArgs};

/// The kind of degradation a mutation applies. Flat so the spec reads
/// `kind: truncate` with sibling parameters, rather than a nested enum map.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum MutationKind {
    Blank,
    Genericize,
    Truncate,
    DropKeyword,
}

/// One mutation to apply, naming the tool, the kind, and any parameters.
#[derive(Debug, Deserialize)]
struct MutationEntry {
    tool: String,
    kind: MutationKind,
    /// Characters to keep for `truncate`.
    #[serde(default)]
    chars: Option<usize>,
    /// Word to remove for `drop_keyword`.
    #[serde(default)]
    word: Option<String>,
}

impl MutationEntry {
    /// Convert the flat spec entry into a core [`Mutation`].
    fn into_parts(self) -> Result<(String, Mutation)> {
        let mutation = match self.kind {
            MutationKind::Blank => Mutation::Blank,
            MutationKind::Genericize => Mutation::Genericize,
            MutationKind::Truncate => Mutation::Truncate {
                chars: self
                    .chars
                    .context("mutation kind `truncate` requires `chars:`")?,
            },
            MutationKind::DropKeyword => Mutation::DropKeyword {
                word: self
                    .word
                    .context("mutation kind `drop_keyword` requires `word:`")?,
            },
        };
        Ok((self.tool, mutation))
    }
}

/// The benchmark spec file shape.
#[derive(Debug, Deserialize)]
struct BenchSpec {
    /// Inline `tools/list` catalog (an object with a `tools` array, or a raw
    /// array of tools). Mutually exclusive with `catalog_path`.
    #[serde(default)]
    catalog: Option<Value>,
    /// Path to a `tools/list` snapshot JSON, relative to the spec file.
    #[serde(default)]
    catalog_path: Option<String>,
    /// The intents the selector is scored against.
    intents: Vec<Intent>,
    /// The mutations to apply, one at a time.
    #[serde(default)]
    mutations: Vec<MutationEntry>,
}

/// Dispatch the `mcptest tool-bench` subcommand.
pub(crate) fn tool_bench_command(args: ToolBenchArgs) -> Result<i32> {
    match args.command {
        ToolBenchCommand::Run(a) => run(a),
    }
}

/// Run the benchmark from a spec file.
fn run(args: ToolBenchRunArgs) -> Result<i32> {
    let text = std::fs::read_to_string(&args.spec)
        .with_context(|| format!("could not read {}", args.spec.display()))?;
    let spec: BenchSpec = serde_yaml::from_str(&text)
        .with_context(|| format!("could not parse benchmark spec {}", args.spec.display()))?;
    let base_dir = args.spec.parent().unwrap_or_else(|| Path::new("."));
    let catalog = resolve_catalog(&spec, base_dir)?;
    let mutations: Vec<(String, Mutation)> = spec
        .mutations
        .into_iter()
        .map(MutationEntry::into_parts)
        .collect::<Result<_>>()?;

    let report = tool_bench::run(&catalog, &spec.intents, &mutations);
    if args.json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        print_human(&report);
    }
    if let Some(min_drop) = args.min_drop {
        let observed = report.max_selection_drop();
        if observed + f64::EPSILON < min_drop {
            eprintln!(
                "tool-bench: largest selection drop {observed:.3} is below --min-drop {min_drop:.3}; \
                 description changes did not measurably affect selection"
            );
            return Ok(1);
        }
    }
    Ok(0)
}

/// Resolve the catalog from the inline value or a snapshot path, normalizing a
/// bare tool array into `{ "tools": [...] }`.
fn resolve_catalog(spec: &BenchSpec, base_dir: &Path) -> Result<Value> {
    let raw = match (&spec.catalog, &spec.catalog_path) {
        (Some(_), Some(_)) => {
            anyhow::bail!("spec sets both `catalog` and `catalog_path`; pick one")
        }
        (Some(inline), None) => inline.clone(),
        (None, Some(path)) => {
            let full = base_dir.join(path);
            let text = std::fs::read_to_string(&full)
                .with_context(|| format!("could not read catalog {}", full.display()))?;
            serde_json::from_str(&text)
                .with_context(|| format!("could not parse catalog {}", full.display()))?
        }
        (None, None) => anyhow::bail!("spec must set either `catalog` or `catalog_path`"),
    };
    Ok(match raw {
        Value::Array(tools) => serde_json::json!({ "tools": tools }),
        other => other,
    })
}

/// Render the benchmark report as a human-readable block.
fn print_human(report: &ToolBenchReport) {
    let b = &report.baseline;
    println!("baseline");
    println!(
        "  selection accuracy: {:.0}% (95% CI {:.0}%-{:.0}%, n={})",
        b.selection.estimate * 100.0,
        b.selection.low * 100.0,
        b.selection.high * 100.0,
        b.selection.total,
    );
    match (b.tdqs_mean, b.tdqs_min) {
        (Some(mean), Some(min)) => {
            println!("  tdqs: mean {mean:.2}, min {min:.2}");
        }
        _ => println!("  tdqs: (empty catalog)"),
    }
    println!("  catalog tokens: {}", b.catalog_tokens);
    if b.smell_total == 0 {
        println!("  smells: none");
    } else {
        let parts: Vec<String> = b
            .smells
            .iter()
            .map(|(smell, count)| format!("{}={}", smell.label(), count))
            .collect();
        println!("  smells ({}): {}", b.smell_total, parts.join(", "));
    }
    if report.mutations.is_empty() {
        println!("mutations: none declared");
        return;
    }
    println!("mutations (a positive drop means selection got worse):");
    for m in &report.mutations {
        println!(
            "  {} on `{}`: accuracy {:.0}% (drop {:+.0}pts), smells {:+}, tokens {:+}",
            m.label,
            m.tool,
            m.selection.estimate * 100.0,
            m.selection_drop * 100.0,
            m.smell_delta,
            m.token_delta,
        );
    }
    println!(
        "largest selection drop: {:.0}%",
        report.max_selection_drop() * 100.0
    );
}
