//! `mcptest lint`: detect usage of MCP features the 2026-07-28 spec
//! deprecates.
//!
//! Offline scan over YAML suites and JSON cassettes. For each file we
//! match every line against the deprecation catalog and emit one finding
//! per hit. The deprecations covered are the same set the migration
//! doctor will probe live: the three deprecated capabilities
//! (Roots / Sampling / Logging), `tasks/list` (removed in the Tasks
//! extension lifecycle), and the literal `-32002` error code (now
//! standard `-32602` Invalid Params).

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

use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;

/// Output format for [`run_lint`].
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LintFormat {
    /// Human-readable table grouped by file.
    Pretty,
    /// One JSON object per finding plus a final `summary` line.
    Json,
}

/// Plain-data arguments for [`run_lint`].
#[derive(Debug, Clone)]
pub struct LintArgs {
    /// Paths to scan.
    pub paths: Vec<PathBuf>,
    /// Output format.
    pub format: LintFormat,
    /// True to exit 0 even when findings land.
    pub no_fail: bool,
}

/// One deprecated feature the catalog can detect.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FindingKind {
    /// Server-initiated `roots/list` request.
    Roots,
    /// Server-initiated `sampling/createMessage` request.
    Sampling,
    /// `logging/*` or `notifications/message` (log) usage.
    Logging,
    /// `tasks/list` method (removed in the Tasks extension lifecycle).
    TasksList,
    /// Literal `-32002` error code (now standard `-32602` Invalid Params).
    LegacyErrorCode,
}

impl FindingKind {
    /// Human-readable name shown in the pretty output.
    pub fn label(self) -> &'static str {
        match self {
            FindingKind::Roots => "roots",
            FindingKind::Sampling => "sampling",
            FindingKind::Logging => "logging",
            FindingKind::TasksList => "tasks-list",
            FindingKind::LegacyErrorCode => "legacy-error-code",
        }
    }

    /// Replacement guidance surfaced on the finding.
    pub fn replacement(self) -> &'static str {
        match self {
            FindingKind::Roots => "Use tool parameters, resource URIs, or server configuration",
            FindingKind::Sampling => "Use direct LLM provider API integration",
            FindingKind::Logging => {
                "Use stderr for stdio servers; OpenTelemetry for structured observability"
            }
            FindingKind::TasksList => {
                "Use `tasks/get` polling on a server-issued task handle (Tasks extension)"
            }
            FindingKind::LegacyErrorCode => {
                "Use the standard JSON-RPC `-32602` Invalid Params code"
            }
        }
    }

    /// Per-kind doc link surfaced on the finding.
    pub fn doc_link(self) -> &'static str {
        match self {
            FindingKind::Roots | FindingKind::Sampling | FindingKind::Logging => {
                "https://modelcontextprotocol.io/specification/draft#deprecations"
            }
            FindingKind::TasksList => "https://modelcontextprotocol.io/specification/draft#tasks",
            FindingKind::LegacyErrorCode => "https://www.jsonrpc.org/specification#error_object",
        }
    }
}

/// One deprecation hit found in a file.
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
    /// Path of the file containing the hit, relative to the cwd when possible.
    pub file: String,
    /// 1-based line number.
    pub line: usize,
    /// What deprecated feature was matched.
    pub kind: FindingKind,
    /// The literal substring that triggered the match.
    pub matched: String,
    /// Replacement guidance.
    pub replacement: String,
    /// Doc link for the deprecation.
    pub doc_link: String,
}

