//! `mcptest distill`: convert a recorded cassette into a draft YAML suite
//! (WOR-1263).
//!
//! Why this exists: recording captures real traffic, but a recording is
//! not a regression suite until someone turns each exchange into an
//! editable, assertion-level test. This command is that conversion: the
//! suite goes to stdout (or `--output`), and everything advisory (notes,
//! the review-flag summary, the skip list) goes to stderr so redirection
//! captures a clean YAML document.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use mcptest_core::distill::{distill, parse_cassette, DistillOptions, DistilledSuite};

/// Plain-data inputs for one `mcptest distill` invocation. The clap
/// struct lowers into this so tests can drive the command directly.
#[derive(Debug, Clone)]
pub struct DistillRunArgs {
    /// Cassette file to distill.
    pub cassette: PathBuf,
    /// Destination for the suite; `None` prints to stdout.
    pub output: Option<PathBuf>,
    /// Server key for the generated `servers:` block.
    pub server_name: String,
}

/// Run the distill flow: parse, distill, emit. Returns the process exit
/// code (0 on success; errors bubble as `anyhow` for the CLI to render).
pub fn run_distill(args: DistillRunArgs) -> Result<i32> {
    let raw = std::fs::read_to_string(&args.cassette)
        .with_context(|| format!("could not read cassette {}", args.cassette.display()))?;
    let parsed = parse_cassette(&raw)
        .with_context(|| format!("could not distill {}", args.cassette.display()))?;
    let cassette_path = suite_cassette_ref(&args.cassette, args.output.as_deref());
    let suite = distill(
        &parsed,
        &DistillOptions {
            cassette_path,
            server_name: args.server_name,
        },
    )
    .with_context(|| format!("could not distill {}", args.cassette.display()))?;
    print_summary(&suite);
    match &args.output {
        Some(path) => {
            std::fs::write(path, &suite.yaml)
                .with_context(|| format!("could not write suite to {}", path.display()))?;
            eprintln!(
                "wrote {} ({} test(s)); run it with: mcptest run --config {}",
                path.display(),
                suite.tests.len(),
                path.display()
            );
        }
        None => print!("{}", suite.yaml),
    }
    Ok(0)
}

/// Print the advisory summary to stderr: distilled-test count, the notes
/// the library raised, and the review-flag count by category.
fn print_summary(suite: &DistilledSuite) {
    eprintln!("distilled {} test(s)", suite.tests.len());
    for note in &suite.notes {
        eprintln!("note: {note}");
    }
    if suite.review_flags.is_empty() {
        eprintln!("review: no suspicious values detected in args or exact assertions");
        return;
    }
    let mut by_category: BTreeMap<&str, usize> = BTreeMap::new();
    for flag in &suite.review_flags {
        *by_category.entry(flag.category.label()).or_insert(0) += 1;
    }
    let breakdown: Vec<String> = by_category
        .iter()
        .map(|(label, count)| format!("{label}: {count}"))
        .collect();
    eprintln!(
        "review: {} value(s) look like personal or live data ({}); each affected test \
         carries a \"# review:\" comment",
        suite.review_flags.len(),
        breakdown.join(", ")
    );
    for flag in &suite.review_flags {
        eprintln!(
            "  review [{}] {} at {}",
            flag.category.label(),
            flag.test,
            flag.path
        );
    }
}

/// Decide how the generated suite should reference the cassette. For
/// stdout the user's own spelling is kept (they will save the suite
/// somewhere of their choosing). With `--output` the reference is made
/// relative to the suite file when both share a directory, since relative
/// `cassette:` paths resolve against the suite; otherwise the canonical
/// absolute path keeps the suite runnable from anywhere.
fn suite_cassette_ref(cassette: &Path, output: Option<&Path>) -> String {
    let Some(output) = output else {
        return cassette.display().to_string();
    };
    let canonical = match std::fs::canonicalize(cassette) {
        Ok(c) => c,
        // An unreadable cassette already failed earlier; keep the user's
        // spelling if canonicalization itself is what fails.
        Err(_) => return cassette.display().to_string(),
    };
    let out_dir = match output.parent() {
        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
        _ => PathBuf::from("."),
    };
    match (std::fs::canonicalize(&out_dir), canonical.file_name()) {
        (Ok(out_canon), Some(file)) if canonical.parent() == Some(out_canon.as_path()) => {
            format!("./{}", file.to_string_lossy())
        }
        _ => canonical.display().to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stdout_mode_keeps_the_cassette_path_as_spelled() {
        assert_eq!(
            suite_cassette_ref(Path::new("recordings/tape.json"), None),
            "recordings/tape.json"
        );
    }

    #[test]
    fn output_next_to_the_cassette_gets_a_suite_relative_reference() {
        let dir = tempfile::tempdir().unwrap();
        let cassette = dir.path().join("tape.json");
        std::fs::write(&cassette, "{}").unwrap();
        let output = dir.path().join("suite.yml");
        assert_eq!(suite_cassette_ref(&cassette, Some(&output)), "./tape.json");
    }

    #[test]
    fn output_in_a_different_directory_gets_an_absolute_reference() {
        let dir = tempfile::tempdir().unwrap();
        let cassette = dir.path().join("tape.json");
        std::fs::write(&cassette, "{}").unwrap();
        let elsewhere = tempfile::tempdir().unwrap();
        let output = elsewhere.path().join("suite.yml");
        let referenced = suite_cassette_ref(&cassette, Some(&output));
        assert!(
            Path::new(&referenced).is_absolute(),
            "expected absolute, got {referenced}"
        );
        assert!(referenced.ends_with("tape.json"));
    }
}
