//! Accepted-finding waivers: a way to acknowledge a known security or config
//! finding so it stops failing CI, without raising the global `--fail-on`
//! threshold and hiding everything beneath it.
//!
//! A waiver file (`.mcptest-waivers.yaml`) lists accepted findings by rule id
//! and optional target, each with a required reason and an optional expiry.
//! [`partition`] splits a finding set into the ones that still count and the
//! ones a live waiver suppresses, and reports expired waivers (the finding
//! re-activates) and stale waivers (matched nothing) so a baseline cannot rot.
//!
//! The same machinery applies to the runtime security report and to the static
//! config scan, so the [`Waivable`] trait abstracts over both finding shapes.

use std::time::{SystemTime, UNIX_EPOCH};

use serde::Deserialize;

/// A civil (Gregorian) UTC date. Used for waiver expiry without pulling in a
/// date crate; comparison is chronological.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CivilDate {
    /// Full year, for example 2026.
    pub year: i32,
    /// Month, 1 to 12.
    pub month: u32,
    /// Day of month, 1 to 31.
    pub day: u32,
}

impl CivilDate {
    /// Parse a `YYYY-MM-DD` string. Returns `None` for any other shape.
    pub fn parse(s: &str) -> Option<CivilDate> {
        let mut parts = s.trim().split('-');
        let year = parts.next()?.parse::<i32>().ok()?;
        let month = parts.next()?.parse::<u32>().ok()?;
        let day = parts.next()?.parse::<u32>().ok()?;
        if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
            return None;
        }
        Some(CivilDate { year, month, day })
    }

    /// Today's UTC date from the system clock.
    pub fn today_utc() -> CivilDate {
        let secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        Self::from_unix_days(secs.div_euclid(86_400))
    }

    /// Civil date from a count of days since the Unix epoch. Uses Howard
    /// Hinnant's `civil_from_days` algorithm, valid across the proleptic
    /// Gregorian calendar with no date-crate dependency.
    fn from_unix_days(days: i64) -> CivilDate {
        let z = days + 719_468;
        let era = z.div_euclid(146_097);
        let doe = z - era * 146_097;
        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
        let y = yoe + era * 400;
        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
        let mp = (5 * doy + 2) / 153;
        let d = doy - (153 * mp + 2) / 5 + 1;
        let m = if mp < 10 { mp + 3 } else { mp - 9 };
        CivilDate {
            year: (y + i64::from(m <= 2)) as i32,
            month: m as u32,
            day: d as u32,
        }
    }

    fn key(self) -> (i32, u32, u32) {
        (self.year, self.month, self.day)
    }
}

impl PartialOrd for CivilDate {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for CivilDate {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.key().cmp(&other.key())
    }
}

/// One accepted-finding waiver.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Waiver {
    /// Rule id to waive, for example `SEC-001` or `CFG-001`.
    pub rule_id: String,
    /// Optional target (tool/prompt/resource name, or server name). When
    /// absent, the waiver applies to every finding of `rule_id`.
    #[serde(default)]
    pub target: Option<String>,
    /// Why this finding is accepted. Required so a reviewer can audit it.
    pub reason: String,
    /// Optional `YYYY-MM-DD` expiry. After this date the finding re-activates.
    #[serde(default)]
    pub expiry: Option<String>,
}

impl Waiver {
    /// True when this waiver targets the given finding.
    fn matches(&self, rule_id: &str, target: &str) -> bool {
        self.rule_id == rule_id && self.target.as_deref().is_none_or(|t| t == target)
    }

    /// True when the waiver is still in force on `today` (no expiry, or expiry
    /// not yet passed). A malformed expiry is treated as inactive (fail closed).
    fn is_active(&self, today: CivilDate) -> bool {
        match &self.expiry {
            None => true,
            Some(e) => CivilDate::parse(e).is_some_and(|d| d >= today),
        }
    }
}

/// A waiver file as loaded from YAML.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct WaiverFile {
    /// The accepted-finding entries.
    #[serde(default)]
    pub waivers: Vec<Waiver>,
}

