//! Migration pair-corpus.
//!
//! A small, disposable corpus that powers the migration doctor:
//! one row per breaking change between MCP spec versions, each row pairing
//! the **old contract**, the **new contract**, and a **detection signal**
//! the doctor uses to decide whether the old contract is in use. Disposable
//! because rows can be retired once their deprecation window closes (the
//! 12-month Active → Deprecated → Removed cycle introduced by SEP-2577).
//!
//! Separate from the main multi-version compliance corpus
//! (`crate::compliance`): the main corpus is permanent and tier-scored
//! against the official conformance suite; this one is a checklist for a
//! known migration. The doctor consumes the embedded corpus by default;
//! callers can also load a custom corpus from any YAML body via
//! [`MigrationCorpus::from_yaml_str`].

use std::fmt;

use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;

/// Embedded corpus body, compiled in so a released binary can run the
/// migration doctor without the YAML file on disk.
const BUILTIN_CORPUS_YAML: &str = include_str!("corpus.yaml");

/// OLD contract: the MCP-custom missing-resource error code, removed by
/// the 2026-07-28 revision.
const LEGACY_RESOURCE_ERROR_CODE: i64 = -32002;

/// NEW contract: the standard JSON-RPC Invalid Params code the 2026-07-28
/// revision uses for a missing resource.
const STANDARD_RESOURCE_ERROR_CODE: i64 = -32602;

/// Errors raised while loading or validating a pair-corpus.
#[derive(Debug, Error)]
pub enum MigrationError {
    /// The YAML body did not parse against the documented shape.
    #[error("migration corpus is not valid YAML: {0}")]
    Yaml(#[from] serde_yaml::Error),

    /// Two cases shared the same `id`. Ids are surfaced in the doctor
    /// report and used as stable keys; a collision would make a report
    /// ambiguous, so the loader refuses to accept it.
    #[error("duplicate migration case id `{0}`")]
    DuplicateId(String),

    /// A case declared neither an old nor a new contract. Almost always a
    /// typo: the row was meant to pair contracts, so the loader rejects
    /// the empty form rather than emitting a useless report row.
    #[error("migration case `{id}` declares neither `old_contract` nor `new_contract`")]
    EmptyCase {
        /// The case id that triggered the error.
        id: String,
    },
}

/// Severity assigned to a migration case. Doctors and reporters use this
/// to color the row and to decide the process exit code.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationSeverity {
    /// The current behavior breaks on the target version; users must
    /// migrate before the deprecation window closes.
    High,
    /// Behavior changes but a documented compatibility shim is available;
    /// migration is strongly recommended.
    Medium,
    /// Cosmetic, informational, or future-looking: surfaces the change
    /// but does not require action.
    Low,
}

impl fmt::Display for MigrationSeverity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MigrationSeverity::High => f.write_str("high"),
            MigrationSeverity::Medium => f.write_str("medium"),
            MigrationSeverity::Low => f.write_str("low"),
        }
    }
}

/// Categorical kind for a migration case. Drives grouping in reports and
/// lets the doctor decide which checks to run live vs static.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MigrationKind {
    /// Transport-level change (session, headers, protocol envelope).
    StatelessTransport,
    /// Tasks extension lifecycle change.
    TasksExtension,
    /// Server-initiated capability deprecation (Roots / Sampling /
    /// Logging).
    DeprecatedCapability,
    /// Authorization-hardening rule (iss, DCR, refresh, well-known).
    AuthHardening,
    /// JSON-RPC error-code migration (today: -32002 → -32602).
    LegacyErrorCode,
    /// JSON Schema 2020-12 upgrade for tool schemas.
    SchemaVersion,
}

