//! The compliance `grade_delta:` check block.
//!
//! Compares the current compliance run's scorecard against a saved prior one
//! and exposes the comparison as assertable targets: `grade` (the current
//! letter grade), `score` (the current weighted percentage), `grade_regressed`
//! (true when the grade fell), and `regressed_sections` (the section ids that
//! got worse). The comparison reuses [`mod@crate::compliance::score_delta`].
//!
//! The prior scorecard is a serialized [`ComplianceScore`] saved from an
//! earlier run and committed to the repo. A first run with no prior file
//! passes with a "no baseline" note, so adding the gate never fails the build
//! on the day it lands.

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

use serde_json::{json, Value};
use thiserror::Error;

use crate::compliance::score_delta::{score_delta, Direction};
use crate::compliance::scoring::ComplianceScore;
use crate::suite::Assertion;

/// One parsed compliance `grade_delta:` block.
#[derive(Debug, Clone, PartialEq)]
pub struct GradeDelta {
    /// Path to the saved prior scorecard (a serialized [`ComplianceScore`]),
    /// resolved relative to the suite file. `None` means no baseline yet, in
    /// which case the check passes with a bootstrap note.
    pub against: Option<PathBuf>,
    /// Assertions over the targets. Empty means "apply the defaults".
    pub expect: Vec<Assertion>,
}

/// Errors raised while loading a saved scorecard.
#[derive(Debug, Error)]
pub enum GradeDeltaError {
    /// The scorecard file could not be read from disk.
    #[error("grade_delta: could not read scorecard `{path}`: {source}")]
    Read {
        /// Path that could not be read.
        path: String,
        /// Underlying I/O error.
        source: std::io::Error,
    },
    /// The scorecard file was read but did not parse as a serialized
    /// `ComplianceScore`.
    #[error("grade_delta: scorecard `{path}` is not a valid saved scorecard: {detail}")]
    Parse {
        /// Path that failed to parse.
        path: String,
        /// One-line parse-failure summary.
        detail: String,
    },
}

/// Load a saved scorecard (a serialized [`ComplianceScore`]) from disk.
///
/// A read or parse failure is a load error so a typo in the path, or a
/// scorecard written by an incompatible version, surfaces before the gate
/// rather than silently passing.
pub fn load_scorecard(path: &Path) -> Result<ComplianceScore, GradeDeltaError> {
    let raw = std::fs::read_to_string(path).map_err(|source| GradeDeltaError::Read {
        path: path.display().to_string(),
        source,
    })?;
    serde_json::from_str(&raw).map_err(|e| GradeDeltaError::Parse {
        path: path.display().to_string(),
        detail: e.to_string(),
    })
}

/// Build the targets object the `grade_delta:` assertions resolve against.
///
/// Shape: `{ "grade": "A", "score": 95.0, "grade_regressed": <bool>,
/// "regressed_sections": ["tools", ...] }`. The section ids are the
/// lowercase registry strings (`lifecycle`, `tools`, ...) so a reader can
/// match them to the rubric.
pub fn metrics_object(baseline: &ComplianceScore, current: &ComplianceScore) -> Value {
    let delta = score_delta(baseline, current);
    let regressed: Vec<&str> = delta
        .sections
        .iter()
        .filter(|s| s.direction == Direction::Regressed)
        .map(|s| s.section.as_str())
        .collect();
    json!({
        "grade": current.grade.as_str(),
        "score": current.numeric_score,
        "grade_regressed": delta.grade_fell(),
        "regressed_sections": regressed,
    })
}

/// The default assertion set applied when a `grade_delta:` block declares no
/// `expect:`: `grade_regressed == false` and `regressed_sections`
/// empty.
pub fn default_expect() -> Vec<Assertion> {
    use crate::suite::Matcher;
    vec![
        Assertion {
            target: "grade_regressed".to_string(),
            matcher: Matcher::Exact(Value::Bool(false)),
            weight: 1.0,
            name: None,
            transform: None,
        },
        Assertion {
            target: "regressed_sections".to_string(),
            matcher: Matcher::Schema(json!({ "maxItems": 0 })),
            weight: 1.0,
            name: None,
            transform: None,
        },
    ]
}

/// Evaluate a `grade_delta:` block against the current scorecard.
///
/// Resolves the prior scorecard path against `base_dir`, loads it, runs the
/// comparison, and applies the entry's `expect:` (or the defaults). A missing
/// `against:` path, or a path that does not exist yet, is the bootstrap case:
/// the check passes with a "no baseline" note rather than failing the build on
/// the day the gate lands. A present-but-unreadable scorecard is a load error.
pub fn evaluate(
    delta: &GradeDelta,
    current: &ComplianceScore,
    base_dir: &Path,
) -> Result<super::CheckOutcome, GradeDeltaError> {
    let Some(rel) = &delta.against else {
        return Ok(no_baseline_outcome());
    };
    let path = resolve_path(base_dir, rel);
    if !path.exists() {
        return Ok(no_baseline_outcome());
    }
    let baseline = load_scorecard(&path)?;
    let metrics = metrics_object(&baseline, current);

    let assertions: Vec<Assertion> = if delta.expect.is_empty() {
        default_expect()
    } else {
        delta.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
                ));
            }
        }
    }

    Ok(super::CheckOutcome {
        name: "compliance grade delta".to_string(),
        passed: failure.is_none(),
        failure,
    })
}

