//! `mcptest migrate`: rewrite YAML suites toward a target MCP revision.
//!
//! v1 walks a set of paths, scans every `*.yaml` / `*.yml` file with the
//! same deprecation catalog the `mcptest lint` command uses,
//! and produces two kinds of rewrite:
//!
//! 1. **Annotation.** Any line containing a deprecated feature gets a
//!    `# TODO(mcptest-migrate)` comment inserted immediately above it,
//!    pointing at the replacement guidance from the migration corpus.
//!    The original line is left intact so the file still parses and so
//!    the operator can apply the human-judgement rewrite.
//! 2. **Mechanical rewrite.** The legacy `-32002` JSON-RPC error code
//!    is the only documented deprecation with a safe one-to-one
//!    replacement (`-32602` Invalid Params). The rewrite annotates the
//!    line with a TODO and substitutes the literal token.
//!
//! Dry-run is the default and prints a unified diff per file. `--write`
//! applies in place. Cassettes are out of scope for v1 (cassette rewrites
//! land alongside the streamable-HTTP transport rerouting in the
//!  v2 follow-up).

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

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

/// Plain-data arguments for [`run_migrate`].
#[derive(Debug, Clone)]
pub struct MigrateArgs {
    /// Target spec revision. v1 supports `2026-07-28` only.
    pub to: String,
    /// Paths to walk.
    pub paths: Vec<PathBuf>,
    /// Apply rewrites in place. Default is dry-run.
    pub write: bool,
}

/// One classification of finding the migrator can act on. Mirrors
/// [`crate::commands::lint::FindingKind`] but is duplicated here so
/// changes to the migrator's rule set do not silently drift the
/// linter's offline scan.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum MigrateKind {
    /// Deprecated server-initiated `roots/list` request.
    Roots,
    /// Deprecated server-initiated `sampling/createMessage` request.
    Sampling,
    /// Deprecated logging surface (`logging/*` or `notifications/message`).
    Logging,
    /// Removed `tasks/list` method (Tasks extension lifecycle replaces it).
    TasksList,
    /// Legacy `-32002` error code. The only rule with a mechanical
    /// rewrite to `-32602`.
    LegacyErrorCode,
}

impl MigrateKind {
    fn replacement(self) -> &'static str {
        match self {
            Self::Roots => "Use tool parameters, resource URIs, or server configuration",
            Self::Sampling => "Use direct LLM provider API integration",
            Self::Logging => {
                "Use stderr for stdio servers; OpenTelemetry for structured observability"
            }
            Self::TasksList => "Use `tasks/get` polling on a server-issued task handle",
            Self::LegacyErrorCode => "Auto-rewritten to `-32602` Invalid Params",
        }
    }
}

/// One per-line rewrite the migrator proposes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Rewrite {
    /// 1-based line number where the original hit was.
    pub line: usize,
    /// Classification.
    pub kind: MigrateKind,
    /// The literal substring that triggered the match.
    pub matched: String,
    /// Was the line itself rewritten (true for `-32002`, false otherwise).
    pub rewrote_line: bool,
}

/// Migration outcome for one file. The migrator never silently drops
/// information; an empty `rewrites` vector means no findings landed.
#[derive(Debug, Clone, Serialize)]
pub struct FileOutcome {
    /// Path of the file relative to the cwd when possible.
    pub file: String,
    /// Per-line rewrites.
    pub rewrites: Vec<Rewrite>,
    /// New file body if any rewrites landed; `None` otherwise.
    #[serde(skip)]
    pub new_body: Option<String>,
}