impl fmt::Display for MigrationKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MigrationKind::StatelessTransport => f.write_str("stateless-transport"),
            MigrationKind::TasksExtension => f.write_str("tasks-extension"),
            MigrationKind::DeprecatedCapability => f.write_str("deprecated-capability"),
            MigrationKind::AuthHardening => f.write_str("auth-hardening"),
            MigrationKind::LegacyErrorCode => f.write_str("legacy-error-code"),
            MigrationKind::SchemaVersion => f.write_str("schema-version"),
        }
    }
}

/// One row in the pair-corpus: the old contract, the new contract, the
/// signal the doctor probes for, and pointers to the replacement.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MigrationCase {
    /// Stable kebab-case id, surfaced in the doctor report and used as a
    /// report key. Must be unique within a corpus.
    pub id: String,
    /// Categorical bucket; see [`MigrationKind`].
    pub kind: MigrationKind,
    /// Doctor-report severity; see [`MigrationSeverity`].
    pub severity: MigrationSeverity,
    /// Spec version that introduces the change (for example `2026-07-28`).
    pub spec_version: String,
    /// One-line headline shown at the top of the report row.
    pub summary: String,
    /// Multi-line description of the old, pre-target-version contract.
    /// Optional only because some cases are net-new behavior with no
    /// before-state; the loader rejects a case where both `old_contract`
    /// and `new_contract` are empty.
    #[serde(default)]
    pub old_contract: String,
    /// Multi-line description of the new, post-target-version contract.
    /// Optional under the same rule as `old_contract`.
    #[serde(default)]
    pub new_contract: String,
    /// Multi-line description of the signal the doctor probes for to
    /// decide whether the old contract is in use. Plain language: the
    /// doctor parses each kind in code, not here.
    pub detection_signal: String,
    /// One-line replacement guidance surfaced on the doctor row.
    pub replacement: String,
    /// URL pointing at the spec or a migration note for this case.
    pub doc_link: String,
    /// SEP citations relevant to the case, comma-separated for readability
    /// (for example `SEP-2567, SEP-2575`). Free-form; the loader only
    /// stores it.
    #[serde(default)]
    pub sep: String,
}

/// A loaded migration pair-corpus.
///
/// Held as plain data so the doctor and any other consumer share one
/// representation. The order of [`MigrationCorpus::cases`] is the order
/// the doctor reports rows in.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MigrationCorpus {
    /// Cases in report order.
    pub cases: Vec<MigrationCase>,
}

impl MigrationCorpus {
    /// Load the embedded built-in corpus that ships with the binary.
    pub fn builtin() -> Result<Self, MigrationError> {
        Self::from_yaml_str(BUILTIN_CORPUS_YAML)
    }

    /// Parse a corpus from a YAML body.
    pub fn from_yaml_str(body: &str) -> Result<Self, MigrationError> {
        let corpus: MigrationCorpus = serde_yaml::from_str(body)?;
        corpus.validate()?;
        Ok(corpus)
    }

    /// Return every case matching `kind`. Used by the doctor when a
    /// caller scopes a run to one category.
    pub fn by_kind(&self, kind: MigrationKind) -> impl Iterator<Item = &MigrationCase> {
        self.cases.iter().filter(move |c| c.kind == kind)
    }

