//! OSS governance policy simulator.
//!
//! This module turns saved mcptest artifacts (a run report, a judge
//! certification, a conformance score, a security scan, and so on) into a flat
//! map of named "facts", then evaluates a small declarative policy of rules and
//! expiring waivers against those facts. It exists so a team can gate a release
//! on the artifacts it already produces without standing up an external policy
//! service. It is deliberately tiny: comparators are `max`/`min`/`equals`/
//! `one_of` rather than a general expression DSL, because a governance gate
//! that nobody can read is a governance gate nobody trusts.

use std::collections::BTreeMap;

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

/// A fact value extracted from an artifact: a number, a boolean, or text.
///
/// Facts are the single currency the evaluator understands. Keeping the type
/// closed (three variants, no nesting) is what lets the comparators stay
/// trivial and the report stay readable.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub enum FactValue {
    /// A numeric fact, for example a count of failed tests.
    Num(f64),
    /// A boolean fact, for example whether a judge is certified.
    Bool(bool),
    /// A textual fact, for example a conformance badge such as `T1`.
    Text(String),
}

impl FactValue {
    /// The fact as a number, when it is numeric. Used by `max`/`min` rules.
    pub fn as_num(&self) -> Option<f64> {
        match self {
            FactValue::Num(n) => Some(*n),
            _ => None,
        }
    }

    /// The fact as a boolean, when it is one. Used by `equals: true|false`.
    pub fn as_bool(&self) -> Option<bool> {
        match self {
            FactValue::Bool(b) => Some(*b),
            _ => None,
        }
    }

    /// The fact as text, when it is textual. Used by `one_of` and string
    /// `equals`.
    pub fn as_text(&self) -> Option<&str> {
        match self {
            FactValue::Text(s) => Some(s.as_str()),
            _ => None,
        }
    }

    /// Render the fact for a report line. Numbers print without a trailing
    /// `.0` when integral so `run.failed = 2` reads naturally.
    fn render(&self) -> String {
        match self {
            FactValue::Num(n) => {
                if n.fract() == 0.0 {
                    format!("{}", *n as i64)
                } else {
                    format!("{n}")
                }
            }
            FactValue::Bool(b) => b.to_string(),
            FactValue::Text(s) => s.clone(),
        }
    }
}

/// The flat fact map an evaluation runs against. Keyed by dotted fact name
/// (for example `run.failed`) so rules cite a stable identifier.
pub type Facts = BTreeMap<String, FactValue>;

/// Severity of a policy rule: a failed `fail` rule fails the gate; a failed
/// `warn` rule only warns.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleSeverity {
    /// A failing rule fails the overall verdict (the default).
    #[default]
    Fail,
    /// A failing rule only warns and never fails the gate.
    Warn,
}

/// One declarative policy rule: name a fact and one comparator. Exactly one of
/// `max`/`min`/`equals`/`one_of` must be set.
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyRule {
    /// Stable rule identifier, cited in the report and matched by waivers.
    pub id: String,
    /// Optional human description shown when a rule is authored verbosely.
    #[serde(default)]
    pub description: Option<String>,
    /// The fact name this rule constrains, for example `run.failed`.
    pub fact: String,
    /// Upper bound: the fact (a number) must be `<= max`.
    #[serde(default)]
    pub max: Option<f64>,
    /// Lower bound: the fact (a number) must be `>= min`.
    #[serde(default)]
    pub min: Option<f64>,
    /// Exact match against a JSON literal (bool, number, or string).
    #[serde(default)]
    pub equals: Option<Value>,
    /// Membership: the fact (rendered to text) must be one of these.
    #[serde(default)]
    pub one_of: Option<Vec<String>>,
    /// Whether a failure fails the gate or only warns.
    #[serde(default)]
    pub severity: RuleSeverity,
}

/// A waiver suppressing one rule's failure until it expires. An expired waiver
/// does not suppress (fail closed) and is itself reported.
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyWaiver {
    /// The rule id this waiver suppresses.
    pub rule: String,
    /// Who owns the waiver, so reviewers know whom to ask.
    pub owner: String,
    /// Why the failure is tolerated, captured for the audit trail.
    pub reason: String,
    /// RFC 3339 UTC expiry (for example `2026-12-31T00:00:00Z`).
    pub expiry: String,
    /// Optional tracking reference (for example a GitHub issue id).
    #[serde(default)]
    pub issue: Option<String>,
}