/// Run the lint over `args` and write the report to stdout. Returns the
/// desired process exit code (0 on no findings or when `no_fail` is set;
/// 1 when findings landed and `no_fail` is off).
pub fn run_lint(args: LintArgs) -> Result<i32> {
    let paths = if args.paths.is_empty() {
        vec![PathBuf::from(".")]
    } else {
        args.paths.clone()
    };

    let mut files: Vec<PathBuf> = Vec::new();
    for root in &paths {
        collect_files(root, &mut files)?;
    }
    files.sort();

    let mut findings: Vec<Finding> = Vec::new();
    for path in &files {
        scan_file(path, &mut findings)?;
    }

    match args.format {
        LintFormat::Pretty => print_pretty(&findings, files.len()),
        LintFormat::Json => print_json(&findings, files.len())?,
    }

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

/// Recursively collect every `*.yaml` / `*.yml` / `*.json` file under
/// `root`. If `root` is itself a file, its extension is checked the same
/// way so `mcptest lint tests.yaml` works without a directory walk.
fn collect_files(root: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    let meta = fs::metadata(root).with_context(|| format!("cannot stat {}", root.display()))?;
    if meta.is_file() {
        if has_scannable_extension(root) {
            out.push(root.to_path_buf());
        }
        return Ok(());
    }
    if !meta.is_dir() {
        return Ok(());
    }
    let entries =
        fs::read_dir(root).with_context(|| format!("cannot read directory {}", root.display()))?;
    for entry in entries.flatten() {
        let path = entry.path();
        let file_type = match entry.file_type() {
            Ok(t) => t,
            Err(_) => continue,
        };
        // Skip well-known build outputs that would generate huge no-op
        // walks; `target/` alone can hold tens of thousands of files on
        // a fresh build.
        if file_type.is_dir() {
            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                if matches!(name, "target" | "node_modules" | ".git") {
                    continue;
                }
            }
            collect_files(&path, out)?;
        } else if file_type.is_file() && has_scannable_extension(&path) {
            out.push(path);
        }
    }
    Ok(())
}

fn has_scannable_extension(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|e| e.to_str()),
        Some("yaml") | Some("yml") | Some("json")
    )
}

/// Scan one file. We read the whole file as text and walk line by line
/// so each finding carries an honest line number; JSON cassettes are not
/// reparsed because the patterns we care about are unambiguous as raw
/// substrings and a parse-then-walk would only slow the lint down.
fn scan_file(path: &Path, out: &mut Vec<Finding>) -> Result<()> {
    let body = match fs::read_to_string(path) {
        Ok(b) => b,
        // Binary or unreadable files are silently skipped: a YAML scanner
        // is the wrong place to surface "this is not UTF-8" errors.
        Err(_) => return Ok(()),
    };
    let label = display_path(path);
    let legacy_code = legacy_error_code_pattern();
    for (i, line) in body.lines().enumerate() {
        let line_no = i + 1;
        for (needle, kind) in DEPRECATED_METHODS {
            if line.contains(needle) {
                out.push(Finding {
                    file: label.clone(),
                    line: line_no,
                    kind: *kind,
                    matched: (*needle).to_string(),
                    replacement: kind.replacement().to_string(),
                    doc_link: kind.doc_link().to_string(),
                });
            }
        }
        if let Some(m) = legacy_code.find(line) {
            out.push(Finding {
                file: label.clone(),
                line: line_no,
                kind: FindingKind::LegacyErrorCode,
                matched: m.as_str().to_string(),
                replacement: FindingKind::LegacyErrorCode.replacement().to_string(),
                doc_link: FindingKind::LegacyErrorCode.doc_link().to_string(),
            });
        }
    }
    Ok(())
}

/// The deprecation catalog of method-name substrings. A line is matched
/// only by raw substring; method names are distinctive enough that the
/// false-positive rate is negligible.
const DEPRECATED_METHODS: &[(&str, FindingKind)] = &[
    ("roots/list", FindingKind::Roots),
    ("sampling/createMessage", FindingKind::Sampling),
    ("logging/setLevel", FindingKind::Logging),
    ("notifications/message", FindingKind::Logging),
    ("tasks/list", FindingKind::TasksList),
];

/// Match the literal `-32002` error code as a stand-alone token, so an
/// unrelated number like `-320021` does not light the rule up.
fn legacy_error_code_pattern() -> Regex {
    // Word boundary around `-32002`. `-` is not a word char so the leading
    // boundary is just the start of the string or a non-digit; trailing
    // requires a non-digit (or end of line).
    Regex::new(r"(?:^|[^-\d])(-32002)(?:[^\d]|$)").expect("static regex")
}

fn display_path(path: &Path) -> String {
    if let Ok(cwd) = std::env::current_dir() {
        if let Ok(rel) = path.strip_prefix(&cwd) {
            return rel.display().to_string();
        }
    }
    path.display().to_string()
}

