//! QA-owned output-model corpus. Pins the canonical run output
//! model end to end: serde round-trips for `Report`, `RunSummary`,
//! `TestResult`, `TestFailure`, and `TestStatus`; an insta snapshot of
//! the canonical fixture so a change to the JSON shape shows up as a
//! reviewable diff rather than a silent break; and cache-key determinism
//! tests that pin the "same inputs hash to same key, different inputs
//! hash to different keys" invariant.

use insta::assert_json_snapshot;
use mcptest_core::cache::store::{derive, CacheKeyInputs};
use mcptest_core::report::{Report, RunSummary, TestFailure, TestResult, TestStatus, TestStep};
use proptest::prelude::*;
use serde_json::{json, Value};

// ---------------------------------------------------------------------------
// Serde round-trip
// ---------------------------------------------------------------------------

#[test]
fn report_round_trips_via_json() {
    let r = Report::sample();
    let serialized = serde_json::to_string(&r).unwrap();
    let parsed: Report = serde_json::from_str(&serialized).unwrap();
    assert_eq!(r, parsed);
}

#[test]
fn report_all_pass_round_trips_via_json() {
    let r = Report::sample_all_pass();
    let serialized = serde_json::to_string(&r).unwrap();
    let parsed: Report = serde_json::from_str(&serialized).unwrap();
    assert_eq!(r, parsed);
}

#[test]
fn report_round_trips_via_pretty_json() {
    let r = Report::sample();
    let pretty = serde_json::to_string_pretty(&r).unwrap();
    let parsed: Report = serde_json::from_str(&pretty).unwrap();
    assert_eq!(r, parsed);
}

#[test]
fn test_status_serializes_as_kebab_case() {
    let s = serde_json::to_value(TestStatus::Pass).unwrap();
    assert_eq!(s, json!("pass"));
    let f = serde_json::to_value(TestStatus::Fail).unwrap();
    assert_eq!(f, json!("fail"));
    let sk = serde_json::to_value(TestStatus::Skip).unwrap();
    assert_eq!(sk, json!("skip"));
}

#[test]
fn test_status_round_trip_for_each_variant() {
    for status in [TestStatus::Pass, TestStatus::Fail, TestStatus::Skip] {
        let v = serde_json::to_value(status).unwrap();
        let back: TestStatus = serde_json::from_value(v).unwrap();
        assert_eq!(back, status);
    }
}

#[test]
fn test_result_with_failure_round_trips() {
    let t = TestResult {
        name: "name".into(),
        status: TestStatus::Fail,
        duration_ms: 17,
        failure: Some(TestFailure {
            message: "boom".into(),
            diff: Some("a vs b".into()),
            network_diagnostic: None,
        }),
        file: Some("tests/x.yml".into()),
        line: Some(3),
        cache_hit: false,
        started_at: Some("2026-05-16T00:00:00Z".into()),
        ended_at: Some("2026-05-16T00:00:00.017Z".into()),
        steps: Vec::new(),
        consensus: None,
        rule_id: None,
        severity: None,
        score: None,
        derived_metrics: Vec::new(),
        natural_language: false,
    };
    let v = serde_json::to_value(&t).unwrap();
    let back: TestResult = serde_json::from_value(v).unwrap();
    assert_eq!(t, back);
}

#[test]
fn test_result_without_failure_round_trips() {
    let t = TestResult {
        name: "ok".into(),
        status: TestStatus::Pass,
        duration_ms: 1,
        failure: None,
        file: None,
        line: None,
        cache_hit: true,
        started_at: None,
        ended_at: None,
        steps: Vec::new(),
        consensus: None,
        rule_id: None,
        severity: None,
        score: None,
        derived_metrics: Vec::new(),
        natural_language: false,
    };
    let v = serde_json::to_value(&t).unwrap();
    let back: TestResult = serde_json::from_value(v).unwrap();
    assert_eq!(t, back);
}

#[test]
fn run_summary_round_trips() {
    let s = RunSummary {
        total: 10,
        passed: 7,
        failed: 2,
        skipped: 1,
        inconclusive: 0,
        duration_ms: 1234,
        cache_hits: 3,
        verdict_cache_hits: 0,
        started_at: Some("2026-05-16T00:00:00Z".into()),
        ended_at: Some("2026-05-16T00:00:01.234Z".into()),
    };
    let v = serde_json::to_value(&s).unwrap();
    let back: RunSummary = serde_json::from_value(v).unwrap();
    assert_eq!(s, back);
}

// ---------------------------------------------------------------------------
// Insta snapshot of the canonical fixture
// ---------------------------------------------------------------------------

