//! The compliance `spec_version_check:` block.
//!
//! Diffs the spec revision a compliance suite is pinned to against a target
//! revision and exposes the cross-version breakage as assertable targets:
//! `spec_clean` (true when no shared rule changed its expectation) and
//! `spec_breaking_changes` (the rule ids whose `expect` blocks differ). The
//! comparison reuses [`mod@crate::compliance::version_diff`].
//!
//! This is the spec-version sibling of the `grade_delta:` block: same shape (an
//! optional `against:` plus an optional `expect:`), same default-or-explicit
//! assertion flow, same one PASS/FAIL row folded into the compliance exit code.
//! The diff itself is computed by the caller (the CLI knows where the shipped
//! corpus lives); this module is the pure metrics-plus-assertion half, so it is
//! unit-testable without the corpus on disk.

use serde_json::{json, Value};

use crate::compliance::version_diff::{VersionChange, VersionDiff};
use crate::suite::Assertion;

/// One parsed compliance `spec_version_check:` block.
#[derive(Debug, Clone, PartialEq)]
pub struct SpecVersionCheck {
    /// Target spec revision to diff the suite's pin into (`v2025-06-18`,
    /// `draft`, ...). `None` means "no target named yet", in which case the
    /// check passes with a no-target note (the bootstrap case), so adding the
    /// gate never fails the build on the day it lands.
    pub against: Option<String>,
    /// Assertions over the targets. Empty means "apply the defaults".
    pub expect: Vec<Assertion>,
}

/// Build the targets object the `spec_version_check:` assertions resolve
/// against.
///
/// Shape: `{ "spec_clean": <bool>, "spec_breaking_changes": ["PROTO-002", ...]
/// }`. `spec_breaking_changes` is the list of rule ids whose `expect` blocks
/// changed between the two revisions, the upgrade-breakage signal; an empty
/// list is the clean case. Added and removed rules are expected when revisions
/// diverge and do not count as breakage.
pub fn metrics_object(diff: &VersionDiff) -> Value {
    let breaking: Vec<&str> = diff
        .entries
        .iter()
        .filter(|e| e.change == VersionChange::Changed)
        .map(|e| e.rule_id.as_str())
        .collect();
    json!({
        "spec_clean": diff.is_clean(),
        "spec_breaking_changes": breaking,
    })
}

/// The default assertion set applied when a `spec_version_check:` block
/// declares no `expect:`: `spec_clean == true` and
/// `spec_breaking_changes` empty. Either alone would do; both are asserted so a
/// reader sees the intent spelled out and a future target rename breaks loudly.
pub fn default_expect() -> Vec<Assertion> {
    use crate::suite::Matcher;
    vec![
        Assertion {
            target: "spec_clean".to_string(),
            matcher: Matcher::Exact(Value::Bool(true)),
            weight: 1.0,
            name: None,
            transform: None,
        },
        Assertion {
            target: "spec_breaking_changes".to_string(),
            matcher: Matcher::Schema(json!({ "maxItems": 0 })),
            weight: 1.0,
            name: None,
            transform: None,
        },
    ]
}

/// Evaluate a `spec_version_check:` block against an already-computed
/// cross-version diff.
///
/// The caller loads the two corpora (it knows where the shipped corpus lives)
/// and runs [`crate::compliance::version_diff::diff_versions`]; this folds the
/// result into a metrics object, applies the entry's `expect:` (or the
/// defaults), and returns a [`super::CheckOutcome`]. The no-target bootstrap
/// (the suite named no `against:` revision) is handled by the caller before it
/// has a diff to pass here.
pub fn evaluate(check: &SpecVersionCheck, diff: &VersionDiff) -> super::CheckOutcome {
    let metrics = metrics_object(diff);

    let assertions: Vec<Assertion> = if check.expect.is_empty() {
        default_expect()
    } else {
        check.expect.clone()
    };

    let mut failure: Option<String> = None;
    for (i, assertion) in assertions.iter().enumerate() {
        if let Err(reason) = crate::executor::eval_metric_assertion(&metrics, assertion) {
            if failure.is_none() {
                failure = Some(format!(
                    "assertion #{i} (`{}`) failed: {reason}",
                    assertion.target
                ));
            }
        }
    }

    super::CheckOutcome {
        name: format!("spec_version diff {} -> {}", diff.from, diff.to),
        passed: failure.is_none(),
        failure,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compliance::version_diff::{VersionDiffEntry, VersionDiffSummary};
    use crate::executor::resolve_target;

    fn diff(from: &str, to: &str, entries: Vec<VersionDiffEntry>) -> VersionDiff {
        let mut summary = VersionDiffSummary::default();
        for e in &entries {
            match e.change {
                VersionChange::Unchanged => summary.unchanged += 1,
                VersionChange::Changed => summary.changed += 1,
                VersionChange::Added => summary.added += 1,
                VersionChange::Removed => summary.removed += 1,
            }
        }
        VersionDiff {
            from: from.to_string(),
            to: to.to_string(),
            entries,
            summary,
        }
    }

    fn entry(rule_id: &str, change: VersionChange) -> VersionDiffEntry {
        VersionDiffEntry {
            rule_id: rule_id.to_string(),
            change,
            name: format!("{rule_id} name"),
        }
    }

    fn check(expect: Vec<Assertion>) -> SpecVersionCheck {
        SpecVersionCheck {
            against: Some("v2025-06-18".to_string()),
            expect,
        }
    }

    #[test]
    fn metrics_object_exposes_clean_and_breaking_targets() {
        let d = diff(
            "v2024-11-05",
            "v2025-06-18",
            vec![
                entry("PROTO-002", VersionChange::Changed),
                entry("ELICIT-001", VersionChange::Added),
                entry("PROTO-008", VersionChange::Unchanged),
            ],
        );
        let obj = metrics_object(&d);
        assert_eq!(resolve_target(&obj, "spec_clean"), json!(false));
        let breaking = resolve_target(&obj, "spec_breaking_changes");
        // Only the changed rule lands in the breaking list; added/unchanged do not.
        assert_eq!(breaking, json!(["PROTO-002"]));
    }

    #[test]
    fn a_clean_diff_passes_the_defaults() {
        let d = diff(
            "v2024-11-05",
            "draft",
            vec![
                entry("PROTO-008", VersionChange::Unchanged),
                entry("ELICIT-001", VersionChange::Added),
            ],
        );
        let outcome = evaluate(&check(Vec::new()), &d);
        assert!(outcome.passed, "{outcome:?}");
    }

    #[test]
    fn a_breaking_diff_fails_the_defaults() {
        let d = diff(
            "v2024-11-05",
            "v2025-06-18",
            vec![entry("PROTO-002", VersionChange::Changed)],
        );
        let outcome = evaluate(&check(Vec::new()), &d);
        assert!(!outcome.passed);
        let msg = outcome.failure.unwrap();
        assert!(msg.contains("spec_clean") || msg.contains("spec_breaking_changes"));
    }

    #[test]
    fn an_explicit_expect_replaces_the_defaults() {
        // A user who tolerates up to one breaking change writes their own gate.
        let d = diff(
            "v2024-11-05",
            "v2025-06-18",
            vec![entry("PROTO-002", VersionChange::Changed)],
        );
        let expect = vec![Assertion {
            target: "spec_breaking_changes".to_string(),
            matcher: crate::suite::Matcher::Schema(json!({ "maxItems": 1 })),
            weight: 1.0,
            name: None,
            transform: None,
        }];
        let outcome = evaluate(&check(expect), &d);
        assert!(outcome.passed, "{outcome:?}");
    }
}