fn print_pretty(findings: &[Finding], files_scanned: usize) {
    if findings.is_empty() {
        println!(
            "mcptest lint: scanned {files_scanned} file(s); no deprecated-feature usage found."
        );
        return;
    }
    println!(
        "mcptest lint: {} finding(s) across {} file(s):",
        findings.len(),
        files_scanned
    );
    let mut current_file = String::new();
    for f in findings {
        if f.file != current_file {
            println!();
            println!("{}", f.file);
            current_file = f.file.clone();
        }
        println!(
            "  {file}:{line}  [{kind}] `{matched}`",
            file = f.file,
            line = f.line,
            kind = f.kind.label(),
            matched = f.matched
        );
        println!("    replacement: {}", f.replacement);
        println!("    doc:         {}", f.doc_link);
    }
}

fn print_json(findings: &[Finding], files_scanned: usize) -> Result<()> {
    for f in findings {
        println!("{}", serde_json::to_string(f)?);
    }
    let summary = serde_json::json!({
        "summary": {
            "files_scanned": files_scanned,
            "findings": findings.len(),
        }
    });
    println!("{}", serde_json::to_string(&summary)?);
    Ok(())
}

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

    fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
        let p = dir.join(name);
        let mut f = std::fs::File::create(&p).expect("create");
        f.write_all(body.as_bytes()).expect("write");
        p
    }

    #[test]
    fn legacy_code_regex_matches_only_full_token() {
        let re = legacy_error_code_pattern();
        assert!(re.is_match("code: -32002"));
        assert!(re.is_match("\"code\": -32002,"));
        // A different code that happens to contain the digits should not match.
        assert!(!re.is_match("code: -320021"));
        assert!(!re.is_match("code: -132002"));
    }

    #[test]
    fn scans_yaml_for_deprecated_methods() {
        let dir = tempdir().unwrap();
        let path = write(
            dir.path(),
            "suite.yaml",
            "tools:\n  - tool: roots/list\n  - method: sampling/createMessage\n  - method: tasks/list\n",
        );
        let mut out = Vec::new();
        scan_file(&path, &mut out).unwrap();
        let kinds: Vec<_> = out.iter().map(|f| f.kind).collect();
        assert!(kinds.contains(&FindingKind::Roots));
        assert!(kinds.contains(&FindingKind::Sampling));
        assert!(kinds.contains(&FindingKind::TasksList));
    }

    #[test]
    fn scans_cassette_for_legacy_error_code() {
        let dir = tempdir().unwrap();
        let path = write(
            dir.path(),
            "cassette.json",
            r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"missing"}}"#,
        );
        let mut out = Vec::new();
        scan_file(&path, &mut out).unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].kind, FindingKind::LegacyErrorCode);
        assert!(out[0].replacement.contains("-32602"));
    }

    #[test]
    fn flags_notifications_message_as_logging() {
        let dir = tempdir().unwrap();
        let path = write(
            dir.path(),
            "trace.json",
            r#"{"method":"notifications/message","params":{"level":"info"}}"#,
        );
        let mut out = Vec::new();
        scan_file(&path, &mut out).unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].kind, FindingKind::Logging);
    }

    #[test]
    fn no_findings_on_clean_yaml() {
        let dir = tempdir().unwrap();
        let path = write(
            dir.path(),
            "clean.yaml",
            "servers:\n  weather: { command: [\"./w\"] }\ntools:\n  - tool: get_weather\n",
        );
        let mut out = Vec::new();
        scan_file(&path, &mut out).unwrap();
        assert!(out.is_empty(), "clean file should produce no findings");
    }

    #[test]
    fn collects_yaml_yml_json_recursively_and_skips_target() {
        let dir = tempdir().unwrap();
        write(dir.path(), "a.yaml", "x: 1\n");
        write(dir.path(), "b.yml", "y: 2\n");
        write(dir.path(), "c.json", "{}\n");
        write(dir.path(), "ignore.txt", "ignored");
        let nested = dir.path().join("target");
        std::fs::create_dir(&nested).unwrap();
        write(&nested, "huge.yaml", "should: be skipped");
        let mut out = Vec::new();
        collect_files(dir.path(), &mut out).unwrap();
        let names: Vec<String> = out
            .iter()
            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        assert!(names.iter().any(|n| n == "a.yaml"));
        assert!(names.iter().any(|n| n == "b.yml"));
        assert!(names.iter().any(|n| n == "c.json"));
        assert!(!names.iter().any(|n| n == "ignore.txt"));
        assert!(
            !names.iter().any(|n| n == "huge.yaml"),
            "target/ subdirectory should be skipped"
        );
    }
}