/// Run the migrator with `args` and write the report to stdout.
/// Returns the desired process exit code (0 when no rewrites land or
/// `--write` succeeds; 1 if writes are requested but a target spec
/// version is unsupported).
pub fn run_migrate(args: MigrateArgs) -> Result<i32> {
    if args.to != "2026-07-28" {
        eprintln!(
            "mcptest migrate v1 supports `--to 2026-07-28` only; got `{}`",
            args.to
        );
        return Ok(2);
    }

    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 outcomes: Vec<FileOutcome> = Vec::new();
    for path in &files {
        outcomes.push(plan_file(path)?);
    }

    // v2 cassette pass: walk *.json files too. The planner skips
    // any JSON that does not look like a cassette so plain config
    // JSON next to a suite stays untouched.
    let mut cassette_files: Vec<PathBuf> = Vec::new();
    for root in &paths {
        collect_cassette_files(root, &mut cassette_files)?;
    }
    cassette_files.sort();
    let mut cassette_outcomes: Vec<super::migrate_cassette::CassetteOutcome> = Vec::new();
    for path in &cassette_files {
        if let Some(outcome) = super::migrate_cassette::plan_cassette(path)? {
            cassette_outcomes.push(outcome);
        }
    }

    let mut annotated_files = 0usize;
    let mut total_rewrites = 0usize;
    for outcome in &outcomes {
        if outcome.rewrites.is_empty() {
            continue;
        }
        annotated_files += 1;
        total_rewrites += outcome.rewrites.len();
        print_outcome(outcome, args.write);
        if args.write {
            if let Some(body) = &outcome.new_body {
                fs::write(&outcome.file, body)
                    .with_context(|| format!("writing migrated file {}", outcome.file))?;
            }
        }
    }
    for outcome in &cassette_outcomes {
        if outcome.edits.is_empty() {
            continue;
        }
        annotated_files += 1;
        total_rewrites += outcome.edits.len();
        println!("==> {} (cassette)", outcome.file);
        for edit in &outcome.edits {
            let action = if edit.rewrote { "REWRITE" } else { "ANNOTATE" };
            println!(
                "  exchange {:>3}  {:9} {}",
                edit.exchange, action, edit.detail
            );
        }
        if args.write {
            if let Some(body) = &outcome.new_body {
                fs::write(&outcome.file, body)
                    .with_context(|| format!("writing migrated cassette {}", outcome.file))?;
            }
        }
    }
    println!(
        "\nmcptest migrate: {} yaml + {} cassette file(s) scanned, {} annotated, {} rewrite(s) {}.",
        files.len(),
        cassette_files.len(),
        annotated_files,
        total_rewrites,
        if args.write {
            "applied"
        } else {
            "proposed (dry-run; pass --write to apply)"
        }
    );
    Ok(0)
}

/// Walk `root` and collect every `*.json` candidate. The cassette
/// planner runs a content check before treating any file as a
/// cassette, so this collector intentionally does not filter beyond
/// the extension.
fn collect_cassette_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 path_has_json_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,
        };
        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_cassette_files(&path, out)?;
        } else if file_type.is_file() && path_has_json_extension(&path) {
            out.push(path);
        }
    }
    Ok(())
}

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

/// Walk a single file or directory and append matching YAML paths to
/// `out`. Mirrors the lint walker so a `mcptest lint` and `mcptest
/// migrate` over the same root see the same files.
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_migrable_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,
        };
        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_migrable_extension(&path) {
            out.push(path);
        }
    }
    Ok(())
}

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

/// Plan rewrites for one file without touching disk.
fn plan_file(path: &Path) -> Result<FileOutcome> {
    let body = match fs::read_to_string(path) {
        Ok(b) => b,
        // Skip binary or non-UTF-8 files quietly; the migrator is the
        // wrong place to surface a `not UTF-8` error.
        Err(_) => {
            return Ok(FileOutcome {
                file: display_path(path),
                rewrites: Vec::new(),
                new_body: None,
            });
        }
    };
    let (new_body, rewrites) = annotate(&body);
    let final_body = if rewrites.is_empty() {
        None
    } else {
        Some(new_body)
    };
    Ok(FileOutcome {
        file: display_path(path),
        rewrites,
        new_body: final_body,
    })
}