/// The parsed policy file.
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyFile {
    /// Schema version of the policy document, reserved for future migrations.
    pub version: String,
    /// The rules evaluated against the facts.
    pub rules: Vec<PolicyRule>,
    /// Waivers that may suppress specific rule failures until they expire.
    #[serde(default)]
    pub waivers: Vec<PolicyWaiver>,
}

/// Per-rule status after evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RuleStatus {
    /// The rule's comparator was satisfied.
    Pass,
    /// The rule failed and no waiver applied (fail severity).
    Fail,
    /// The rule failed and no waiver applied (warn severity).
    Warn,
    /// Failed a `fail` rule but an active waiver suppressed it.
    Waived,
    /// Failed and the waiver that would suppress it has expired (fail closed).
    ExpiredWaiver,
    /// The fact the rule references was not available (artifact not provided or
    /// malformed). Treated as a failure so a missing input never silently passes.
    Unevaluated,
}

/// One rule's evaluated outcome, the cited evidence row.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RuleOutcome {
    /// The rule id this row reports on.
    pub id: String,
    /// The fact the rule referenced.
    pub fact: String,
    /// The resolved status for this rule.
    pub status: RuleStatus,
    /// The observed fact value rendered for the report, when available.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub observed: Option<FactValue>,
    /// One-line human explanation.
    pub detail: String,
}

/// Overall verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
    /// Every rule passed (or was waived).
    Pass,
    /// No fail-severity rule failed, but at least one warn rule did.
    Warn,
    /// At least one fail-severity rule failed, expired its waiver, or could
    /// not be evaluated.
    Fail,
}

/// The full simulation result.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PolicyOutcome {
    /// The overall verdict the gate keys off.
    pub verdict: Verdict,
    /// Per-rule outcomes, in policy order, as cited evidence.
    pub rules: Vec<RuleOutcome>,
}

impl PolicyOutcome {
    /// True when the overall verdict is `Fail` (any fail-severity rule failed,
    /// hit an expired waiver, or could not be evaluated).
    pub fn failed(&self) -> bool {
        self.verdict == Verdict::Fail
    }
}

/// Evaluate a policy against a fact map at a given wall-clock instant.
///
/// `now_epoch` is Unix epoch seconds, supplied by the caller so the evaluation
/// stays pure and tests can pin time. Each rule is resolved independently, then
/// the overall verdict is the worst per-rule status: any fail / expired-waiver
/// / unevaluated rule makes the verdict `Fail`, otherwise any warn makes it
/// `Warn`, otherwise `Pass`. Waived and passing rules never fail the gate.
pub fn evaluate(policy: &PolicyFile, facts: &Facts, now_epoch: i64) -> PolicyOutcome {
    let mut rules = Vec::with_capacity(policy.rules.len());
    for rule in &policy.rules {
        rules.push(evaluate_rule(rule, facts, &policy.waivers, now_epoch));
    }

    let verdict = if rules.iter().any(|r| {
        matches!(
            r.status,
            RuleStatus::Fail | RuleStatus::ExpiredWaiver | RuleStatus::Unevaluated
        )
    }) {
        Verdict::Fail
    } else if rules.iter().any(|r| r.status == RuleStatus::Warn) {
        Verdict::Warn
    } else {
        Verdict::Pass
    };

    PolicyOutcome { verdict, rules }
}