/// The bootstrap outcome: no prior scorecard, so the gate passes with a note
/// rather than failing on the day it lands.
fn no_baseline_outcome() -> super::CheckOutcome {
    super::CheckOutcome {
        name: "compliance grade delta".to_string(),
        passed: true,
        failure: Some(
            "no baseline: no prior scorecard to compare against (bootstrap pass)".to_string(),
        ),
    }
}

/// Resolve the scorecard path against the suite directory unless absolute.
fn resolve_path(base_dir: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        base_dir.join(path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compliance::official::{CheckResult, CheckSource};
    use crate::compliance::registry::{RuleMetadata, RuleRegistry, RuleSeverity};
    use crate::compliance::scoring::{score_run, Rubric};
    use crate::executor::resolve_target;
    use std::collections::BTreeSet;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn rule(id: &str, section: &str, severity: RuleSeverity) -> RuleMetadata {
        RuleMetadata {
            id: id.into(),
            name: format!("rule {id}"),
            category: id.split('-').next().unwrap_or("PROTO").into(),
            severity,
            applies_to: vec!["stdio".into()],
            spec_versions: vec!["v2024-11-05".into()],
            description: "test rule".into(),
            references: vec![],
            section: section.into(),
            applicability: "true".into(),
            spec_citation: "spec".into(),
        }
    }

    fn check(id: &str, passed: bool) -> CheckResult {
        CheckResult {
            rule_id: id.into(),
            passed,
            source: CheckSource::Local,
            message: None,
            duration_ms: 1,
        }
    }

    /// One MUST plus ten tools SHOULDs; `passes` of the ten pass.
    fn score_with_should_passes(passes: usize) -> ComplianceScore {
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = RuleRegistry { version: 1, rules };
        let caps = BTreeSet::from(["tools".to_string()]);
        let rubric = Rubric::new(&reg, caps);
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("TOOL-{i:03}"), i < passes));
        }
        score_run(&results, &rubric)
    }

    #[test]
    fn no_against_path_is_a_bootstrap_pass() {
        let current = score_with_should_passes(10);
        let delta = GradeDelta {
            against: None,
            expect: Vec::new(),
        };
        let outcome = evaluate(&delta, &current, Path::new("/")).expect("evaluates");
        assert!(outcome.passed);
        assert!(outcome.failure.unwrap().contains("no baseline"));
    }

    #[test]
    fn missing_against_file_is_a_bootstrap_pass() {
        let current = score_with_should_passes(10);
        let delta = GradeDelta {
            against: Some(PathBuf::from("does-not-exist.json")),
            expect: Vec::new(),
        };
        let outcome = evaluate(&delta, &current, Path::new("/tmp")).expect("evaluates");
        assert!(outcome.passed);
        assert!(outcome.failure.unwrap().contains("no baseline"));
    }

    #[test]
    fn metrics_object_exposes_all_four_targets() {
        let baseline = score_with_should_passes(10);
        let current = score_with_should_passes(5);
        let obj = metrics_object(&baseline, &current);
        assert!(resolve_target(&obj, "grade").is_string());
        assert!(resolve_target(&obj, "score").is_number());
        assert_eq!(resolve_target(&obj, "grade_regressed"), json!(true));
        let regressed = resolve_target(&obj, "regressed_sections");
        assert!(regressed.as_array().is_some());
    }

    fn write_scorecard(score: &ComplianceScore) -> NamedTempFile {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(serde_json::to_string(score).unwrap().as_bytes())
            .unwrap();
        f
    }

    #[test]
    fn a_steady_run_passes_the_defaults() {
        let score = score_with_should_passes(10);
        let f = write_scorecard(&score);
        let delta = GradeDelta {
            against: Some(f.path().to_path_buf()),
            expect: Vec::new(),
        };
        let outcome = evaluate(&delta, &score, Path::new("/")).expect("evaluates");
        assert!(outcome.passed, "{outcome:?}");
    }

    #[test]
    fn a_grade_regression_fails_the_defaults() {
        let baseline = score_with_should_passes(10); // A+
        let current = score_with_should_passes(5); // lower grade
        let f = write_scorecard(&baseline);
        let delta = GradeDelta {
            against: Some(f.path().to_path_buf()),
            expect: Vec::new(),
        };
        let outcome = evaluate(&delta, &current, Path::new("/")).expect("evaluates");
        assert!(!outcome.passed);
        let msg = outcome.failure.unwrap();
        assert!(msg.contains("grade_regressed") || msg.contains("regressed_sections"));
    }

    #[test]
    fn unreadable_scorecard_is_a_load_error() {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(b"not valid json").unwrap();
        let current = score_with_should_passes(10);
        let delta = GradeDelta {
            against: Some(f.path().to_path_buf()),
            expect: Vec::new(),
        };
        let err = evaluate(&delta, &current, Path::new("/")).expect_err("parse error");
        assert!(matches!(err, GradeDeltaError::Parse { .. }));
    }
}
