//! Parse and apply a compliance baseline file.
//!
//! A baseline lists rule IDs the server is known to fail. CI passes when
//! the actual set of failures matches the baseline exactly. New failures
//! flip CI red because they are real regressions. Entries that the server
//! now passes also flip CI red so the baseline cannot rot in place.
//!
//! The YAML format accepts a short form (`- LC-INIT-007`) and a long form
//! (`- { rule_id: LC-INIT-007, reason: ..., tracking: ... }`) in the same
//! list. The short form is convenient for the first commit; the long form
//! is what teams reach for once they want to record why each rule is
//! still pending.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Serialize};

use crate::{Error, Result};

/// Parsed `compliance-baseline.yml` for a single server.
///
/// Wraps the entry list in a `BTreeMap` keyed by rule ID so lookups are
/// O(log n) and serialization order is deterministic. The runner only ever
/// asks "is this rule listed?" so the map shape is the natural fit.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Baseline {
    /// Map of `rule_id` to its baseline entry.
    entries: BTreeMap<String, BaselineEntry>,
    /// Version of the `mcptest` binary that produced the baseline.
    /// Populated by [`Baseline::from_yaml`] when the file carries the
    /// field; left `None` for legacy baselines so they keep loading.
    mcptest_version: Option<String>,
}

/// A single rule the server is expected to fail.
///
/// `reason` and `tracking` are optional because the short form (just the
/// rule ID) is allowed. When present they help reviewers see why a rule is
/// still on the list and where to follow up.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BaselineEntry {
    /// Compliance rule identifier (for example `LC-INIT-007`).
    pub rule_id: String,

    /// Why the rule is on the baseline. Free-form prose.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,

    /// Optional URL to an issue tracking the fix.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tracking: Option<String>,
}

impl Baseline {
    /// Load a baseline from a YAML file on disk.
    pub fn load(path: &Path) -> Result<Self> {
        let contents = fs::read_to_string(path)?;
        Self::from_yaml(&contents)
    }

    /// Parse a baseline from a YAML string. Useful in tests and for the
    /// `--update-baseline` writer path that round-trips through memory.
    pub fn from_yaml(contents: &str) -> Result<Self> {
        let doc: BaselineDoc = serde_yaml::from_str(contents)?;
        let mut entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
        for entry in doc.server.into_iter().flatten() {
            if entries.contains_key(&entry.rule_id) {
                return Err(Error::InvalidBaseline(format!(
                    "duplicate rule_id '{}' in baseline",
                    entry.rule_id
                )));
            }
            entries.insert(entry.rule_id.clone(), entry);
        }
        Ok(Baseline {
            entries,
            mcptest_version: doc.mcptest_version,
        })
    }

    /// Version of the `mcptest` binary that wrote this baseline. `None`
    /// for legacy files that pre-date the field; callers surface this in
    /// `mcptest doctor` so operators spot baselines from stale builds.
    pub fn mcptest_version(&self) -> Option<&str> {
        self.mcptest_version.as_deref()
    }

    /// Return `true` when the baseline lists `rule_id`.
    pub fn contains(&self, rule_id: &str) -> bool {
        self.entries.contains_key(rule_id)
    }

    /// Look up the entry for a rule. Returns `None` when the rule is not
    /// on the baseline.
    pub fn get(&self, rule_id: &str) -> Option<&BaselineEntry> {
        self.entries.get(rule_id)
    }

    /// Iterate over every entry in stable (rule_id-sorted) order.
    pub fn iter(&self) -> impl Iterator<Item = &BaselineEntry> {
        self.entries.values()
    }