impl WaiverFile {
    /// Parse a waiver file body (YAML).
    pub fn parse(body: &str) -> Result<WaiverFile, serde_yaml::Error> {
        serde_yaml::from_str(body)
    }
}

/// A finding that a waiver can match. Implemented for both the security report
/// and the config scan so [`partition`] is shared.
pub trait Waivable {
    /// The finding's rule id, for example `SEC-001`.
    fn rule_id(&self) -> &str;
    /// The finding's target (definition name or server name).
    fn target(&self) -> &str;
}

/// One suppressed or re-activated finding, for the report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WaivedFinding {
    /// Rule id of the affected finding.
    pub rule_id: String,
    /// Target of the affected finding.
    pub target: String,
    /// The waiver's reason.
    pub reason: String,
}

/// The bookkeeping side of [`partition`]: what was suppressed, what came back,
/// and which waivers are now dead weight.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WaiverReport {
    /// Findings an active waiver suppressed (excluded from gating).
    pub waived: Vec<WaivedFinding>,
    /// Findings whose only matching waiver has expired (kept active, flagged).
    pub expired: Vec<WaivedFinding>,
    /// Active waivers that matched no finding (likely safe to delete).
    pub stale: Vec<Waiver>,
}

impl WaiverReport {
    /// True when nothing was waived, expired, or left stale.
    pub fn is_empty(&self) -> bool {
        self.waived.is_empty() && self.expired.is_empty() && self.stale.is_empty()
    }
}

/// Split `findings` into the ones that still count and a [`WaiverReport`].
///
/// A finding is suppressed when a live waiver matches it. A finding matched only
/// by an expired waiver stays active but is recorded under `expired`. Active
/// waivers that match nothing are recorded under `stale`.
pub fn partition<T: Waivable>(
    findings: Vec<T>,
    file: &WaiverFile,
    today: CivilDate,
) -> (Vec<T>, WaiverReport) {
    let mut active = Vec::new();
    let mut report = WaiverReport::default();
    let mut used = vec![false; file.waivers.len()];

    for f in findings {
        let rule = f.rule_id().to_string();
        let target = f.target().to_string();
        let mut suppressed = false;
        let mut expired_hit: Option<usize> = None;

        for (i, w) in file.waivers.iter().enumerate() {
            if !w.matches(&rule, &target) {
                continue;
            }
            if w.is_active(today) {
                used[i] = true;
                suppressed = true;
                report.waived.push(WaivedFinding {
                    rule_id: rule.clone(),
                    target: target.clone(),
                    reason: w.reason.clone(),
                });
                break;
            }
            // An expired match does not suppress, but marks the waiver as used
            // so it is not also reported as stale.
            used[i] = true;
            expired_hit.get_or_insert(i);
        }

        if suppressed {
            continue;
        }
        if let Some(i) = expired_hit {
            report.expired.push(WaivedFinding {
                rule_id: rule,
                target,
                reason: file.waivers[i].reason.clone(),
            });
        }
        active.push(f);
    }

    report.stale = file
        .waivers
        .iter()
        .enumerate()
        .filter(|(i, w)| !used[*i] && w.is_active(today))
        .map(|(_, w)| w.clone())
        .collect();

    (active, report)
}