/// Resolve a single rule against the facts and waivers. Split out of
/// [`evaluate`] to keep that function short and to keep all the per-rule
/// branching in one place.
fn evaluate_rule(
    rule: &PolicyRule,
    facts: &Facts,
    waivers: &[PolicyWaiver],
    now_epoch: i64,
) -> RuleOutcome {
    let comparator_count = rule.max.is_some() as u8
        + rule.min.is_some() as u8
        + rule.equals.is_some() as u8
        + rule.one_of.is_some() as u8;
    if comparator_count != 1 {
        return RuleOutcome {
            id: rule.id.clone(),
            fact: rule.fact.clone(),
            status: RuleStatus::Unevaluated,
            observed: None,
            detail: format!("rule {} must set exactly one comparator", rule.id),
        };
    }

    let Some(fact) = facts.get(&rule.fact) else {
        return RuleOutcome {
            id: rule.id.clone(),
            fact: rule.fact.clone(),
            status: RuleStatus::Unevaluated,
            observed: None,
            detail: format!(
                "fact `{}` not available (artifact not provided?)",
                rule.fact
            ),
        };
    };

    let check = check_comparator(rule, fact);
    match check {
        ComparatorResult::Unevaluated(detail) => RuleOutcome {
            id: rule.id.clone(),
            fact: rule.fact.clone(),
            status: RuleStatus::Unevaluated,
            observed: Some(fact.clone()),
            detail,
        },
        ComparatorResult::Passed(detail) => RuleOutcome {
            id: rule.id.clone(),
            fact: rule.fact.clone(),
            status: RuleStatus::Pass,
            observed: Some(fact.clone()),
            detail,
        },
        ComparatorResult::Failed(detail) => resolve_failure(rule, fact, waivers, now_epoch, detail),
    }
}

/// Decide the status of a rule that failed its comparator: an active waiver
/// downgrades it to `Waived`, an expired or unparseable waiver fails closed as
/// `ExpiredWaiver`, and no waiver yields the rule's own severity.
fn resolve_failure(
    rule: &PolicyRule,
    fact: &FactValue,
    waivers: &[PolicyWaiver],
    now_epoch: i64,
    fail_detail: String,
) -> RuleOutcome {
    if let Some(waiver) = waivers.iter().find(|w| w.rule == rule.id) {
        let expiry = rfc3339_to_epoch(&waiver.expiry);
        let active = expiry.map(|e| now_epoch <= e).unwrap_or(false);
        if active {
            let issue = waiver
                .issue
                .as_ref()
                .map(|i| format!(" ({i})"))
                .unwrap_or_default();
            let until = waiver.expiry.get(0..10).unwrap_or(waiver.expiry.as_str());
            return RuleOutcome {
                id: rule.id.clone(),
                fact: rule.fact.clone(),
                status: RuleStatus::Waived,
                observed: Some(fact.clone()),
                detail: format!("waived by {} until {until}{issue}", waiver.owner),
            };
        }
        let until = waiver.expiry.get(0..10).unwrap_or(waiver.expiry.as_str());
        return RuleOutcome {
            id: rule.id.clone(),
            fact: rule.fact.clone(),
            status: RuleStatus::ExpiredWaiver,
            observed: Some(fact.clone()),
            detail: format!("waiver for {} expired {until}", rule.id),
        };
    }

    let status = if rule.severity == RuleSeverity::Fail {
        RuleStatus::Fail
    } else {
        RuleStatus::Warn
    };
    RuleOutcome {
        id: rule.id.clone(),
        fact: rule.fact.clone(),
        status,
        observed: Some(fact.clone()),
        detail: fail_detail,
    }
}

/// The raw verdict of applying a rule's single comparator to a fact, before
/// waivers or severity are considered.
enum ComparatorResult {
    /// The comparator held; detail is a human description of why.
    Passed(String),
    /// The comparator did not hold; detail explains the breach.
    Failed(String),
    /// The fact's type did not match the comparator (for example a `max` rule
    /// over a textual fact); detail explains the mismatch.
    Unevaluated(String),
}

