//! Cassette JSON migration for `mcptest migrate` (WOR-938).
//!
//! v1 of the migrator (`migrate.rs`) walks `*.yaml` / `*.yml` and
//! either annotates or mechanically rewrites deprecated content.
//! v2 extends the walk to `*.json` cassettes so a server moving to
//! the 2026-07-28 spec can ship both its suite and its recorded
//! traffic through one command.
//!
//! Today's scope:
//!
//! - Detect cassette files via the JSON content shape
//!   (`{"version": "...", "exchanges": [...]}`); plain JSON config
//!   files that happen to live next to a suite are skipped.
//! - Rewrite the legacy error code `-32002` -> `-32602` in any
//!   response envelope (mirrors the YAML migrator's safe rewrite).
//! - Detect deprecated method names (`tasks/list`, `roots/list`,
//!   `sampling/createMessage`, `logging/setLevel`,
//!   `notifications/message`) on the request envelope and record a
//!   migration note in the cassette's `meta` map under
//!   `migration_notes` so a replay surfaces the warning without
//!   forcing an immediate rewrite. The envelope shape under
//!   stateless 2026-07-28 is a runtime contract that needs the
//!   transport's source of truth to rewrite safely, so the
//!   "annotate" path is the right v1 outcome for those.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use serde_json::Value;

/// Outcome of planning a cassette migration.
#[derive(Debug, Clone)]
pub struct CassetteOutcome {
    /// Display path of the cassette.
    pub file: String,
    /// One entry per rewrite or annotation the planner found.
    pub edits: Vec<CassetteEdit>,
    /// Rewritten JSON body, or `None` when nothing changed.
    pub new_body: Option<String>,
}

/// One edit recorded by the cassette planner.
#[derive(Debug, Clone)]
pub struct CassetteEdit {
    /// Zero-based exchange index in the cassette.
    pub exchange: usize,
    /// Short description of the change.
    pub detail: String,
    /// True for a mechanical rewrite; false for an annotation-only entry.
    pub rewrote: bool,
}

/// True when the file looks like an mcptest cassette: parseable as
/// JSON and carrying both `version` and `exchanges` at the top
/// level.
pub fn looks_like_cassette(value: &Value) -> bool {
    let obj = match value.as_object() {
        Some(m) => m,
        None => return false,
    };
    obj.contains_key("version") && obj.get("exchanges").is_some_and(Value::is_array)
}

/// Plan rewrites for one cassette without touching disk.
pub fn plan_cassette(path: &Path) -> Result<Option<CassetteOutcome>> {
    let body = fs::read_to_string(path)
        .with_context(|| format!("cannot read cassette `{}`", path.display()))?;
    let mut value: Value = match serde_json::from_str(&body) {
        Ok(v) => v,
        Err(_) => return Ok(None),
    };
    if !looks_like_cassette(&value) {
        return Ok(None);
    }
    let mut edits = Vec::new();
    rewrite_legacy_error_code(&mut value, &mut edits);
    record_deprecated_methods(&mut value, &mut edits);
    if edits.is_empty() {
        return Ok(Some(CassetteOutcome {
            file: display_path(path),
            edits,
            new_body: None,
        }));
    }
    let new_body =
        serde_json::to_string_pretty(&value).with_context(|| "re-serializing cassette")?;
    Ok(Some(CassetteOutcome {
        file: display_path(path),
        edits,
        new_body: Some(new_body),
    }))
}

fn rewrite_legacy_error_code(value: &mut Value, edits: &mut Vec<CassetteEdit>) {
    let Some(exchanges) = value.get_mut("exchanges").and_then(Value::as_array_mut) else {
        return;
    };
    for (idx, exchange) in exchanges.iter_mut().enumerate() {
        let Some(response) = exchange.get_mut("response") else {
            continue;
        };
        let Some(error) = response.get_mut("error") else {
            continue;
        };
        let Some(code) = error.get_mut("code") else {
            continue;
        };
        if code.as_i64() == Some(-32002) {
            *code = Value::from(-32602);
            edits.push(CassetteEdit {
                exchange: idx,
                detail: "rewrote response.error.code -32002 -> -32602 (Invalid Params)".into(),
                rewrote: true,
            });
        }
    }
}