/// Render a one-block human summary of waiver activity, or the empty string
/// when nothing was waived, expired, or left stale.
pub fn render_summary(report: &WaiverReport) -> String {
    if report.is_empty() {
        return String::new();
    }
    let mut s = String::new();
    if !report.waived.is_empty() {
        s.push_str(&format!("waived {} finding(s):\n", report.waived.len()));
        for w in &report.waived {
            s.push_str(&format!(
                "  - {} on {} ({})\n",
                w.rule_id, w.target, w.reason
            ));
        }
    }
    if !report.expired.is_empty() {
        s.push_str(&format!(
            "expired waiver(s), {} finding(s) re-activated:\n",
            report.expired.len()
        ));
        for w in &report.expired {
            s.push_str(&format!(
                "  - {} on {} ({})\n",
                w.rule_id, w.target, w.reason
            ));
        }
    }
    if !report.stale.is_empty() {
        s.push_str(&format!(
            "{} stale waiver(s) matching nothing (safe to remove):\n",
            report.stale.len()
        ));
        for w in &report.stale {
            let t = w.target.as_deref().unwrap_or("*");
            s.push_str(&format!("  - {} on {}\n", w.rule_id, t));
        }
    }
    s
}

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

    struct F {
        rule: &'static str,
        target: &'static str,
    }
    impl Waivable for F {
        fn rule_id(&self) -> &str {
            self.rule
        }
        fn target(&self) -> &str {
            self.target
        }
    }

    fn today() -> CivilDate {
        CivilDate {
            year: 2026,
            month: 6,
            day: 26,
        }
    }

    fn file(waivers: Vec<Waiver>) -> WaiverFile {
        WaiverFile { waivers }
    }

    #[test]
    fn civil_date_parses_and_orders() {
        let a = CivilDate::parse("2026-06-26").unwrap();
        let b = CivilDate::parse("2026-07-01").unwrap();
        assert!(a < b);
        assert!(CivilDate::parse("nonsense").is_none());
        assert!(CivilDate::parse("2026-13-01").is_none());
    }

    #[test]
    fn epoch_day_zero_is_1970_01_01() {
        assert_eq!(
            CivilDate::from_unix_days(0),
            CivilDate {
                year: 1970,
                month: 1,
                day: 1
            }
        );
    }

    #[test]
    fn active_waiver_suppresses_a_matching_finding() {
        let findings = vec![F {
            rule: "SEC-001",
            target: "send_email",
        }];
        let f = file(vec![Waiver {
            rule_id: "SEC-001".into(),
            target: Some("send_email".into()),
            reason: "reviewed, intended".into(),
            expiry: None,
        }]);
        let (active, report) = partition(findings, &f, today());
        assert!(active.is_empty());
        assert_eq!(report.waived.len(), 1);
        assert_eq!(report.waived[0].reason, "reviewed, intended");
        assert!(report.stale.is_empty());
    }

    #[test]
    fn target_none_waives_every_finding_of_the_rule() {
        let findings = vec![
            F {
                rule: "CFG-003",
                target: "github",
            },
            F {
                rule: "CFG-003",
                target: "gitlab",
            },
        ];
        let f = file(vec![Waiver {
            rule_id: "CFG-003".into(),
            target: None,
            reason: "creds injected at deploy time".into(),
            expiry: None,
        }]);
        let (active, report) = partition(findings, &f, today());
        assert!(active.is_empty());
        assert_eq!(report.waived.len(), 2);
    }

    #[test]
    fn expired_waiver_reactivates_the_finding() {
        let findings = vec![F {
            rule: "SEC-001",
            target: "x",
        }];
        let f = file(vec![Waiver {
            rule_id: "SEC-001".into(),
            target: None,
            reason: "temporary".into(),
            expiry: Some("2026-01-01".into()),
        }]);
        let (active, report) = partition(findings, &f, today());
        assert_eq!(active.len(), 1, "expired waiver must not suppress");
        assert_eq!(report.expired.len(), 1);
        assert!(report.waived.is_empty());
        // An expired waiver that matched is not stale.
        assert!(report.stale.is_empty());
    }

    #[test]
    fn unused_active_waiver_is_stale() {
        let findings = vec![F {
            rule: "SEC-002",
            target: "y",
        }];
        let f = file(vec![Waiver {
            rule_id: "SEC-999".into(),
            target: None,
            reason: "no longer applies".into(),
            expiry: None,
        }]);
        let (active, report) = partition(findings, &f, today());
        assert_eq!(active.len(), 1);
        assert_eq!(report.stale.len(), 1);
    }

    #[test]
    fn waiver_file_parses_yaml() {
        let body = "waivers:\n  - rule_id: SEC-001\n    reason: accepted\n";
        let wf = WaiverFile::parse(body).unwrap();
        assert_eq!(wf.waivers.len(), 1);
        assert_eq!(wf.waivers[0].rule_id, "SEC-001");
        assert!(wf.waivers[0].target.is_none());
    }
}