/// Apply the one set comparator on `rule` to `fact`. The caller has already
/// verified exactly one comparator is set.
fn check_comparator(rule: &PolicyRule, fact: &FactValue) -> ComparatorResult {
    if let Some(max) = rule.max {
        return match fact.as_num() {
            Some(n) if n <= max => {
                ComparatorResult::Passed(format!("{} = {} within max {max}", rule.fact, n))
            }
            Some(n) => ComparatorResult::Failed(format!("{} = {} exceeds max {max}", rule.fact, n)),
            None => ComparatorResult::Unevaluated(format!(
                "{} is not numeric, cannot apply max",
                rule.fact
            )),
        };
    }
    if let Some(min) = rule.min {
        return match fact.as_num() {
            Some(n) if n >= min => {
                ComparatorResult::Passed(format!("{} = {} at least min {min}", rule.fact, n))
            }
            Some(n) => ComparatorResult::Failed(format!("{} = {} below min {min}", rule.fact, n)),
            None => ComparatorResult::Unevaluated(format!(
                "{} is not numeric, cannot apply min",
                rule.fact
            )),
        };
    }
    if let Some(expected) = &rule.equals {
        return check_equals(rule, fact, expected);
    }
    if let Some(allowed) = &rule.one_of {
        let rendered = fact.render();
        return if allowed.iter().any(|v| v == &rendered) {
            ComparatorResult::Passed(format!("{} = {rendered} is allowed", rule.fact))
        } else {
            ComparatorResult::Failed(format!(
                "{} = {rendered} not one of [{}]",
                rule.fact,
                allowed.join(", ")
            ))
        };
    }
    // Unreachable: the caller guarantees one comparator is set.
    ComparatorResult::Unevaluated(format!("rule {} has no comparator", rule.id))
}

/// Compare a fact against an `equals` JSON literal, choosing the comparison by
/// the literal's JSON type so `equals: true` and `equals: "T1"` both work.
fn check_equals(rule: &PolicyRule, fact: &FactValue, expected: &Value) -> ComparatorResult {
    match expected {
        Value::Bool(b) => match fact.as_bool() {
            Some(actual) if actual == *b => {
                ComparatorResult::Passed(format!("{} = {actual}, as expected", rule.fact))
            }
            Some(actual) => {
                ComparatorResult::Failed(format!("{} = {actual}, expected {b}", rule.fact))
            }
            None => ComparatorResult::Unevaluated(format!(
                "{} is not boolean, cannot compare to {b}",
                rule.fact
            )),
        },
        Value::Number(num) => {
            let want = num.as_f64();
            match (fact.as_num(), want) {
                (Some(actual), Some(want)) if actual == want => {
                    ComparatorResult::Passed(format!("{} = {actual}, as expected", rule.fact))
                }
                (Some(actual), Some(want)) => {
                    ComparatorResult::Failed(format!("{} = {actual}, expected {want}", rule.fact))
                }
                _ => ComparatorResult::Unevaluated(format!(
                    "{} is not numeric, cannot compare",
                    rule.fact
                )),
            }
        }
        Value::String(s) => match fact.as_text() {
            Some(actual) if actual == s => {
                ComparatorResult::Passed(format!("{} = {actual}, as expected", rule.fact))
            }
            Some(actual) => {
                ComparatorResult::Failed(format!("{} = {actual}, expected {s}", rule.fact))
            }
            None => ComparatorResult::Unevaluated(format!(
                "{} is not text, cannot compare to {s}",
                rule.fact
            )),
        },
        _ => ComparatorResult::Unevaluated(format!(
            "rule {} equals must be a bool, number, or string",
            rule.id
        )),
    }
}

/// Parse the `YYYY-MM-DDTHH:MM:SS` prefix of an RFC 3339 timestamp as UTC into
/// Unix epoch seconds.
///
/// Dependency-free on purpose so the simulator pulls no date crate. Fractional
/// seconds and timezone offsets are ignored, and inputs shorter than 19
/// characters return `None`. Exposed as `pub` so the CLI handler and tests can
/// reuse the exact same parse the evaluator uses for waiver expiry.
pub fn rfc3339_to_epoch(ts: &str) -> Option<i64> {
    if ts.len() < 19 {
        return None;
    }
    let year: i64 = ts.get(0..4)?.parse().ok()?;
    let month: i64 = ts.get(5..7)?.parse().ok()?;
    let day: i64 = ts.get(8..10)?.parse().ok()?;
    let hour: i64 = ts.get(11..13)?.parse().ok()?;
    let min: i64 = ts.get(14..16)?.parse().ok()?;
    let sec: i64 = ts.get(17..19)?.parse().ok()?;
    // days_from_civil (Howard Hinnant)
    let y = if month <= 2 { year - 1 } else { year };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe - 719_468;
    Some(days * 86_400 + hour * 3_600 + min * 60 + sec)
}

/// Read defensively into the facts map: insert a numeric fact from a JSON
/// `u64` field only when present, never panicking on a missing or wrong-typed
/// field.
fn insert_u64(facts: &mut Facts, key: &str, field: Option<u64>) {
    if let Some(n) = field {
        facts.insert(key.to_string(), FactValue::Num(n as f64));
    }
}