/// Pure transformation: take a file body, return the annotated body
/// and the list of rewrites. Pulled out as a free function so the
/// rewrite logic is testable without filesystem I/O.
pub fn annotate(body: &str) -> (String, Vec<Rewrite>) {
    let legacy = legacy_error_code_pattern();
    let mut out = String::with_capacity(body.len());
    let mut rewrites: Vec<Rewrite> = Vec::new();
    for (idx, raw_line) in body.split_inclusive('\n').enumerate() {
        let line_no = idx + 1;
        let line = strip_trailing_newline(raw_line);
        let indent: String = line
            .chars()
            .take_while(|c| *c == ' ' || *c == '\t')
            .collect();
        // Find every deprecated-method hit on this line.
        let mut hits: Vec<(MigrateKind, String)> = Vec::new();
        for (needle, kind) in DEPRECATED_METHODS {
            if line.contains(needle) {
                hits.push((*kind, (*needle).to_string()));
            }
        }
        // Legacy error code is matched by regex so a stray `-320021`
        // does not light it up. The match itself includes the
        // boundary chars (group 1 / group 2); the reported `matched`
        // field strips them so the operator sees the clean `-32002`
        // token rather than the regex sandwich.
        let legacy_hit: Option<String> = if legacy.is_match(line) {
            Some("-32002".to_string())
        } else {
            None
        };

        // Emit a TODO comment for every non-mechanical hit (annotation
        // only; we never silently rewrite a non-mechanical line).
        for (kind, matched) in &hits {
            out.push_str(&indent);
            out.push_str(&format!(
                "# TODO(mcptest-migrate): `{}` is deprecated in 2026-07-28. {}\n",
                matched,
                kind.replacement(),
            ));
            rewrites.push(Rewrite {
                line: line_no,
                kind: *kind,
                matched: matched.clone(),
                rewrote_line: false,
            });
        }
        // The legacy code rewrite IS mechanical: annotate and replace.
        if let Some(matched) = &legacy_hit {
            out.push_str(&indent);
            out.push_str(&format!(
                "# TODO(mcptest-migrate): `{}` rewritten to `-32602`. {}\n",
                matched,
                MigrateKind::LegacyErrorCode.replacement(),
            ));
            // `${1}` / `${2}` restore the captured boundary characters so
            // a line like `code: -32002,` rewrites to `code: -32602,`
            // without eating the leading space or the trailing comma.
            let replaced = legacy.replace_all(line, "${1}-32602${2}");
            out.push_str(&replaced);
            // Preserve the trailing newline the original carried so
            // line boundaries do not shift.
            if raw_line.ends_with('\n') {
                out.push('\n');
            }
            rewrites.push(Rewrite {
                line: line_no,
                kind: MigrateKind::LegacyErrorCode,
                matched: matched.clone(),
                rewrote_line: true,
            });
            continue;
        }
        // For every other hit, the original line stays as it was.
        out.push_str(raw_line);
    }
    (out, rewrites)
}

fn strip_trailing_newline(s: &str) -> &str {
    if let Some(stripped) = s.strip_suffix("\r\n") {
        return stripped;
    }
    s.strip_suffix('\n').unwrap_or(s)
}

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

const DEPRECATED_METHODS: &[(&str, MigrateKind)] = &[
    ("roots/list", MigrateKind::Roots),
    ("sampling/createMessage", MigrateKind::Sampling),
    ("logging/setLevel", MigrateKind::Logging),
    ("notifications/message", MigrateKind::Logging),
    ("tasks/list", MigrateKind::TasksList),
];

fn legacy_error_code_pattern() -> Regex {
    // Word boundary around `-32002` so a longer number like `-320021`
    // does not light up. The leading boundary is start-of-string or a
    // non-digit, captured as group 1; the trailing boundary is a
    // non-digit or end-of-line, captured as group 2. The replacement
    // restores both groups so the leading whitespace and the trailing
    // character (newline, comma, etc.) survive the rewrite.
    Regex::new(r"(^|[^\d-])-32002([^\d]|$)").unwrap_or_else(|e| {
        panic!("legacy error code regex must compile: {e}");
    })
}

