//! Model-compatibility corpus shape validation.
//!
//! Walks `tests/fixtures/model-compat/` and confirms every scenario
//! directory carries the three required files (`baseline.json`,
//! `candidate.json`, `expected.yaml`), that the two JSON files parse,
//! and that the YAML file pins a known classification.
//!
//! The diff engine has landed, so `diff_engine_matches_every_expected_classification`
//! runs `diff_baselines` over every fixture and asserts the verdict matches
//! `expected.yaml`. The shape test still runs first so a malformed fixture fails
//! fast. Live `model-compat capture` / `run` against a real model remain
//! tracked separately (WOR-1385).

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

use mcptest_core::model_compat::{
    diff_baselines, BaselineFile, CapturedExchange, ConfigFingerprint, ContentBlock, DiffSummary,
    FinishReason, ToolCall,
};
use serde::Deserialize;

/// On-disk shape of `expected.yaml`. Mirrors the schema in
/// `tests/fixtures/model-compat/README.md`.
#[derive(Debug, Deserialize)]
struct Expected {
    classification: String,
    rationale: String,
    #[serde(default)]
    invariants_violated: Vec<String>,
    #[serde(default)]
    drift_kind: Option<String>,
}

fn fixtures_root() -> PathBuf {
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    manifest
        .join("..")
        .join("..")
        .join("tests")
        .join("fixtures")
        .join("model-compat")
}

fn list_scenarios() -> Vec<PathBuf> {
    let root = fixtures_root();
    let mut out: Vec<PathBuf> = std::fs::read_dir(&root)
        .unwrap_or_else(|e| panic!("read {}: {e}", root.display()))
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.path())
        .filter(|p| p.is_dir())
        .collect();
    out.sort();
    out
}

fn load_expected(dir: &Path) -> Expected {
    let path = dir.join("expected.yaml");
    let raw =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    serde_yaml::from_str(&raw).expect("expected.yaml parses")
}

fn parse_json(dir: &Path, name: &str) -> serde_json::Value {
    let path = dir.join(name);
    let raw =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
}

#[test]
fn corpus_has_enough_scenarios() {
    let scenarios = list_scenarios();
    assert!(
        scenarios.len() >= 15 && scenarios.len() <= 30,
        "found {} scenarios;  requires 15 to 20 (with a small buffer)",
        scenarios.len()
    );
}

#[test]
fn every_scenario_has_required_files() {
    for scenario in list_scenarios() {
        let baseline = scenario.join("baseline.json");
        let candidate = scenario.join("candidate.json");
        let expected = scenario.join("expected.yaml");
        assert!(
            baseline.is_file(),
            "missing baseline.json in {}",
            scenario.display()
        );
        assert!(
            candidate.is_file(),
            "missing candidate.json in {}",
            scenario.display()
        );
        assert!(
            expected.is_file(),
            "missing expected.yaml in {}",
            scenario.display()
        );
    }
}

#[test]
fn every_scenario_baseline_and_candidate_parse_as_json() {
    for scenario in list_scenarios() {
        let _ = parse_json(&scenario, "baseline.json");
        let _ = parse_json(&scenario, "candidate.json");
    }
}

#[test]
fn every_expected_yaml_has_a_known_classification() {
    let allowed = ["PASS", "DRIFT", "FAIL"];
    for scenario in list_scenarios() {
        let expected = load_expected(&scenario);
        assert!(
            allowed.contains(&expected.classification.as_str()),
            "{}: classification {} not in {:?}",
            scenario.display(),
            expected.classification,
            allowed
        );
        assert!(
            !expected.rationale.trim().is_empty(),
            "{}: rationale is empty",
            scenario.display()
        );
    }
}

#[test]
fn pass_scenarios_have_no_invariant_violations() {
    for scenario in list_scenarios() {
        let expected = load_expected(&scenario);
        if expected.classification == "PASS" {
            assert!(
                expected.invariants_violated.is_empty(),
                "{}: PASS scenarios cannot list invariants",
                scenario.display()
            );
            assert!(
                expected.drift_kind.is_none(),
                "{}: PASS scenarios cannot set drift_kind",
                scenario.display()
            );
        }
    }
}