    /// Return every case whose `spec_version` equals `version`.
    pub fn for_spec_version<'a>(
        &'a self,
        version: &'a str,
    ) -> impl Iterator<Item = &'a MigrationCase> {
        self.cases.iter().filter(move |c| c.spec_version == version)
    }

    /// Run every case in the corpus against an `initialize` response and
    /// emit one [`MigrationFinding`] per case.
    ///
    /// `init_response` should be the raw JSON body the server returned
    /// from `initialize` (it carries `capabilities` and `serverInfo`).
    /// Cases the v1 doctor can decide statically return `Pass` or `Fail`;
    /// every other case returns `Skipped` with a one-line rationale that
    /// names the future ticket which will close the gap. The order of
    /// findings matches the order of [`MigrationCorpus::cases`] so the
    /// report reads top to bottom.
    pub fn check_initialize_response(&self, init_response: &Value) -> Vec<MigrationFinding> {
        let caps = init_response
            .get("capabilities")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({}));
        self.cases
            .iter()
            .map(|case| evaluate_case(case, &caps))
            .collect()
    }

    /// Check an observed missing-resource error code against the
    /// legacy-error-code case.
    ///
    /// The 2026-07-28 revision moves the missing-resource error code from
    /// the MCP-custom `-32002` (the OLD contract) to the standard JSON-RPC
    /// `-32602` Invalid Params code (the NEW contract). Pass the `code` a
    /// server returned when a client read a resource that does not exist;
    /// the doctor returns a [`MigrationFinding`] for the corpus's
    /// `legacy-error-code` case:
    ///
    /// - `-32002` is `Fail`: the server still emits the legacy code and the
    ///   finding names `-32602` as the replacement.
    /// - `-32602` is `Pass`: the server already returns the new code.
    /// - any other code is `Skipped`: this probe only decides the
    ///   missing-resource transition, so an unrelated error code is left
    ///   for the caller to interpret rather than misreported.
    ///
    /// Returns `None` when the corpus carries no `legacy-error-code` case
    /// (a custom corpus may drop the row); callers that always want a row
    /// can treat `None` as "not applicable".
    pub fn check_resource_error_code(&self, code: i64) -> Option<MigrationFinding> {
        let case = self
            .cases
            .iter()
            .find(|c| c.kind == MigrationKind::LegacyErrorCode)?;
        let (status, detail) = match code {
            LEGACY_RESOURCE_ERROR_CODE => (
                FindingStatus::Fail,
                format!(
                    "server returned the legacy `{LEGACY_RESOURCE_ERROR_CODE}` code for a \
                     missing resource; the 2026-07-28 revision expects \
                     `{STANDARD_RESOURCE_ERROR_CODE}` (Invalid Params)"
                ),
            ),
            STANDARD_RESOURCE_ERROR_CODE => (FindingStatus::Pass, String::new()),
            other => (
                FindingStatus::Skipped,
                format!(
                    "observed error code `{other}` is neither the legacy \
                     `{LEGACY_RESOURCE_ERROR_CODE}` nor the standard \
                     `{STANDARD_RESOURCE_ERROR_CODE}`; the missing-resource probe is \
                     inconclusive"
                ),
            ),
        };
        Some(MigrationFinding {
            id: case.id.clone(),
            kind: case.kind,
            severity: case.severity,
            status,
            summary: case.summary.clone(),
            detail,
            replacement: case.replacement.clone(),
            doc_link: case.doc_link.clone(),
        })
    }

    fn validate(&self) -> Result<(), MigrationError> {
        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
        for case in &self.cases {
            if !seen.insert(case.id.as_str()) {
                return Err(MigrationError::DuplicateId(case.id.clone()));
            }
            if case.old_contract.trim().is_empty() && case.new_contract.trim().is_empty() {
                return Err(MigrationError::EmptyCase {
                    id: case.id.clone(),
                });
            }
        }
        Ok(())
    }
}

/// Per-case verdict returned by [`MigrationCorpus::check_initialize_response`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingStatus {
    /// The server does not exhibit the deprecated behavior. Green row.
    Pass,
    /// The server exhibits the deprecated behavior. Red row that gates CI.
    Fail,
    /// The doctor v1 cannot decide this case yet (for example, the check
    /// needs the stateless transport which lands). The row
    /// carries a reason; the future ticket closes the gap.
    Skipped,
}

impl fmt::Display for FindingStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FindingStatus::Pass => f.write_str("pass"),
            FindingStatus::Fail => f.write_str("fail"),
            FindingStatus::Skipped => f.write_str("skipped"),
        }
    }
}

