//! `mcptest schema-lint`: lint and tighten the input schemas in a saved
//! `tools/list` snapshot.
//!
//! An under-constrained `inputSchema` lets malformed input reach the server.
//! [`mcptest_core::lint::lint_schema_catalog`] flags those statically with
//! stable `SCH-NNN` ids; [`mcptest_core::lint::tighten`] is the autofix that
//! adds the mechanical constraints (`additionalProperties: false` and a
//! `required` listing every declared property) without inventing a type.
//!
//! This is the standalone CLI surface for that library, distinct from the
//! `tool_quality` suite check (which runs the same lint inside a run) and from
//! `mcptest lint` (which scans suites for deprecated MCP features).

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

use anyhow::{Context, Result};
use mcptest_core::lint::{lint_schema_catalog, tighten, SchemaFinding, Severity};
use serde_json::{json, Value};

/// Output format for the findings report.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SchemaLintFormat {
    /// Human-readable list grouped by tool.
    Pretty,
    /// One JSON object per finding plus a final `summary` line.
    Json,
}

/// Plain-data arguments for [`run_schema_lint`].
#[derive(Debug, Clone)]
pub struct SchemaLintArgs {
    /// Path to the `tools/list` snapshot JSON file.
    pub snapshot: PathBuf,
    /// Output format for the findings report.
    pub format: SchemaLintFormat,
    /// Emit the tightened catalog instead of a findings report.
    pub fix: bool,
    /// With `fix`, rewrite the snapshot in place instead of printing.
    pub write: bool,
    /// Exit 0 even when findings land.
    pub no_fail: bool,
}

/// Lint (or autofix) the input schemas in a `tools/list` snapshot.
///
/// Returns the process exit code: `0` when the catalog is clean (or `--no-fail`
/// / `--fix` is set), `1` when findings land. A read or parse failure surfaces
/// as an `Err` the CLI boundary renders.
pub fn run_schema_lint(args: SchemaLintArgs) -> Result<i32> {
    let catalog = load_snapshot(&args.snapshot)?;

    if args.fix {
        return apply_fix(&args, &catalog);
    }

    let report = lint_schema_catalog(&catalog);
    match args.format {
        SchemaLintFormat::Pretty => print_pretty(&report.findings, report.tools_checked),
        SchemaLintFormat::Json => print_json(&report.findings, report.tools_checked)?,
    }

    if report.findings.is_empty() || args.no_fail {
        Ok(0)
    } else {
        Ok(1)
    }
}

/// Read and parse the snapshot file as a JSON `tools/list` catalog.
fn load_snapshot(path: &Path) -> Result<Value> {
    let text = fs::read_to_string(path)
        .with_context(|| format!("reading tools/list snapshot {}", path.display()))?;
    serde_json::from_str(&text)
        .with_context(|| format!("parsing tools/list snapshot {} as JSON", path.display()))
}

/// Build the tightened catalog and either print it or rewrite the snapshot.
fn apply_fix(args: &SchemaLintArgs, catalog: &Value) -> Result<i32> {
    let tightened = tighten_catalog(catalog);
    let serialized = serde_json::to_string_pretty(&tightened)
        .context("serializing the tightened tools/list catalog")?;
    if args.write {
        fs::write(&args.snapshot, format!("{serialized}\n"))
            .with_context(|| format!("writing tightened catalog to {}", args.snapshot.display()))?;
        eprintln!("tightened {} in place", args.snapshot.display());
    } else {
        println!("{serialized}");
    }
    Ok(0)
}

/// Return a copy of the catalog with every tool's `inputSchema` tightened. Tools
/// without an `inputSchema` are left untouched.
fn tighten_catalog(catalog: &Value) -> Value {
    let mut out = catalog.clone();
    let Some(tools) = out.get_mut("tools").and_then(Value::as_array_mut) else {
        return out;
    };
    for tool in tools {
        if let Some(schema) = tool.get("inputSchema") {
            let fixed = tighten(schema);
            tool["inputSchema"] = fixed;
        }
    }
    out
}

/// Human-readable report grouped by tool.
fn print_pretty(findings: &[SchemaFinding], tools_checked: usize) {
    if findings.is_empty() {
        println!("schema lint: {tools_checked} tool(s) checked, no findings");
        return;
    }
    let mut last_tool = "";
    for finding in findings {
        if finding.tool_name != last_tool {
            println!("\n{}", finding.tool_name);
            last_tool = &finding.tool_name;
        }
        println!(
            "  [{}] {} {}",
            severity_label(finding.severity),
            finding.rule_id,
            finding.message
        );
    }
    let criticals = findings
        .iter()
        .filter(|f| f.severity == Severity::Critical)
        .count();
    println!(
        "\nschema lint: {} finding(s) ({} critical) across {tools_checked} tool(s)",
        findings.len(),
        criticals
    );
}

/// Newline-delimited JSON: one object per finding, then a `summary` line.
fn print_json(findings: &[SchemaFinding], tools_checked: usize) -> Result<()> {
    for finding in findings {
        let line = json!({
            "tool": finding.tool_name,
            "rule_id": finding.rule_id,
            "severity": severity_label(finding.severity),
            "message": finding.message,
        });
        println!("{}", serde_json::to_string(&line)?);
    }
    let summary = json!({
        "summary": {
            "tools_checked": tools_checked,
            "findings": findings.len(),
            "critical": findings
                .iter()
                .filter(|f| f.severity == Severity::Critical)
                .count(),
        }
    });
    println!("{}", serde_json::to_string(&summary)?);
    Ok(())
}

/// Lowercase severity label shared by both output formats. The schema linter
/// only emits `Warning` and `Critical`; `Pass` is the description linter's
/// clean-tool marker and never reaches here, but the match stays exhaustive.
fn severity_label(severity: Severity) -> &'static str {
    match severity {
        Severity::Pass => "pass",
        Severity::Warning => "warning",
        Severity::Critical => "critical",
    }
}

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

    fn loose_catalog() -> Value {
        // `query` carries a type and a maxLength, so the only findings are the
        // mechanical SCH-001 / SCH-002 the autofix clears (SCH-003 / SCH-004
        // would survive tighten, which never invents a type or a bound).
        json!({
            "tools": [{
                "name": "search",
                "description": "Search.",
                "inputSchema": {
                    "type": "object",
                    "properties": { "query": { "type": "string", "maxLength": 200 } }
                }
            }]
        })
    }

    #[test]
    fn tighten_catalog_adds_constraints_to_every_tool() {
        let tightened = tighten_catalog(&loose_catalog());
        let schema = &tightened["tools"][0]["inputSchema"];
        assert_eq!(schema["additionalProperties"], json!(false));
        assert_eq!(schema["required"], json!(["query"]));
    }

    #[test]
    fn a_tightened_catalog_lints_clean() {
        let tightened = tighten_catalog(&loose_catalog());
        let report = lint_schema_catalog(&tightened);
        assert!(
            report.findings.is_empty(),
            "expected no findings after tighten, got {:?}",
            report.findings
        );
    }

    #[test]
    fn a_catalog_without_tools_tightens_to_itself() {
        let catalog = json!({ "not_tools": [] });
        assert_eq!(tighten_catalog(&catalog), catalog);
    }
}