    /// Total number of baselined rules. Reporters use this for a one-line
    /// summary.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` when no rules are baselined.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

/// Outcome of evaluating a single rule against the baseline.
///
/// `decide` returns one of four cases. The aggregator turns these into
/// the run-level exit decision: any [`BaselineDecision::NewRegression`] or
/// [`BaselineDecision::StaleBaseline`] flips CI red.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaselineDecision {
    /// Check passed and is not on the baseline. Normal green.
    NormalPass,
    /// Check failed but is on the baseline. Expected, still green.
    ExpectedFailure(BaselineEntry),
    /// Check failed and is NOT on the baseline. New regression, exit 1.
    NewRegression,
    /// Check passed but is still on the baseline. Stale entry, exit 1.
    StaleBaseline(BaselineEntry),
}

/// Apply baseline logic to a single rule outcome.
///
/// The caller passes the rule ID, whether the check passed, and the
/// loaded baseline. Combining those three inputs into one of the four
/// [`BaselineDecision`] variants keeps the rule fan-out in one place and
/// the aggregator a flat counter.
pub fn decide(rule_id: &str, passed: bool, baseline: &Baseline) -> BaselineDecision {
    match (passed, baseline.get(rule_id)) {
        (true, None) => BaselineDecision::NormalPass,
        (false, None) => BaselineDecision::NewRegression,
        (false, Some(entry)) => BaselineDecision::ExpectedFailure(entry.clone()),
        (true, Some(entry)) => BaselineDecision::StaleBaseline(entry.clone()),
    }
}

/// Summary of every per-rule decision in a run.
///
/// The CLI maps `should_fail` to exit code 1 (true) or 0 (false). The
/// per-bucket counters and stale list are surfaced in reporter output so
/// users see exactly why CI flipped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AggregateOutcome {
    /// Number of failed rules that were on the baseline (still green).
    pub expected_failures: usize,
    /// Number of failed rules NOT on the baseline. Each flips CI red.
    pub new_regressions: usize,
    /// Rules the baseline still lists but the server now passes. Each
    /// flips CI red so the baseline does not rot in place.
    pub stale_entries: Vec<BaselineEntry>,
    /// `true` when any new regression or stale entry was seen.
    pub should_fail: bool,
}

/// Aggregate per-rule decisions into the run-level exit signal.
///
/// Counts expected failures, new regressions, and stale entries, then
/// flips `should_fail` when either of the last two is non-zero. Normal
/// passes are not counted because they are the default state and would
/// add noise without informing the exit decision.
pub fn aggregate(decisions: &[BaselineDecision]) -> AggregateOutcome {
    let mut expected_failures = 0usize;
    let mut new_regressions = 0usize;
    let mut stale_entries: Vec<BaselineEntry> = Vec::new();
    for decision in decisions {
        match decision {
            BaselineDecision::NormalPass => {}
            BaselineDecision::ExpectedFailure(_) => expected_failures += 1,
            BaselineDecision::NewRegression => new_regressions += 1,
            BaselineDecision::StaleBaseline(entry) => stale_entries.push(entry.clone()),
        }
    }
    let should_fail = new_regressions > 0 || !stale_entries.is_empty();
    AggregateOutcome {
        expected_failures,
        new_regressions,
        stale_entries,
        should_fail,
    }
}

/// Build a fresh baseline from a list of `(rule_id, passed)` tuples.
///
/// Only rules that currently fail show up in the result. Reasons and
/// tracking URLs are dropped because the runner does not know them; the
/// `--update-baseline` flow regenerates the file from scratch and asks
/// the operator to fill the metadata back in. Duplicate rule IDs in the
/// input collapse to the last seen entry (stable but undefined which one
/// "wins"; production callers should pass a deduplicated list).
pub fn update_baseline<I, S>(results: I) -> Baseline
where
    I: IntoIterator<Item = (S, bool)>,
    S: Into<String>,
{
    let mut entries: BTreeMap<String, BaselineEntry> = BTreeMap::new();
    for (rule_id, passed) in results {
        if passed {
            continue;
        }
        let rule_id = rule_id.into();
        entries.insert(
            rule_id.clone(),
            BaselineEntry {
                rule_id,
                reason: None,
                tracking: None,
            },
        );
    }
    Baseline {
        entries,
        // Stamp the running build's full version so the regenerated
        // baseline carries provenance the same way fresh recordings do.
        mcptest_version: Some(crate::version::full().to_string()),
    }
}