/// One row of the migration doctor report: the case being checked, the
/// verdict, and a short detail string.
#[derive(Debug, Clone, Serialize)]
pub struct MigrationFinding {
    /// Case id (stable kebab-case key from the corpus).
    pub id: String,
    /// Categorical bucket inherited from the case.
    pub kind: MigrationKind,
    /// Severity inherited from the case. Only matters when `status` is
    /// `Fail`; reporters can use it for color or to decide the exit code.
    pub severity: MigrationSeverity,
    /// Verdict.
    pub status: FindingStatus,
    /// Headline summary from the case.
    pub summary: String,
    /// One-line note: for `Fail`, what we saw; for `Skipped`, why we
    /// could not decide; for `Pass`, the empty string.
    pub detail: String,
    /// Replacement guidance inherited from the case.
    pub replacement: String,
    /// Doc link inherited from the case.
    pub doc_link: String,
}

fn evaluate_case(case: &MigrationCase, caps: &Value) -> MigrationFinding {
    let (status, detail) = match case.kind {
        MigrationKind::DeprecatedCapability => evaluate_deprecated_capability(case, caps),
        MigrationKind::StatelessTransport => skip_for_future(
            "stateless transport probe lands with the version-aware client",
        ),
        MigrationKind::TasksExtension => skip_for_future(
            "Tasks extension check lands with the extension test-pack framework (/928)",
        ),
        MigrationKind::AuthHardening => skip_for_future(
            "auth-hardening checks land with the auth conformance pack",
        ),
        MigrationKind::LegacyErrorCode => skip_for_future(
            "the legacy `-32002` code is not visible in the initialize response; probe a missing resource and pass the code to `check_resource_error_code`, or run `mcptest lint` over your suites and cassettes",
        ),
        MigrationKind::SchemaVersion => skip_for_future(
            "tool-schema 2020-12 inspection lands with the schema validator upgrade",
        ),
    };
    MigrationFinding {
        id: case.id.clone(),
        kind: case.kind,
        severity: case.severity,
        status,
        summary: case.summary.clone(),
        detail,
        replacement: case.replacement.clone(),
        doc_link: case.doc_link.clone(),
    }
}

/// Static check for a deprecated capability: the case id is one of the
/// well-known capability keys; report Fail when the server advertises
/// the key. Unknown ids fall through to `Skipped` so a future corpus
/// case in the same kind does not silently misreport.
fn evaluate_deprecated_capability(case: &MigrationCase, caps: &Value) -> (FindingStatus, String) {
    let key = match case.id.as_str() {
        "roots-deprecated" => "roots",
        "sampling-deprecated" => "sampling",
        "logging-deprecated" => "logging",
        _ => return skip_for_future("unknown deprecated-capability case"),
    };
    if caps.get(key).is_some() {
        (
            FindingStatus::Fail,
            format!("server advertises `{key}` in its capability block"),
        )
    } else {
        (FindingStatus::Pass, String::new())
    }
}