#[test]
fn canonical_sample_report_matches_snapshot() {
    // Stable across runs: `mcptest_version` would otherwise drift on each
    // crate bump, so we redact it. `generated_at` and `run_id` are pinned
    // in the fixture and do not need redaction.
    let r = Report::sample();
    insta::with_settings!({sort_maps => true}, {
        assert_json_snapshot!(
            "canonical_sample_report",
            r,
            { ".mcptest_version" => "[pinned]" }
        );
    });
}

#[test]
fn canonical_sample_all_pass_report_matches_snapshot() {
    let r = Report::sample_all_pass();
    insta::with_settings!({sort_maps => true}, {
        assert_json_snapshot!(
            "canonical_sample_all_pass_report",
            r,
            { ".mcptest_version" => "[pinned]" }
        );
    });
}

// ---------------------------------------------------------------------------
// Output-model shape invariants
// ---------------------------------------------------------------------------

#[test]
fn sample_report_summary_matches_test_outcomes() {
    let r = Report::sample();
    let actual_passed = r
        .tests
        .iter()
        .filter(|t| t.status == TestStatus::Pass)
        .count();
    let actual_failed = r
        .tests
        .iter()
        .filter(|t| t.status == TestStatus::Fail)
        .count();
    assert_eq!(r.summary.passed, actual_passed);
    assert_eq!(r.summary.failed, actual_failed);
    assert_eq!(r.summary.total, r.tests.len());
}

#[test]
fn sample_all_pass_has_no_failure_payload() {
    let r = Report::sample_all_pass();
    assert!(r.tests.iter().all(|t| t.failure.is_none()));
    assert_eq!(r.summary.failed, 0);
}

#[test]
fn failing_test_carries_failure_payload() {
    let r = Report::sample();
    let failing: Vec<&TestResult> = r
        .tests
        .iter()
        .filter(|t| t.status == TestStatus::Fail)
        .collect();
    assert!(!failing.is_empty());
    for t in failing {
        assert!(
            t.failure.is_some(),
            "failing test must carry a failure payload"
        );
    }
}

#[test]
fn json_values_for_sample_are_validatable_objects() {
    let r = Report::sample();
    let v: Value = serde_json::to_value(&r).unwrap();
    assert!(v.is_object());
    assert!(v["schema_version"].is_string());
    assert!(v["tests"].is_array());
    assert!(v["summary"].is_object());
}

// ---------------------------------------------------------------------------
// Cache key determinism
// ---------------------------------------------------------------------------

fn base_inputs<'a>(args: &'a Value) -> CacheKeyInputs<'a> {
    CacheKeyInputs {
        server_id: "svr-1",
        tool_name: "search",
        normalized_args: args,
        server_version_pin: Some("1.2.3"),
        runner_version: "0.1.0",
        matcher_signature: "exact:abc",
    }
}

#[test]
fn cache_key_is_deterministic_across_calls() {
    let args = json!({"q": "hello"});
    let inputs = base_inputs(&args);
    let a = derive(&inputs);
    let b = derive(&inputs);
    assert_eq!(a, b);
    assert_eq!(a.len(), 64, "SHA-256 hex string is 64 chars");
    assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}

#[test]
fn cache_key_changes_when_tool_name_changes() {
    let args = json!({"q": "hello"});
    let inputs_a = base_inputs(&args);
    let mut inputs_b = inputs_a.clone();
    inputs_b.tool_name = "fetch";
    assert_ne!(derive(&inputs_a), derive(&inputs_b));
}

#[test]
fn cache_key_changes_when_args_change() {
    let args_a = json!({"q": "hello"});
    let args_b = json!({"q": "world"});
    assert_ne!(derive(&base_inputs(&args_a)), derive(&base_inputs(&args_b)));
}

#[test]
fn cache_key_changes_when_runner_version_bumps() {
    let args = json!({});
    let inputs_a = base_inputs(&args);
    let mut inputs_b = inputs_a.clone();
    inputs_b.runner_version = "9.9.9";
    assert_ne!(derive(&inputs_a), derive(&inputs_b));
}

#[test]
fn cache_key_changes_when_matcher_signature_changes() {
    let args = json!({});
    let inputs_a = base_inputs(&args);
    let mut inputs_b = inputs_a.clone();
    inputs_b.matcher_signature = "exact:zzz";
    assert_ne!(derive(&inputs_a), derive(&inputs_b));
}

#[test]
fn cache_key_changes_when_server_id_changes() {
    let args = json!({});
    let inputs_a = base_inputs(&args);
    let mut inputs_b = inputs_a.clone();
    inputs_b.server_id = "svr-2";
    assert_ne!(derive(&inputs_a), derive(&inputs_b));
}