fn record_deprecated_methods(value: &mut Value, edits: &mut Vec<CassetteEdit>) {
    const DEPRECATED: &[&str] = &[
        "roots/list",
        "sampling/createMessage",
        "logging/setLevel",
        "notifications/message",
        "tasks/list",
    ];
    let Some(exchanges) = value.get("exchanges").and_then(Value::as_array) else {
        return;
    };
    let mut hits: Vec<(usize, String)> = Vec::new();
    for (idx, exchange) in exchanges.iter().enumerate() {
        let Some(method) = exchange
            .get("request")
            .and_then(|r| r.get("method"))
            .and_then(Value::as_str)
        else {
            continue;
        };
        if DEPRECATED.contains(&method) {
            hits.push((idx, method.to_string()));
        }
    }
    if hits.is_empty() {
        return;
    }
    let Some(obj) = value.as_object_mut() else {
        // Not a JSON object, so there is nothing to annotate. The earlier
        // exchange walk would not have produced hits for a non-object body,
        // but stay panic-free regardless.
        return;
    };
    // Coerce a missing or non-object `meta` to a fresh object. A user cassette
    // with `"meta": "x"` (or any non-object) must not crash the migrate walk.
    if !obj.get("meta").map(Value::is_object).unwrap_or(false) {
        obj.insert("meta".into(), Value::Object(serde_json::Map::new()));
    }
    let Some(meta) = obj.get_mut("meta").and_then(Value::as_object_mut) else {
        return;
    };
    for (idx, method) in &hits {
        let key = format!("migration_notes[{idx}]");
        let body = format!(
            "deprecated method `{method}` at exchange {idx}; rewrite by hand for 2026-07-28."
        );
        meta.insert(key, Value::String(body));
        edits.push(CassetteEdit {
            exchange: *idx,
            detail: format!("annotated deprecated method `{method}`"),
            rewrote: false,
        });
    }
}

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()
}

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

    #[test]
    fn looks_like_cassette_returns_true_for_a_minimal_cassette_shape() {
        assert!(looks_like_cassette(
            &json!({"version": "1", "exchanges": []})
        ));
    }

    #[test]
    fn looks_like_cassette_returns_false_for_a_plain_json_blob() {
        assert!(!looks_like_cassette(&json!({"hello": "world"})));
    }

    #[test]
    fn rewrite_legacy_error_code_flips_minus_32002_to_minus_32602() {
        let mut v = json!({
            "version": "1",
            "exchanges": [
                {"request": {"method": "tools/call"}, "response": {"error": {"code": -32002}}},
                {"request": {"method": "ping"}, "response": {"result": {}}}
            ]
        });
        let mut edits = Vec::new();
        rewrite_legacy_error_code(&mut v, &mut edits);
        assert_eq!(edits.len(), 1);
        assert!(edits[0].rewrote);
        assert_eq!(
            v["exchanges"][0]["response"]["error"]["code"],
            Value::from(-32602)
        );
    }

    #[test]
    fn record_deprecated_methods_annotates_a_tasks_list_hit() {
        let mut v = json!({
            "version": "1",
            "exchanges": [
                {"request": {"method": "tasks/list"}, "response": {"result": {}}}
            ]
        });
        let mut edits = Vec::new();
        record_deprecated_methods(&mut v, &mut edits);
        assert_eq!(edits.len(), 1);
        assert!(!edits[0].rewrote);
        assert!(v["meta"]["migration_notes[0]"]
            .as_str()
            .unwrap()
            .contains("tasks/list"));
    }

    #[test]
    fn record_deprecated_methods_handles_a_non_object_meta_without_panicking() {
        let mut v = json!({
            "version": "1",
            "meta": "not an object",
            "exchanges": [
                {"request": {"method": "tasks/list"}, "response": {"result": {}}}
            ]
        });
        let mut edits = Vec::new();
        record_deprecated_methods(&mut v, &mut edits);
        assert_eq!(edits.len(), 1);
        assert!(v["meta"]["migration_notes[0]"]
            .as_str()
            .unwrap()
            .contains("tasks/list"));
    }

    #[test]
    fn record_deprecated_methods_is_a_noop_when_every_method_is_current() {
        let mut v = json!({
            "version": "1",
            "exchanges": [
                {"request": {"method": "tools/call"}, "response": {"result": {}}}
            ]
        });
        let mut edits = Vec::new();
        record_deprecated_methods(&mut v, &mut edits);
        assert!(edits.is_empty());
        assert!(v.get("meta").is_none());
    }

    #[test]
    fn plan_cassette_returns_none_for_a_non_cassette_json_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("not_a_cassette.json");
        std::fs::write(&path, r#"{"hello": "world"}"#).unwrap();
        let outcome = plan_cassette(&path).unwrap();
        assert!(outcome.is_none());
    }

    #[test]
    fn plan_cassette_reports_no_edits_for_a_clean_cassette() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("clean.json");
        std::fs::write(
            &path,
            r#"{"version":"1","exchanges":[{"request":{"method":"ping"},"response":{"result":{}}}]}"#,
        )
        .unwrap();
        let outcome = plan_cassette(&path).unwrap().expect("cassette planned");
        assert!(outcome.edits.is_empty());
        assert!(outcome.new_body.is_none());
    }

    #[test]
    fn plan_cassette_returns_a_new_body_when_an_edit_was_made() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("legacy.json");
        std::fs::write(
            &path,
            r#"{"version":"1","exchanges":[{"request":{"method":"tools/call"},"response":{"error":{"code":-32002}}}]}"#,
        )
        .unwrap();
        let outcome = plan_cassette(&path).unwrap().expect("cassette planned");
        assert_eq!(outcome.edits.len(), 1);
        assert!(outcome.new_body.as_deref().unwrap().contains("-32602"));
    }
}