// ---------------------------------------------------------------------------
// Wire-format types and the dual short/long deserializer.
// ---------------------------------------------------------------------------

/// Top-level YAML shape: a `server:` key carrying an entry list plus an
/// optional `mcptest_version` stamp.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BaselineDoc {
    /// `Option` so an empty file (or one without the `server` key) loads
    /// as an empty baseline instead of erroring. Callers expect a missing
    /// section to mean "nothing baselined yet".
    #[serde(default)]
    server: Option<Vec<BaselineEntry>>,
    /// Version of the `mcptest` binary that wrote the file. Optional so
    /// baselines from earlier builds keep loading; the writer always
    /// emits it via [`crate::version::full`].
    #[serde(default)]
    mcptest_version: Option<String>,
}

impl<'de> Deserialize<'de> for BaselineEntry {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_any(BaselineEntryVisitor)
    }
}

/// Visitor that accepts either a bare string (short form) or a mapping
/// with `rule_id` plus optional `reason` and `tracking` (long form).
struct BaselineEntryVisitor;

impl<'de> Visitor<'de> for BaselineEntryVisitor {
    type Value = BaselineEntry;

    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str("a rule_id string or a mapping with rule_id/reason/tracking")
    }

    fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
    where
        E: de::Error,
    {
        if value.is_empty() {
            return Err(de::Error::custom("rule_id must not be empty"));
        }
        Ok(BaselineEntry {
            rule_id: value.to_string(),
            reason: None,
            tracking: None,
        })
    }

    fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
    where
        E: de::Error,
    {
        self.visit_str(&value)
    }

    fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
    where
        M: MapAccess<'de>,
    {
        let mut rule_id: Option<String> = None;
        let mut reason: Option<String> = None;
        let mut tracking: Option<String> = None;
        while let Some(key) = map.next_key::<String>()? {
            match key.as_str() {
                "rule_id" => {
                    if rule_id.is_some() {
                        return Err(de::Error::duplicate_field("rule_id"));
                    }
                    rule_id = Some(map.next_value()?);
                }
                "reason" => {
                    if reason.is_some() {
                        return Err(de::Error::duplicate_field("reason"));
                    }
                    reason = Some(map.next_value()?);
                }
                "tracking" => {
                    if tracking.is_some() {
                        return Err(de::Error::duplicate_field("tracking"));
                    }
                    tracking = Some(map.next_value()?);
                }
                other => {
                    return Err(de::Error::unknown_field(
                        other,
                        &["rule_id", "reason", "tracking"],
                    ));
                }
            }
        }
        let rule_id = rule_id.ok_or_else(|| de::Error::missing_field("rule_id"))?;
        if rule_id.is_empty() {
            return Err(de::Error::custom("rule_id must not be empty"));
        }
        Ok(BaselineEntry {
            rule_id,
            reason,
            tracking,
        })
    }

    fn visit_seq<S>(self, _seq: S) -> std::result::Result<Self::Value, S::Error>
    where
        S: SeqAccess<'de>,
    {
        Err(de::Error::custom(
            "baseline entry must be a string or a mapping, not a sequence",
        ))
    }
}

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

    #[test]
    fn parses_short_form_entries() {
        let yaml = r#"
server:
  - LC-INIT-007
  - TOOLS-CALL-003
"#;
        let baseline = Baseline::from_yaml(yaml).unwrap();
        assert_eq!(baseline.len(), 2);
        assert!(baseline.contains("LC-INIT-007"));
        assert!(baseline.contains("TOOLS-CALL-003"));
        let entry = baseline.get("LC-INIT-007").unwrap();
        assert_eq!(entry.rule_id, "LC-INIT-007");
        assert!(entry.reason.is_none());
        assert!(entry.tracking.is_none());
    }

    #[test]
    fn parses_optional_mcptest_version_field() {
        let yaml = r#"
mcptest_version: 0.1.0+abcd1234
server:
  - LC-INIT-007
"#;
        let baseline = Baseline::from_yaml(yaml).unwrap();
        assert_eq!(baseline.mcptest_version(), Some("0.1.0+abcd1234"));
        assert!(baseline.contains("LC-INIT-007"));
    }

    #[test]
    fn legacy_baseline_without_mcptest_version_still_loads() {
        let yaml = "server:\n  - LC-INIT-007\n";
        let baseline = Baseline::from_yaml(yaml).unwrap();
        assert!(baseline.mcptest_version().is_none());
        assert_eq!(baseline.len(), 1);
    }

    #[test]
    fn parses_long_form_entries() {
        let yaml = r#"
server:
  - rule_id: LC-INIT-007
    reason: server.json field missing in v1.x
    tracking: https://github.com/our-org/server/issues/42
"#;
        let baseline = Baseline::from_yaml(yaml).unwrap();
        let entry = baseline.get("LC-INIT-007").unwrap();
        assert_eq!(
            entry.reason.as_deref(),
            Some("server.json field missing in v1.x")
        );
        assert_eq!(
            entry.tracking.as_deref(),
            Some("https://github.com/our-org/server/issues/42")
        );
    }

    #[test]
    fn parses_mixed_short_and_long_form() {
        let yaml = r#"
server:
  - LC-INIT-007
  - rule_id: TOOLS-CALL-003
    reason: known race condition
"#;
        let baseline = Baseline::from_yaml(yaml).unwrap();
        assert_eq!(baseline.len(), 2);
        assert!(baseline.get("LC-INIT-007").unwrap().reason.is_none());
        assert_eq!(
            baseline.get("TOOLS-CALL-003").unwrap().reason.as_deref(),
            Some("known race condition")
        );
    }

    #[test]
    fn empty_file_loads_as_empty_baseline() {
        let baseline = Baseline::from_yaml("").unwrap();
        assert!(baseline.is_empty());
        let baseline = Baseline::from_yaml("server: []\n").unwrap();
        assert!(baseline.is_empty());
    }

    #[test]
    fn rejects_unknown_top_level_field() {
        let yaml = r#"
server:
  - LC-INIT-007
client:
  - X
"#;
        let err = Baseline::from_yaml(yaml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("yaml error"), "got: {msg}");
    }

    #[test]
    fn rejects_unknown_entry_field() {
        let yaml = r#"
server:
  - rule_id: LC-INIT-007
    notes: wrong-field-name
"#;
        let err = Baseline::from_yaml(yaml).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("notes") || msg.contains("unknown field"),
            "got: {msg}"
        );
    }

    #[test]
    fn rejects_duplicate_rule_ids() {
        let yaml = r#"
server:
  - LC-INIT-007
  - rule_id: LC-INIT-007
    reason: also listed
"#;
        let err = Baseline::from_yaml(yaml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("duplicate rule_id"), "got: {msg}");
    }

    #[test]
    fn rejects_empty_rule_id() {
        let yaml = "server:\n  - \"\"\n";
        let err = Baseline::from_yaml(yaml).unwrap_err();
        assert!(err.to_string().contains("rule_id must not be empty"));
    }

    #[test]
    fn decide_returns_normal_pass_when_rule_not_baselined_and_check_passed() {
        let baseline = Baseline::default();
        let decision = decide("LC-INIT-001", true, &baseline);
        assert_eq!(decision, BaselineDecision::NormalPass);
    }

    #[test]
    fn decide_returns_new_regression_when_rule_not_baselined_and_check_failed() {
        let baseline = Baseline::default();
        let decision = decide("LC-INIT-001", false, &baseline);
        assert_eq!(decision, BaselineDecision::NewRegression);
    }

    #[test]
    fn decide_returns_expected_failure_when_rule_baselined_and_check_failed() {
        let baseline = Baseline::from_yaml("server:\n  - LC-INIT-007\n").unwrap();
        let decision = decide("LC-INIT-007", false, &baseline);
        match decision {
            BaselineDecision::ExpectedFailure(entry) => {
                assert_eq!(entry.rule_id, "LC-INIT-007");
            }
            other => panic!("expected ExpectedFailure, got {other:?}"),
        }
    }

    #[test]
    fn decide_returns_stale_baseline_when_rule_baselined_but_check_passed() {
        let baseline = Baseline::from_yaml("server:\n  - LC-INIT-007\n").unwrap();
        let decision = decide("LC-INIT-007", true, &baseline);
        match decision {
            BaselineDecision::StaleBaseline(entry) => {
                assert_eq!(entry.rule_id, "LC-INIT-007");
            }
            other => panic!("expected StaleBaseline, got {other:?}"),
        }
    }

    #[test]
    fn aggregate_flips_should_fail_on_new_regression() {
        let outcome = aggregate(&[
            BaselineDecision::NormalPass,
            BaselineDecision::NewRegression,
        ]);
        assert_eq!(outcome.new_regressions, 1);
        assert!(outcome.should_fail);
    }

    #[test]
    fn aggregate_flips_should_fail_on_stale_entry() {
        let entry = BaselineEntry {
            rule_id: "LC-INIT-007".into(),
            reason: None,
            tracking: None,
        };
        let outcome = aggregate(&[BaselineDecision::StaleBaseline(entry.clone())]);
        assert_eq!(outcome.stale_entries, vec![entry]);
        assert!(outcome.should_fail);
    }

    #[test]
    fn aggregate_stays_green_when_only_expected_failures() {
        let entry = BaselineEntry {
            rule_id: "LC-INIT-007".into(),
            reason: None,
            tracking: None,
        };
        let outcome = aggregate(&[
            BaselineDecision::NormalPass,
            BaselineDecision::ExpectedFailure(entry),
        ]);
        assert_eq!(outcome.expected_failures, 1);
        assert_eq!(outcome.new_regressions, 0);
        assert!(outcome.stale_entries.is_empty());
        assert!(!outcome.should_fail);
    }

    #[test]
    fn update_baseline_keeps_only_failing_rules() {
        let baseline = update_baseline(vec![
            ("LC-INIT-001", true),
            ("LC-INIT-007", false),
            ("TOOLS-CALL-003", false),
            ("TOOLS-LIST-002", true),
        ]);
        assert_eq!(baseline.len(), 2);
        assert!(baseline.contains("LC-INIT-007"));
        assert!(baseline.contains("TOOLS-CALL-003"));
        for entry in baseline.iter() {
            assert!(entry.reason.is_none());
            assert!(entry.tracking.is_none());
        }
    }

    #[test]
    fn iter_returns_entries_in_rule_id_order() {
        let yaml = r#"
server:
  - TOOLS-CALL-003
  - LC-INIT-007
  - LC-INIT-001
"#;
        let baseline = Baseline::from_yaml(yaml).unwrap();
        let ids: Vec<&str> = baseline.iter().map(|e| e.rule_id.as_str()).collect();
        assert_eq!(ids, vec!["LC-INIT-001", "LC-INIT-007", "TOOLS-CALL-003"]);
    }

    #[test]
    fn load_reads_file_from_disk() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("baseline-{}.yml", std::process::id()));
        std::fs::write(&path, "server:\n  - LC-INIT-007\n").unwrap();
        let baseline = Baseline::load(&path).unwrap();
        assert!(baseline.contains("LC-INIT-007"));
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn rejects_sequence_inside_entry_slot() {
        let yaml = "server:\n  - [LC-INIT-007]\n";
        let err = Baseline::from_yaml(yaml).unwrap_err();
        assert!(err.to_string().contains("must be a string or a mapping"));
    }
}