fn skip_for_future(reason: &str) -> (FindingStatus, String) {
    (FindingStatus::Skipped, reason.to_string())
}

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

    #[test]
    fn builtin_corpus_loads_and_covers_every_kind() {
        let corpus = MigrationCorpus::builtin().expect("builtin corpus is valid");
        assert!(!corpus.cases.is_empty(), "builtin corpus must have cases");
        let mut kinds = std::collections::BTreeSet::new();
        for case in &corpus.cases {
            kinds.insert(case.kind);
        }
        // Every kind we model should appear at least once; if a future
        // change retires a kind, drop it from this assertion AND the
        // MigrationKind enum together so they cannot drift.
        for k in [
            MigrationKind::StatelessTransport,
            MigrationKind::TasksExtension,
            MigrationKind::DeprecatedCapability,
            MigrationKind::AuthHardening,
            MigrationKind::LegacyErrorCode,
            MigrationKind::SchemaVersion,
        ] {
            assert!(kinds.contains(&k), "builtin corpus missing kind {k}");
        }
    }

    #[test]
    fn rejects_duplicate_case_ids() {
        let body = "cases:\n  - id: a\n    kind: legacy-error-code\n    severity: low\n    spec_version: '2026-07-28'\n    summary: x\n    old_contract: o\n    detection_signal: s\n    replacement: r\n    doc_link: u\n  - id: a\n    kind: legacy-error-code\n    severity: low\n    spec_version: '2026-07-28'\n    summary: x\n    old_contract: o\n    detection_signal: s\n    replacement: r\n    doc_link: u\n";
        let err = MigrationCorpus::from_yaml_str(body).expect_err("duplicate id rejected");
        assert!(matches!(err, MigrationError::DuplicateId(ref id) if id == "a"));
    }

    #[test]
    fn rejects_empty_case() {
        let body = "cases:\n  - id: a\n    kind: legacy-error-code\n    severity: low\n    spec_version: '2026-07-28'\n    summary: x\n    detection_signal: s\n    replacement: r\n    doc_link: u\n";
        let err = MigrationCorpus::from_yaml_str(body).expect_err("empty case rejected");
        assert!(matches!(err, MigrationError::EmptyCase { ref id } if id == "a"));
    }

    #[test]
    fn by_kind_filters_correctly() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let auth: Vec<_> = corpus.by_kind(MigrationKind::AuthHardening).collect();
        assert!(
            !auth.is_empty(),
            "expected at least one auth-hardening case"
        );
        for case in auth {
            assert_eq!(case.kind, MigrationKind::AuthHardening);
        }
    }

    #[test]
    fn check_flags_advertised_deprecated_capabilities() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let init = serde_json::json!({
            "protocolVersion": "2025-03-26",
            "capabilities": {
                "roots": {},
                "sampling": {},
                "logging": {},
                "tools": { "listChanged": true },
            },
            "serverInfo": { "name": "demo", "version": "0.1.0" },
        });
        let findings = corpus.check_initialize_response(&init);
        let by_id: std::collections::BTreeMap<_, _> =
            findings.iter().map(|f| (f.id.as_str(), f)).collect();
        assert_eq!(by_id["roots-deprecated"].status, FindingStatus::Fail);
        assert_eq!(by_id["sampling-deprecated"].status, FindingStatus::Fail);
        assert_eq!(by_id["logging-deprecated"].status, FindingStatus::Fail);
        assert!(by_id["roots-deprecated"].detail.contains("roots"));
    }

    #[test]
    fn check_passes_when_no_deprecated_capability_is_advertised() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let init = serde_json::json!({
            "capabilities": { "tools": {}, "resources": {} },
        });
        let findings = corpus.check_initialize_response(&init);
        for f in findings.iter() {
            if matches!(f.kind, MigrationKind::DeprecatedCapability) {
                assert_eq!(f.status, FindingStatus::Pass, "id={}", f.id);
            }
        }
    }

    #[test]
    fn check_skips_categories_that_need_future_tickets() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let init = serde_json::json!({ "capabilities": {} });
        let findings = corpus.check_initialize_response(&init);
        let skipped: Vec<_> = findings
            .iter()
            .filter(|f| f.status == FindingStatus::Skipped)
            .collect();
        assert!(
            !skipped.is_empty(),
            "v1 doctor must skip the not-yet-detectable cases"
        );
        for f in skipped {
            assert!(
                !f.detail.is_empty(),
                "skipped row must carry a reason: {}",
                f.id
            );
        }
    }

    #[test]
    fn every_builtin_case_targets_2026_07_28() {
        let corpus = MigrationCorpus::builtin().unwrap();
        for case in &corpus.cases {
            assert_eq!(
                case.spec_version, "2026-07-28",
                "case `{}` should target 2026-07-28 for v1 of the corpus",
                case.id
            );
        }
    }

    #[test]
    fn corpus_legacy_error_case_maps_old_32002_to_new_32602() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let case = corpus
            .by_kind(MigrationKind::LegacyErrorCode)
            .next()
            .expect("corpus carries a legacy-error-code case");
        // The OLD contract names the MCP-custom -32002; the NEW contract
        // names the JSON-RPC standard -32602. Guarding both directions
        // here catches an accidental swap of the pair.
        assert!(
            case.old_contract.contains("-32002"),
            "old contract must describe the legacy -32002 code"
        );
        assert!(
            case.new_contract.contains("-32602"),
            "new contract must describe the standard -32602 code"
        );
        assert!(
            case.replacement.contains("-32602"),
            "replacement guidance must name -32602"
        );
    }

    #[test]
    fn doctor_flags_legacy_32002_resource_code_and_names_32602() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let finding = corpus
            .check_resource_error_code(-32002)
            .expect("legacy-error-code case present");
        assert_eq!(finding.kind, MigrationKind::LegacyErrorCode);
        assert_eq!(finding.status, FindingStatus::Fail);
        assert!(
            finding.detail.contains("-32002"),
            "detail should name the observed legacy code"
        );
        assert!(
            finding.detail.contains("-32602"),
            "detail should name the -32602 replacement"
        );
        assert!(
            finding.replacement.contains("-32602"),
            "replacement guidance should name -32602"
        );
    }

    #[test]
    fn doctor_passes_when_resource_code_is_already_32602() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let finding = corpus
            .check_resource_error_code(-32602)
            .expect("legacy-error-code case present");
        assert_eq!(finding.status, FindingStatus::Pass);
        assert!(finding.detail.is_empty());
    }

    #[test]
    fn doctor_skips_unrelated_resource_error_code() {
        let corpus = MigrationCorpus::builtin().unwrap();
        let finding = corpus
            .check_resource_error_code(-32601)
            .expect("legacy-error-code case present");
        assert_eq!(finding.status, FindingStatus::Skipped);
        assert!(!finding.detail.is_empty(), "skip must carry a reason");
    }

    #[test]
    fn doctor_skips_internal_error_resource_code() {
        // -32000 (server-defined / generic internal error) is a third
        // unrelated code distinct from the -32601 case above. It must also
        // fall to Skipped and name the observed code so the row is not
        // silently misreported as a missing-resource verdict.
        let corpus = MigrationCorpus::builtin().unwrap();
        let finding = corpus
            .check_resource_error_code(-32000)
            .expect("legacy-error-code case present");
        assert_eq!(finding.status, FindingStatus::Skipped);
        assert!(
            finding.detail.contains("-32000"),
            "skip detail should name the observed code"
        );
    }

    #[test]
    fn doctor_returns_none_when_corpus_has_no_legacy_error_case() {
        // A custom corpus may drop the legacy-error-code row entirely. The
        // probe then has no case to attach a finding to and must return
        // None rather than fabricating one.
        let body = "cases:\n  - id: roots-deprecated\n    kind: deprecated-capability\n    severity: medium\n    spec_version: '2026-07-28'\n    summary: x\n    old_contract: o\n    new_contract: n\n    detection_signal: s\n    replacement: r\n    doc_link: u\n";
        let corpus = MigrationCorpus::from_yaml_str(body).expect("custom corpus is valid");
        assert!(
            corpus.check_resource_error_code(-32002).is_none(),
            "no legacy-error-code case means no finding"
        );
        assert!(
            corpus.check_resource_error_code(-32602).is_none(),
            "the new-code path also yields None without the case"
        );
    }

    #[test]
    fn resource_error_code_constants_are_the_documented_pair() {
        // Guard the literal values the helper and docs depend on: the OLD
        // MCP-custom code is -32002, the NEW JSON-RPC standard code is
        // -32602 (Invalid Params). A change to either constant should fail
        // here loudly rather than silently shifting every finding.
        assert_eq!(LEGACY_RESOURCE_ERROR_CODE, -32002);
        assert_eq!(STANDARD_RESOURCE_ERROR_CODE, -32602);
    }
}