/// Extract facts from a `mcptest run --reporter json` report.
///
/// Reads the `summary` object for the run tallies. Each field is optional so a
/// trimmed report (for example one without `inconclusive`) simply yields fewer
/// facts rather than an error.
pub fn facts_from_run_report(v: &Value) -> Facts {
    let mut facts = Facts::new();
    let summary = &v["summary"];
    insert_u64(&mut facts, "run.total", summary["total"].as_u64());
    insert_u64(&mut facts, "run.passed", summary["passed"].as_u64());
    insert_u64(&mut facts, "run.failed", summary["failed"].as_u64());
    insert_u64(&mut facts, "run.skipped", summary["skipped"].as_u64());
    insert_u64(
        &mut facts,
        "run.inconclusive",
        summary["inconclusive"].as_u64(),
    );
    facts
}

/// Extract facts from a `mcptest judge certify` certification record.
///
/// `now_epoch` lets the extractor compute `judge.expired` from the
/// certification's validity window so a stale certification can gate without
/// the policy author hardcoding a date. The expiry fact is only emitted when
/// the record carries a validity window.
pub fn facts_from_certification(v: &Value, now_epoch: i64) -> Facts {
    let mut facts = Facts::new();
    if let Some(certified) = v["certified"].as_bool() {
        facts.insert("judge.certified".to_string(), FactValue::Bool(certified));
    }
    if let Some(ece) = v["metrics"]["ece"].as_f64() {
        facts.insert("judge.ece".to_string(), FactValue::Num(ece));
    }
    if let Some(brier) = v["metrics"]["brier"].as_f64() {
        facts.insert("judge.brier".to_string(), FactValue::Num(brier));
    }
    if let Some(valid_until) = v["validity_window"]["valid_until"].as_str() {
        if let Some(epoch) = rfc3339_to_epoch(valid_until) {
            facts.insert(
                "judge.expired".to_string(),
                FactValue::Bool(now_epoch > epoch),
            );
        }
    }
    facts
}

/// Extract facts from a `mcptest conformance run` report.
///
/// The badge and per-tier tallies let a policy gate on, say, "tier 1 or 2" or
/// "every MUST passed". The tier is read flexibly because some report shapes
/// nest it under an object and some expose it as a bare string.
pub fn facts_from_conformance(v: &Value) -> Facts {
    let mut facts = Facts::new();
    if let Some(badge) = v["badge"].as_str() {
        facts.insert(
            "conformance.badge".to_string(),
            FactValue::Text(badge.to_string()),
        );
    }
    insert_u64(
        &mut facts,
        "conformance.must_passed",
        v["must"]["passed"].as_u64(),
    );
    insert_u64(
        &mut facts,
        "conformance.must_total",
        v["must"]["total"].as_u64(),
    );
    insert_u64(
        &mut facts,
        "conformance.should_passed",
        v["should"]["passed"].as_u64(),
    );
    insert_u64(
        &mut facts,
        "conformance.should_total",
        v["should"]["total"].as_u64(),
    );
    let tier = v["tier"]
        .as_str()
        .or_else(|| v["tier"]["tier"].as_str())
        .map(|s| s.to_string());
    if let Some(tier) = tier {
        facts.insert("conformance.tier".to_string(), FactValue::Text(tier));
    }
    facts
}

/// Extract facts from a `mcptest security` report.
///
/// Tallies findings by severity so a policy can gate on, for example,
/// "no critical findings". The five severity counts are always emitted (zero
/// when absent) so a `max: 0` rule never lands as `Unevaluated` just because a
/// clean scan had no findings of that severity.
pub fn facts_from_security(v: &Value) -> Facts {
    let mut facts = Facts::new();
    let findings = v["findings"]
        .as_array()
        .or_else(|| v.as_array())
        .cloned()
        .unwrap_or_default();
    let mut critical = 0u64;
    let mut high = 0u64;
    let mut medium = 0u64;
    let mut low = 0u64;
    let mut info = 0u64;
    for finding in &findings {
        match finding["severity"].as_str() {
            Some("critical") => critical += 1,
            Some("high") => high += 1,
            Some("medium") => medium += 1,
            Some("low") => low += 1,
            Some("info") => info += 1,
            _ => {}
        }
    }
    facts.insert(
        "security.critical_count".to_string(),
        FactValue::Num(critical as f64),
    );
    facts.insert(
        "security.high_count".to_string(),
        FactValue::Num(high as f64),
    );
    facts.insert(
        "security.medium_count".to_string(),
        FactValue::Num(medium as f64),
    );
    facts.insert("security.low_count".to_string(), FactValue::Num(low as f64));
    facts.insert(
        "security.info_count".to_string(),
        FactValue::Num(info as f64),
    );
    facts.insert(
        "security.total_findings".to_string(),
        FactValue::Num(findings.len() as f64),
    );
    facts
}