#[test]
fn cache_key_changes_between_some_and_none_server_pin() {
    let args = json!({});
    let inputs_a = base_inputs(&args);
    let mut inputs_b = inputs_a.clone();
    inputs_b.server_version_pin = None;
    assert_ne!(derive(&inputs_a), derive(&inputs_b));
}

#[test]
fn cache_key_is_order_insensitive_on_args() {
    let args_a = json!({"a": 1, "b": 2, "c": 3});
    let args_b = json!({"c": 3, "b": 2, "a": 1});
    assert_eq!(derive(&base_inputs(&args_a)), derive(&base_inputs(&args_b)));
}

#[test]
fn cache_key_is_order_insensitive_on_nested_objects() {
    let args_a = json!({"outer": {"x": 1, "y": 2}});
    let args_b = json!({"outer": {"y": 2, "x": 1}});
    assert_eq!(derive(&base_inputs(&args_a)), derive(&base_inputs(&args_b)));
}

#[test]
fn cache_key_array_order_matters() {
    let args_a = json!({"list": [1, 2, 3]});
    let args_b = json!({"list": [3, 2, 1]});
    assert_ne!(derive(&base_inputs(&args_a)), derive(&base_inputs(&args_b)));
}

// ---------------------------------------------------------------------------
// Per-step timing invariants
// ---------------------------------------------------------------------------

#[test]
fn stepwise_sample_round_trips_via_json() {
    let r = Report::sample_stepwise();
    let s = serde_json::to_string(&r).unwrap();
    let back: Report = serde_json::from_str(&s).unwrap();
    assert_eq!(r, back);
}

#[test]
fn stepwise_sample_has_three_steps_on_first_test() {
    let r = Report::sample_stepwise();
    assert_eq!(r.tests[0].steps.len(), 3);
    assert!(r.tests[0].steps.iter().any(|s| s.cache_hit));
    // Sum within 5ms of test total invariant.
    let sum: u64 = r.tests[0].steps.iter().map(|s| s.duration_ms).sum();
    let test_total = r.tests[0].duration_ms;
    let delta = sum.abs_diff(test_total);
    assert!(delta <= 5, "step sum {sum} vs test total {test_total}");
}

#[test]
fn single_step_test_has_empty_steps_vec() {
    let r = Report::sample();
    for test in &r.tests {
        assert!(test.steps.is_empty(), " sample has no stepwise tests");
    }
}

proptest! {
    /// Sum of step durations stays within 5ms of the test-level total.
    /// The 5ms gap is the framework overhead allowance:
    /// anything larger means the runner is mis-attributing
    /// work that ought to live under a step.
    #[test]
    fn step_durations_sum_within_5ms_of_test_total(
        step_durs in prop::collection::vec(0u64..2_000u64, 1..8),
        overhead in 0u64..5u64,
    ) {
        let test_total: u64 = step_durs.iter().sum::<u64>() + overhead;
        let steps: Vec<TestStep> = step_durs
            .iter()
            .enumerate()
            .map(|(i, d)| TestStep {
                name: format!("step_{i}"),
                started_at: None,
                ended_at: None,
                duration_ms: *d,
                cache_hit: false,
                consensus: None,
            })
            .collect();
        let test = TestResult {
            name: "stepwise".into(),
            status: TestStatus::Pass,
            duration_ms: test_total,
            cache_hit: false,
            started_at: None,
            ended_at: None,
            failure: None,
            file: None,
            line: None,
            steps,
            consensus: None,
            rule_id: None,
            severity: None,
            score: None,
            derived_metrics: Vec::new(),
        natural_language: false,
        };
        let sum: u64 = test.steps.iter().map(|s| s.duration_ms).sum();
        prop_assert!(test.duration_ms.abs_diff(sum) <= 5);
    }
}

// Runner-produced sanity check: timing instrumentation in
// `runner::step` populates `duration_ms` and the RFC 3339 fields the
// reporters render. The synthetic property test above pins the math;
// this test pins the contract between the runner helper and the
// `TestStep` shape so the two never drift.
#[test]
fn runner_step_helper_populates_test_step_timing_fields() {
    use mcptest_core::runner::time_step_sync;
    let step = time_step_sync("probe", false, || {
        std::thread::sleep(std::time::Duration::from_millis(10));
    });
    assert!(
        step.duration_ms >= 5,
        "expected >=5ms, got {}",
        step.duration_ms
    );
    assert!(step.started_at.is_some());
    assert!(step.ended_at.is_some());
    assert!(!step.cache_hit);

    let hit = time_step_sync("warm", true, || {});
    assert_eq!(hit.duration_ms, 1);
    assert!(hit.cache_hit);
}