#[test]
fn fail_scenarios_list_at_least_one_invariant() {
    for scenario in list_scenarios() {
        let expected = load_expected(&scenario);
        if expected.classification == "FAIL" {
            assert!(
                !expected.invariants_violated.is_empty(),
                "{}: FAIL scenarios must list at least one invariant",
                scenario.display()
            );
        }
    }
}

#[test]
fn drift_scenarios_have_a_drift_kind() {
    let allowed = [
        "semantic-equivalent",
        "structurally-equivalent",
        "shape-additive",
        "surface-whitespace",
        "surface-case",
    ];
    for scenario in list_scenarios() {
        let expected = load_expected(&scenario);
        if expected.classification == "DRIFT" {
            let kind = expected.drift_kind.as_deref().unwrap_or("");
            assert!(
                allowed.contains(&kind),
                "{}: drift_kind {} not in {:?}",
                scenario.display(),
                kind,
                allowed
            );
            assert!(
                expected.invariants_violated.is_empty(),
                "{}: DRIFT scenarios cannot list invariants (use FAIL instead)",
                scenario.display()
            );
        }
    }
}

#[test]
fn every_scenario_has_model_field_in_both_snapshots() {
    // Snapshots without a `model` field would be ambiguous when we
    // print the report. Pin the requirement here.
    for scenario in list_scenarios() {
        for name in ["baseline.json", "candidate.json"] {
            let v = parse_json(&scenario, name);
            assert!(
                v.get("model").is_some(),
                "{}/{}: missing top-level 'model' field",
                scenario.display(),
                name
            );
        }
    }
}

/// The single-exchange fixture shape (`content` / `tool_calls` /
/// `finish_reason`, plus an ignored `model` field). The diff engine works on
/// `BaselineFile`, so the test wraps each fixture exchange in one.
#[derive(Debug, Deserialize)]
struct FixtureExchange {
    #[serde(default)]
    content: Vec<ContentBlock>,
    #[serde(default)]
    tool_calls: Vec<ToolCall>,
    finish_reason: FinishReason,
}

fn load_fixture(dir: &Path, name: &str) -> FixtureExchange {
    let path = dir.join(name);
    let raw =
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
    serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
}

/// Wrap a fixture exchange in a single-assertion `BaselineFile`. Baseline and
/// candidate share `id` (so the diff pairs them) and `model_id` (so the
/// behavioral fingerprint compares behavior, not the model label).
fn to_baseline(fixture: FixtureExchange) -> BaselineFile {
    BaselineFile {
        schema: "https://mcptest.sh/schema/model-compat-baseline-v1.json".to_string(),
        version: 1,
        model_id: "corpus:fixture".to_string(),
        model_version: "test".to_string(),
        mcptest_version: "test".to_string(),
        server_version: "test".to_string(),
        server_config_hash: "test".to_string(),
        config_fingerprint: ConfigFingerprint::default(),
        assertions: vec![CapturedExchange {
            id: "corpus::scenario::1".to_string(),
            content: fixture.content,
            tool_calls: fixture.tool_calls,
            finish_reason: fixture.finish_reason,
            mcp_messages: Vec::new(),
        }],
        invariants: Vec::new(),
    }
}

/// Reduce the diff summary to the corpus verdict, the same rubric the CLI uses
/// for its exit code: any FAIL wins, else any DRIFT, else PASS.
fn verdict(summary: &DiffSummary) -> &'static str {
    if summary.fail > 0 {
        "FAIL"
    } else if summary.drift > 0 {
        "DRIFT"
    } else {
        "PASS"
    }
}

/// End-to-end diff classification: run `diff_baselines` over every fixture and
/// assert the verdict matches `expected.yaml`. Key-reorder fixtures (05/06)
/// carry `raw_arguments` so the engine can detect the source-level reorder that
/// `serde_json::Value` equality alone misses (ADR 0026).
#[test]
fn diff_engine_matches_every_expected_classification() {
    for scenario in list_scenarios() {
        let baseline = to_baseline(load_fixture(&scenario, "baseline.json"));
        let candidate = to_baseline(load_fixture(&scenario, "candidate.json"));
        let diff = diff_baselines(&baseline, &candidate)
            .unwrap_or_else(|e| panic!("diff {}: {e}", scenario.display()));
        assert_eq!(
            verdict(&diff.summary),
            load_expected(&scenario).classification.as_str(),
            "scenario {}",
            scenario.display()
        );
    }
}