/// Extract facts from a `mcptest model-compat diff` report.
///
/// The per-bucket tallies let a policy gate on model drift, for example
/// "no model regressed to fail".
pub fn facts_from_model_compat(v: &Value) -> Facts {
    let mut facts = Facts::new();
    let summary = &v["summary"];
    insert_u64(&mut facts, "model_compat.total", summary["total"].as_u64());
    insert_u64(&mut facts, "model_compat.pass", summary["pass"].as_u64());
    insert_u64(&mut facts, "model_compat.drift", summary["drift"].as_u64());
    insert_u64(&mut facts, "model_compat.fail", summary["fail"].as_u64());
    facts
}

/// Extract facts from an evidence artifact.
///
/// Surfaces the reproducibility and origin flags so a policy can require a
/// reproducible, verifiable-origin run before a release.
pub fn facts_from_evidence(v: &Value) -> Facts {
    let mut facts = Facts::new();
    if let Some(reproducible) = v["reproducible"].as_bool() {
        facts.insert(
            "evidence.reproducible".to_string(),
            FactValue::Bool(reproducible),
        );
    }
    if let Some(unverifiable) = v["unverifiable_origin"].as_bool() {
        facts.insert(
            "evidence.unverifiable_origin".to_string(),
            FactValue::Bool(unverifiable),
        );
    }
    facts
}

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

    fn policy_from(yaml: &str) -> PolicyFile {
        serde_yaml::from_str(yaml).expect("policy fixture parses")
    }

    #[test]
    fn a_satisfied_max_rule_passes() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: no-fail\n    fact: run.failed\n    max: 0\n",
        );
        let facts = facts_from_run_report(&json!({"summary": {"failed": 0}}));
        let outcome = evaluate(&policy, &facts, 0);
        assert_eq!(outcome.verdict, Verdict::Pass);
        assert_eq!(outcome.rules[0].status, RuleStatus::Pass);
    }

    #[test]
    fn an_exceeded_max_rule_fails_the_verdict() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: no-fail\n    fact: run.failed\n    max: 0\n",
        );
        let facts = facts_from_run_report(&json!({"summary": {"failed": 2}}));
        let outcome = evaluate(&policy, &facts, 0);
        assert_eq!(outcome.verdict, Verdict::Fail);
        assert_eq!(outcome.rules[0].status, RuleStatus::Fail);
        assert!(outcome.rules[0].detail.contains("exceeds max 0"));
    }

    #[test]
    fn equals_true_passes_and_false_fails() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: certified\n    fact: judge.certified\n    equals: true\n",
        );
        let pass = facts_from_certification(&json!({"certified": true}), 0);
        assert_eq!(evaluate(&policy, &pass, 0).verdict, Verdict::Pass);
        let fail = facts_from_certification(&json!({"certified": false}), 0);
        let outcome = evaluate(&policy, &fail, 0);
        assert_eq!(outcome.verdict, Verdict::Fail);
        assert!(outcome.rules[0].detail.contains("expected true"));
    }

    #[test]
    fn judge_expired_is_computed_from_validity_window() {
        let cert = json!({
            "certified": true,
            "validity_window": {"valid_until": "2023-11-14T22:13:20Z"}
        });
        // now after expiry -> expired true.
        let after = facts_from_certification(&cert, 1_700_000_001);
        assert_eq!(after.get("judge.expired"), Some(&FactValue::Bool(true)));
        // now before expiry -> expired false.
        let before = facts_from_certification(&cert, 1_600_000_000);
        assert_eq!(before.get("judge.expired"), Some(&FactValue::Bool(false)));
    }

    #[test]
    fn an_active_waiver_suppresses_a_failure() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: no-fail\n    fact: run.failed\n    max: 0\nwaivers:\n  - rule: no-fail\n    owner: alice\n    reason: known\n    expiry: \"2099-01-01T00:00:00Z\"\n    issue: GH-123\n",
        );
        let facts = facts_from_run_report(&json!({"summary": {"failed": 2}}));
        let outcome = evaluate(&policy, &facts, 1_700_000_000);
        assert_eq!(outcome.rules[0].status, RuleStatus::Waived);
        assert_eq!(outcome.verdict, Verdict::Pass);
        assert!(outcome.rules[0].detail.contains("alice"));
        assert!(outcome.rules[0].detail.contains("GH-123"));
    }

    #[test]
    fn an_expired_waiver_does_not_suppress_and_fails_closed() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: no-fail\n    fact: run.failed\n    max: 0\nwaivers:\n  - rule: no-fail\n    owner: alice\n    reason: known\n    expiry: \"2000-01-01T00:00:00Z\"\n",
        );
        let facts = facts_from_run_report(&json!({"summary": {"failed": 2}}));
        let outcome = evaluate(&policy, &facts, 1_700_000_000);
        assert_eq!(outcome.rules[0].status, RuleStatus::ExpiredWaiver);
        assert_eq!(outcome.verdict, Verdict::Fail);
        assert!(outcome.rules[0].detail.contains("expired"));
    }

    #[test]
    fn a_missing_fact_is_unevaluated_and_fails() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: no-fail\n    fact: run.failed\n    max: 0\n",
        );
        let facts = Facts::new();
        let outcome = evaluate(&policy, &facts, 0);
        assert_eq!(outcome.rules[0].status, RuleStatus::Unevaluated);
        assert_eq!(outcome.verdict, Verdict::Fail);
        assert!(outcome.rules[0].detail.contains("not available"));
    }

    #[test]
    fn a_warn_severity_failure_warns_but_does_not_fail() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: tier\n    fact: conformance.badge\n    one_of: [T1, T2]\n    severity: warn\n",
        );
        let facts = facts_from_conformance(&json!({"badge": "T3"}));
        let outcome = evaluate(&policy, &facts, 0);
        assert_eq!(outcome.rules[0].status, RuleStatus::Warn);
        assert_eq!(outcome.verdict, Verdict::Warn);
    }

    #[test]
    fn one_of_on_conformance_badge_matches_membership() {
        let policy = policy_from(
            "version: \"1.0\"\nrules:\n  - id: tier\n    fact: conformance.badge\n    one_of: [T1, T2]\n",
        );
        let pass = facts_from_conformance(&json!({"badge": "T1"}));
        assert_eq!(evaluate(&policy, &pass, 0).verdict, Verdict::Pass);
        let fail = facts_from_conformance(&json!({"badge": "F"}));
        assert_eq!(evaluate(&policy, &fail, 0).verdict, Verdict::Fail);
    }

    #[test]
    fn security_counts_tally_from_a_findings_array() {
        let report = json!({
            "findings": [
                {"severity": "critical"},
                {"severity": "high"},
                {"severity": "high"},
                {"severity": "low"}
            ]
        });
        let facts = facts_from_security(&report);
        assert_eq!(
            facts.get("security.critical_count"),
            Some(&FactValue::Num(1.0))
        );
        assert_eq!(facts.get("security.high_count"), Some(&FactValue::Num(2.0)));
        assert_eq!(
            facts.get("security.total_findings"),
            Some(&FactValue::Num(4.0))
        );
        // A clean severity still emits a zero count.
        assert_eq!(
            facts.get("security.medium_count"),
            Some(&FactValue::Num(0.0))
        );
    }

    #[test]
    fn rfc3339_parses_known_epochs() {
        assert_eq!(rfc3339_to_epoch("1970-01-01T00:00:00Z"), Some(0));
        assert_eq!(
            rfc3339_to_epoch("2023-11-14T22:13:20Z"),
            Some(1_700_000_000)
        );
        assert_eq!(rfc3339_to_epoch("short"), None);
    }
}
