//! `mcptest validate <file>` performs full config validation.
//!
//! The findings come from [`mcptest_config::findings::collect_findings`],
//! the single function the `validate_suite` MCP verb reports from too, so
//! the CLI and the agent surface emit identical (path, message, hint)
//! triples. This command only chooses the rendering: human lines by
//! default, or one machine-readable JSON document with `--format json`
//! (the format the verb requests on the `mcptest validate` process it
//! spawns).

use std::path::Path;

use anyhow::{Context, Result};
use mcptest_config::context::InterpolationContext;
use mcptest_config::findings::{collect_findings, Finding};
use serde_json::{json, Value};

/// Rendering selected by `mcptest validate --format`.
///
/// Two renderings of the same findings: annotated lines for a person at a
/// terminal, and one JSON document for the `validate_suite` MCP verb (and
/// any other tool) to parse. Kept separate from the clap-level enum so this
/// module stays free of CLI-parsing types.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ValidateFormat {
    /// Annotated finding lines (default).
    #[default]
    Human,
    /// One pretty-printed `{ valid, errors }` JSON document.
    Json,
}

/// Run `mcptest validate <file>`.
///
/// Returns the desired process exit code: `0` when the file passes every
/// check, `2` when any validation issue is found.
pub fn run_validate(path: &Path, format: ValidateFormat) -> Result<i32> {
    // A directory flows into `collect_findings` so the loader's actionable
    // refusal (WOR-1354) is reported as a finding with exit 2, instead of
    // this readability guard dying on the raw OS "Is a directory" failure.
    if !path.is_dir() {
        let _ = std::fs::read_to_string(path)
            .with_context(|| format!("failed to read {}", path.display()))?;
    }

    // Seed interpolation from the live OS env so a ${VAR} the user already
    // exported does not trip the check, plus the ${CONFIG_DIR} builtin so
    // validate agrees with the run path on suite-relative references.
    let ctx = InterpolationContext::from_os_env().with_config_dir(path);
    let findings = collect_findings(path, &ctx);
    let code = if findings.is_empty() { 0 } else { 2 };

    if format == ValidateFormat::Json {
        print_json(&findings)?;
        return Ok(code);
    }

    if findings.is_empty() {
        println!("{}: ok", path.display());
    } else {
        println!("{}: {} issue(s)", path.display(), findings.len());
        for finding in &findings {
            println!("{}", finding.display_line());
        }
        // WOR-1268: human output ends with the troubleshooting link; the
        // JSON document stays untouched because its per-finding hints
        // already carry their own links where one exists.
        println!(
            "see: {}",
            mcptest_core::error_anchors::CONFIG_VALIDATION_FAILED
        );
    }
    Ok(code)
}

/// Emit `{ "valid": bool, "errors": [{ path, message, hint }] }`, the exact
/// document the `validate_suite` verb passes through to agents.
fn print_json(findings: &[Finding]) -> Result<()> {
    let errors: Vec<Value> = findings.iter().map(Finding::to_json).collect();
    let doc = json!({ "valid": findings.is_empty(), "errors": errors });
    println!("{}", serde_json::to_string_pretty(&doc)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn write(body: &str) -> NamedTempFile {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(body.as_bytes()).unwrap();
        f
    }

    #[test]
    fn valid_yaml_returns_zero() {
        let f = write("servers:\n  main:\n    command: [\"echo\", \"hi\"]\ntools: []\n");
        let code = run_validate(f.path(), ValidateFormat::Human).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn typoed_top_level_key_returns_two() {
        let f = write("serverz:\n  main:\n    command: [\"echo\", \"hi\"]\n");
        let code = run_validate(f.path(), ValidateFormat::Human).unwrap();
        assert_eq!(code, 2);
    }

    #[test]
    fn json_format_keeps_the_same_exit_codes() {
        let ok = write("servers:\n  main:\n    command: [\"echo\", \"hi\"]\ntools: []\n");
        assert_eq!(run_validate(ok.path(), ValidateFormat::Json).unwrap(), 0);
        let bad = write("serverz:\n  main:\n    command: [\"echo\", \"hi\"]\n");
        assert_eq!(run_validate(bad.path(), ValidateFormat::Json).unwrap(), 2);
    }

    #[test]
    fn broken_var_reference_is_reported() {
        let f = write("servers:\n  main:\n    command: [\"echo\", \"${UNDEF_VAR:?}\"]\n");
        // The `:?` modifier forces an error when the variable is unset; we
        // wipe it from the env to make the failure deterministic.
        std::env::remove_var("UNDEF_VAR");
        let code = run_validate(f.path(), ValidateFormat::Human).unwrap();
        assert_eq!(code, 2);
    }
}