fn print_outcome(outcome: &FileOutcome, write: bool) {
    println!("==> {}", outcome.file);
    for r in &outcome.rewrites {
        let action = if r.rewrote_line {
            "REWRITE"
        } else {
            "ANNOTATE"
        };
        println!(
            "  line {:>4}  {:9} {:?}  `{}`",
            r.line, action, r.kind, r.matched
        );
    }
    if !write {
        // Show a compact preview of the new body (the first few changed
        // lines) without dumping the whole file.
        if let Some(body) = &outcome.new_body {
            println!(
                "  (run with --write to apply; new body is {} bytes)",
                body.len()
            );
        }
    }
}

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

    #[test]
    fn annotate_inserts_todo_above_a_tasks_list_hit() {
        let body = "tools:\n  - name: t\n    tool: tasks/list\n";
        let (new_body, rewrites) = annotate(body);
        assert_eq!(rewrites.len(), 1);
        assert_eq!(rewrites[0].kind, MigrateKind::TasksList);
        assert_eq!(rewrites[0].line, 3);
        assert!(!rewrites[0].rewrote_line);
        // The TODO comment must precede the original line and share its
        // indentation so the YAML still parses as a mapping element.
        let lines: Vec<&str> = new_body.lines().collect();
        assert_eq!(lines.len(), 4);
        assert!(
            lines[2].starts_with("    # TODO(mcptest-migrate): `tasks/list`"),
            "expected TODO above the offending line, got `{}`",
            lines[2]
        );
        assert!(lines[3].trim_start().starts_with("tool: tasks/list"));
    }

    #[test]
    fn annotate_emits_one_todo_per_hit_on_a_dense_line() {
        // A pathological line carrying both a tasks/list and a logging
        // hit. Both must get their own TODO comment.
        let body = "    - target: { method: tasks/list, fallback: logging/setLevel }\n";
        let (new_body, rewrites) = annotate(body);
        assert_eq!(rewrites.len(), 2);
        let kinds: Vec<MigrateKind> = rewrites.iter().map(|r| r.kind).collect();
        assert!(kinds.contains(&MigrateKind::TasksList));
        assert!(kinds.contains(&MigrateKind::Logging));
        let count = new_body.matches("# TODO(mcptest-migrate)").count();
        assert_eq!(count, 2, "expected 2 TODOs, got body:\n{new_body}");
    }

    #[test]
    fn annotate_rewrites_legacy_error_code_in_place() {
        let body = "    expect:\n      - code: -32002\n";
        let (new_body, rewrites) = annotate(body);
        assert_eq!(rewrites.len(), 1);
        assert_eq!(rewrites[0].kind, MigrateKind::LegacyErrorCode);
        assert!(rewrites[0].rewrote_line);
        // The reported `matched` field carries just the legacy token,
        // not the regex sandwich.
        assert_eq!(rewrites[0].matched, "-32002");
        // The rewritten YAML line preserves leading whitespace and the
        // ordering so the YAML still parses.
        assert!(new_body.contains("rewritten to `-32602`"));
        assert!(
            new_body.contains("      - code: -32602\n"),
            "got new_body:\n{new_body}"
        );
        // The original literal is gone from the YAML data line. The
        // TODO comment still references `-32002` by name as documentation
        // for the operator, which is fine because YAML treats `#` as a
        // comment.
        assert!(
            !new_body.contains("- code: -32002"),
            "raw `-32002` data line should be replaced; got:\n{new_body}"
        );
    }

    #[test]
    fn annotate_leaves_a_line_without_findings_untouched() {
        let body = "tools:\n  - name: t\n    tool: tools/list\n";
        let (new_body, rewrites) = annotate(body);
        assert!(rewrites.is_empty());
        assert_eq!(new_body, body);
    }

    #[test]
    fn legacy_error_code_does_not_match_longer_numbers() {
        let body = "    expect:\n      - code: -320021\n";
        let (_new_body, rewrites) = annotate(body);
        assert!(
            rewrites.is_empty(),
            "should not match `-320021`, got rewrites: {:?}",
            rewrites
        );
    }

    #[test]
    fn run_migrate_rejects_unsupported_target_version() {
        let args = MigrateArgs {
            to: "2099-12-31".to_string(),
            paths: vec![],
            write: false,
        };
        let code = run_migrate(args).unwrap();
        assert_eq!(code, 2);
    }

    #[test]
    fn run_migrate_dry_run_does_not_touch_disk() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join("suite.yaml");
        let body = "tools:\n  - name: t\n    tool: tasks/list\n";
        std::fs::write(&path, body).expect("write fixture");
        let args = MigrateArgs {
            to: "2026-07-28".to_string(),
            paths: vec![path.clone()],
            write: false,
        };
        let code = run_migrate(args).unwrap();
        assert_eq!(code, 0);
        let after = std::fs::read_to_string(&path).expect("read");
        assert_eq!(after, body, "dry-run must not modify the file");
    }

    #[test]
    fn run_migrate_write_applies_rewrites_in_place() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join("suite.yaml");
        let body = "expect:\n  - code: -32002\n";
        std::fs::write(&path, body).expect("write fixture");
        let args = MigrateArgs {
            to: "2026-07-28".to_string(),
            paths: vec![path.clone()],
            write: true,
        };
        let code = run_migrate(args).unwrap();
        assert_eq!(code, 0);
        let after = std::fs::read_to_string(&path).expect("read");
        assert!(after.contains("- code: -32602"), "got:\n{after}");
        assert!(after.contains("# TODO(mcptest-migrate)"), "got:\n{after}");
        // The raw YAML data line should be rewritten; the TODO comment
        // is allowed to reference `-32002` by name as documentation.
        assert!(
            !after.contains("- code: -32002"),
            "raw data line should be rewritten; got:\n{after}"
        );
    }
}
