//! Baseline diff engine.
//!
//! Compares two [`BaselineFile`]s and produces a per-test
//! [`BaselineDiff`] structured by category. Classification follows the
//! three-tier rubric from ADR 0026: PASS, DRIFT, FAIL.
//!
//! The engine is deterministic and transport-free. The CLI consumes
//! `BaselineDiff` via `render_pretty` (ANSI-tinted plain text) or
//! `serde_json` for `--format json`.

use serde::{Deserialize, Serialize};

use crate::model_compat::diff_pair::diff_pair;
use crate::model_compat::error::ModelCompatError;
use crate::model_compat::fingerprint::{BehavioralFingerprint, FingerprintDelta};
#[cfg(test)]
use crate::model_compat::invariants::Invariant;
use crate::model_compat::invariants::InvariantResult;
use crate::model_compat::model::{index_by_id, BaselineFile};
#[cfg(test)]
use crate::model_compat::model::{CapturedExchange, ContentBlock, FinishReason, ToolCall};

/// Three-tier severity per ADR 0026.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
    /// Byte-identical or variance-only equivalence.
    Pass,
    /// Surface-level change with no invariant violation.
    Drift,
    /// Invariant violated; rollout must stop.
    Fail,
}

/// Category bucket each [`DiffEntry`] lands in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeCategory {
    /// Tool was added, removed, or reordered.
    ToolCalls,
    /// A tool's argument bag changed.
    ToolArgs,
    /// Response content shape changed (block count, types).
    ResponseShape,
    /// Response semantics changed (text rephrased, casing, whitespace).
    ResponseSemantics,
    /// Finish reason flipped.
    FinishReason,
    /// An invariant declared on the candidate baseline was violated.
    Invariant,
}

/// One observation the diff engine made about a single assertion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffEntry {
    /// Stable assertion id the entry belongs to.
    pub test_id: String,
    /// Category of change.
    pub category: ChangeCategory,
    /// Severity per the ADR 0026 rubric.
    pub severity: Severity,
    /// Short human-readable explanation.
    pub message: String,
}

/// Roll-up counts the CLI prints in quiet mode.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffSummary {
    /// Total entries observed.
    pub total: usize,
    /// Entries classified `PASS`.
    pub pass: usize,
    /// Entries classified `DRIFT`.
    pub drift: usize,
    /// Entries classified `FAIL`.
    pub fail: usize,
}

/// Distance past which a behavioral-fingerprint change, otherwise uncaught by
/// the per-field diff, raises a DRIFT entry of its own. `0.0` means any
/// nonzero distance (`!same`) qualifies; raise it to require a larger shift.
pub const FINGERPRINT_DRIFT_THRESHOLD: f64 = 0.0;

/// Diff between two baselines. Lists every observed change plus a
/// pre-computed summary so callers can print without re-scanning.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BaselineDiff {
    /// Per-test entries, ordered by `test_id`.
    pub entries: Vec<DiffEntry>,
    /// Aggregate counts.
    pub summary: DiffSummary,
    /// Invariants that fired during the diff (one per (test, invariant)).
    /// Surfaced separately so reporters can group invariant signals
    /// without scanning the full entry list.
    pub invariant_results: Vec<(String, InvariantResult)>,
    /// Behavioral-fingerprint comparison of the two baselines: the top-line
    /// "did the behavior shape move?" signal that sits above the per-field
    /// entries. `serde(default)` so a baseline written before this field
    /// round-trips and a default-constructed diff reports no change.
    #[serde(default)]
    pub fingerprint: FingerprintDelta,
}

impl BaselineDiff {
    /// Render a color-tinted plain-text report. Uses minimal ANSI
    /// escape codes inline (no new deps). Pass `color = false` for
    /// strict plain text (CI logs, etc.).
    pub fn render_pretty(&self, color: bool) -> String {
        let mut out = String::new();
        let bold = if color { "\x1b[1m" } else { "" };
        let reset = if color { "\x1b[0m" } else { "" };
        out.push_str(&format!(
            "{bold}Model baseline diff{reset}: {} total ({} PASS, {} DRIFT, {} FAIL)\n",
            self.summary.total, self.summary.pass, self.summary.drift, self.summary.fail
        ));
        out.push_str(&format!("{}\n", self.fingerprint.render_line()));
        for entry in &self.entries {
            let tag = match entry.severity {
                Severity::Pass => paint("PASS", "\x1b[32m", color),
                Severity::Drift => paint("DRIFT", "\x1b[33m", color),
                Severity::Fail => paint("FAIL", "\x1b[31m", color),
            };
            out.push_str(&format!(
                "  [{tag}] {} {} {} {}\n",
                entry.test_id,
                category_label(entry.category),
                "::",
                entry.message
            ));
        }
        if !self.invariant_results.is_empty() {
            out.push_str("\nInvariants:\n");
            for (test_id, r) in &self.invariant_results {
                let tag = if r.passed { "ok" } else { "violated" };
                out.push_str(&format!("  - {test_id} {} {tag}: {}\n", r.name, r.detail));
            }
        }
        out
    }
}

fn paint(text: &str, code: &str, color: bool) -> String {
    if color {
        format!("{code}{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

fn category_label(c: ChangeCategory) -> &'static str {
    match c {
        ChangeCategory::ToolCalls => "tool_calls",
        ChangeCategory::ToolArgs => "tool_args",
        ChangeCategory::ResponseShape => "response_shape",
        ChangeCategory::ResponseSemantics => "response_semantics",
        ChangeCategory::FinishReason => "finish_reason",
        ChangeCategory::Invariant => "invariant",
    }
}

/// Top-level diff entry point. ADR 0026 OQ-2: refuses cross-version
/// comparisons. The candidate file's `invariants` block takes
/// precedence when the two files diverge.
pub fn diff_baselines(
    a: &BaselineFile,
    b: &BaselineFile,
) -> Result<BaselineDiff, ModelCompatError> {
    if a.version != b.version {
        return Err(ModelCompatError::IncompatibleVersions {
            a: a.version,
            b: b.version,
        });
    }

    let invariants = if a.invariants == b.invariants {
        &a.invariants
    } else {
        &b.invariants
    };

    let mut diff = BaselineDiff::default();
    let a_idx = index_by_id(&a.assertions);
    let b_idx = index_by_id(&b.assertions);

    let mut ids: Vec<&str> = a_idx.keys().chain(b_idx.keys()).copied().collect();
    ids.sort();
    ids.dedup();

    for id in ids {
        match (a_idx.get(id), b_idx.get(id)) {
            (Some(left), Some(right)) => diff_pair(id, left, right, invariants, &mut diff),
            (Some(_), None) => push(
                &mut diff,
                DiffEntry {
                    test_id: id.into(),
                    category: ChangeCategory::ToolCalls,
                    severity: Severity::Fail,
                    message: "assertion missing from candidate baseline".into(),
                },
            ),
            (None, Some(_)) => push(
                &mut diff,
                DiffEntry {
                    test_id: id.into(),
                    category: ChangeCategory::ToolCalls,
                    severity: Severity::Drift,
                    message: "new assertion present in candidate".into(),
                },
            ),
            (None, None) => {}
        }
    }

    diff.fingerprint =
        BehavioralFingerprint::from_baseline(a).compare(&BehavioralFingerprint::from_baseline(b));
    fold_fingerprint_drift(&mut diff);
    Ok(diff)
}

/// Fold the behavioral fingerprint into the verdict: when the behavior shape
/// moved (`!same`) past [`FINGERPRINT_DRIFT_THRESHOLD`] but the per-field diff
/// caught nothing (no DRIFT, no FAIL), raise one DRIFT entry so a trajectory
/// shift is not reported as all-green. This is the AgentAssay safety net
/// (assertions stay green while the trajectory drifts); when a field-level
/// entry already flagged the change, the fingerprint stays informational to
/// avoid double-counting.
pub(crate) fn fold_fingerprint_drift(diff: &mut BaselineDiff) {
    if diff.fingerprint.same || diff.fingerprint.distance <= FINGERPRINT_DRIFT_THRESHOLD {
        return;
    }
    if diff.summary.drift > 0 || diff.summary.fail > 0 {
        return;
    }
    push(
        diff,
        DiffEntry {
            test_id: "<fingerprint>".into(),
            category: ChangeCategory::ToolCalls,
            severity: Severity::Drift,
            message: format!(
                "behavioral fingerprint moved (distance {:.2}) with no field-level change",
                diff.fingerprint.distance
            ),
        },
    );
}

/// Shared push helper that updates the running summary counts.
pub(crate) fn push(diff: &mut BaselineDiff, entry: DiffEntry) {
    diff.summary.total += 1;
    match entry.severity {
        Severity::Pass => diff.summary.pass += 1,
        Severity::Drift => diff.summary.drift += 1,
        Severity::Fail => diff.summary.fail += 1,
    }
    diff.entries.push(entry);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model_compat::model::{BASELINE_SCHEMA_URL, BASELINE_SCHEMA_VERSION};

    fn baseline(
        model: &str,
        items: Vec<CapturedExchange>,
        invariants: Vec<Invariant>,
    ) -> BaselineFile {
        BaselineFile {
            schema: BASELINE_SCHEMA_URL.into(),
            version: BASELINE_SCHEMA_VERSION,
            model_id: model.into(),
            model_version: "x".into(),
            mcptest_version: "0.0.0".into(),
            server_version: "0.0.0".into(),
            server_config_hash: "sha256:0".into(),
            config_fingerprint: crate::model_compat::model::ConfigFingerprint::default(),
            assertions: items,
            invariants,
        }
    }

    fn exchange_text(id: &str, text: &str) -> CapturedExchange {
        CapturedExchange {
            id: id.into(),
            content: vec![ContentBlock::Text { text: text.into() }],
            tool_calls: vec![],
            finish_reason: FinishReason::Stop,
            mcp_messages: vec![],
        }
    }

    fn exchange_tools(id: &str, tools: Vec<ToolCall>) -> CapturedExchange {
        CapturedExchange {
            id: id.into(),
            content: vec![],
            tool_calls: tools,
            finish_reason: FinishReason::ToolUse,
            mcp_messages: vec![],
        }
    }

    #[test]
    fn identical_baselines_have_no_failures() {
        let a = baseline("m", vec![exchange_text("t::a::1", "Hello.")], vec![]);
        let b = a.clone();
        let d = diff_baselines(&a, &b).expect("diff");
        assert_eq!(d.summary.fail, 0);
        assert_eq!(d.summary.drift, 0);
    }

    #[test]
    fn rephrased_text_classifies_as_drift() {
        let a = baseline(
            "m",
            vec![exchange_text("t::a::1", "Paris is the capital.")],
            vec![],
        );
        let b = baseline(
            "m",
            vec![exchange_text("t::a::1", "The capital is Paris.")],
            vec![],
        );
        let d = diff_baselines(&a, &b).expect("diff");
        assert_eq!(d.summary.fail, 0);
        assert!(d.summary.drift >= 1);
    }

    #[test]
    fn whitespace_only_is_drift() {
        let a = baseline("m", vec![exchange_text("t::a::1", "Hello, world.")], vec![]);
        let b = baseline(
            "m",
            vec![exchange_text("t::a::1", "Hello,  world.")],
            vec![],
        );
        let d = diff_baselines(&a, &b).expect("diff");
        assert_eq!(d.summary.fail, 0);
        assert_eq!(d.summary.drift, 1);
    }

    #[test]
    fn case_only_is_drift() {
        let a = baseline("m", vec![exchange_text("t::a::1", "Hello.")], vec![]);
        let b = baseline("m", vec![exchange_text("t::a::1", "HELLO.")], vec![]);
        let d = diff_baselines(&a, &b).expect("diff");
        assert_eq!(d.summary.drift, 1);
        assert_eq!(d.summary.fail, 0);
    }

    #[test]
    fn args_reordered_is_drift() {
        // When both sides carry source bytes, the diff engine
        // detects the reorder via raw byte comparison and surfaces a
        // `structurally-equivalent` DRIFT entry. Without raw bytes the
        // engine has no signal (BTreeMap-backed Values compare equal
        // regardless of source order).
        let a = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "f".into(),
                    arguments: serde_json::json!({"a": 1, "b": 2}),
                    raw_arguments: Some(r#"{"a":1,"b":2}"#.into()),
                }],
            )],
            vec![],
        );
        let b = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "f".into(),
                    arguments: serde_json::json!({"b": 2, "a": 1}),
                    raw_arguments: Some(r#"{"b":2,"a":1}"#.into()),
                }],
            )],
            vec![],
        );
        let d = diff_baselines(&a, &b).expect("diff");
        assert_eq!(d.summary.fail, 0);
        assert!(d.summary.drift >= 1, "expected DRIFT for key reorder");
        assert!(d
            .entries
            .iter()
            .any(|e| matches!(e.category, ChangeCategory::ToolArgs)
                && matches!(e.severity, Severity::Drift)));
    }

    #[test]
    fn arg_value_changed_is_fail() {
        let a = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "send_email".into(),
                    arguments: serde_json::json!({"to": "alice@example.com"}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let b = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "send_email".into(),
                    arguments: serde_json::json!({"to": "bob@example.com"}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let d = diff_baselines(&a, &b).expect("diff");
        assert!(d.summary.fail >= 1);
    }

    #[test]
    fn required_tool_not_called_is_fail() {
        let a = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "lookup_account".into(),
                    arguments: serde_json::json!({}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let b = baseline("m", vec![exchange_tools("t::a::1", vec![])], vec![]);
        let d = diff_baselines(&a, &b).expect("diff");
        assert!(d.summary.fail >= 1);
    }

    #[test]
    fn finish_reason_changed_is_fail() {
        let mut a = exchange_text("t::a::1", "hi");
        let mut b = exchange_text("t::a::1", "hi");
        a.finish_reason = FinishReason::Stop;
        b.finish_reason = FinishReason::MaxTokens;
        let d = diff_baselines(
            &baseline("m", vec![a], vec![]),
            &baseline("m", vec![b], vec![]),
        )
        .expect("diff");
        assert!(d.summary.fail >= 1);
    }

    #[test]
    fn response_shape_added_field_is_drift() {
        let a = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "f".into(),
                    arguments: serde_json::json!({"a": 1}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let b = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "f".into(),
                    arguments: serde_json::json!({"a": 1, "b": 2}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let d = diff_baselines(&a, &b).expect("diff");
        assert_eq!(d.summary.fail, 0);
        assert_eq!(d.summary.drift, 1);
    }

    #[test]
    fn response_shape_type_changed_is_fail() {
        let a = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "f".into(),
                    arguments: serde_json::json!({"limit": 100}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let b = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "f".into(),
                    arguments: serde_json::json!({"limit": "100"}),
                    raw_arguments: None,
                }],
            )],
            vec![],
        );
        let d = diff_baselines(&a, &b).expect("diff");
        assert!(d.summary.fail >= 1);
    }

    #[test]
    fn invariant_failure_classifies_as_fail() {
        let inv = Invariant {
            name: "must-call-lookup".into(),
            kind: crate::model_compat::invariants::InvariantKind::ToolCalled,
            condition: crate::model_compat::invariants::InvariantCondition::Tool {
                tool: "lookup_account".into(),
            },
        };
        let a = baseline(
            "m",
            vec![exchange_tools(
                "t::a::1",
                vec![ToolCall {
                    name: "lookup_account".into(),
                    arguments: serde_json::json!({"id": "1"}),
                    raw_arguments: None,
                }],
            )],
            vec![inv.clone()],
        );
        let b = baseline("m", vec![exchange_tools("t::a::1", vec![])], vec![inv]);
        let d = diff_baselines(&a, &b).expect("diff");
        assert!(d
            .entries
            .iter()
            .any(|e| matches!(e.category, ChangeCategory::Invariant)
                && matches!(e.severity, Severity::Fail)));
    }

    #[test]
    fn version_mismatch_errors() {
        let mut a = baseline("m", vec![], vec![]);
        let mut b = baseline("m", vec![], vec![]);
        a.version = 1;
        b.version = 2;
        let err = diff_baselines(&a, &b).expect_err("expected error");
        assert!(matches!(err, ModelCompatError::IncompatibleVersions { .. }));
    }

    #[test]
    fn render_pretty_emits_summary_header() {
        let a = baseline("m", vec![exchange_text("t::a::1", "hi")], vec![]);
        let b = baseline("m", vec![exchange_text("t::a::1", "HI")], vec![]);
        let d = diff_baselines(&a, &b).expect("diff");
        let s = d.render_pretty(false);
        assert!(s.contains("Model baseline diff"));
        assert!(s.contains("DRIFT"));
    }

    #[test]
    fn fingerprint_drift_fires_when_no_field_entry_caught_the_change() {
        // The AgentAssay safety net: behavior moved but the per-field diff is
        // clean, so the fingerprint itself raises a single DRIFT entry.
        let mut diff = BaselineDiff {
            fingerprint: FingerprintDelta {
                same: false,
                tool_sequence_changed: true,
                distance: 0.5,
                baseline_digest: "aaaa".into(),
                candidate_digest: "bbbb".into(),
                ..Default::default()
            },
            ..Default::default()
        };
        fold_fingerprint_drift(&mut diff);
        assert_eq!(diff.summary.drift, 1);
        let entry = diff
            .entries
            .iter()
            .find(|e| e.test_id == "<fingerprint>")
            .expect("fingerprint drift entry");
        assert!(matches!(entry.severity, Severity::Drift));
        assert!(entry.message.contains("distance 0.50"));
    }

    #[test]
    fn fingerprint_drift_is_suppressed_when_a_field_entry_already_fired() {
        let mut diff = BaselineDiff {
            fingerprint: FingerprintDelta {
                same: false,
                distance: 0.5,
                ..Default::default()
            },
            ..Default::default()
        };
        push(
            &mut diff,
            DiffEntry {
                test_id: "t::a::1".into(),
                category: ChangeCategory::FinishReason,
                severity: Severity::Fail,
                message: "finish reason flipped".into(),
            },
        );
        fold_fingerprint_drift(&mut diff);
        assert!(!diff.entries.iter().any(|e| e.test_id == "<fingerprint>"));
    }

    #[test]
    fn fingerprint_drift_does_nothing_when_behavior_is_unchanged() {
        let mut diff = BaselineDiff::default();
        fold_fingerprint_drift(&mut diff);
        assert_eq!(diff.summary.total, 0);
    }

    #[test]
    fn render_pretty_color_uses_ansi_escapes() {
        let a = baseline("m", vec![exchange_text("t::a::1", "hi")], vec![]);
        let b = baseline("m", vec![exchange_text("t::a::1", "HI")], vec![]);
        let d = diff_baselines(&a, &b).expect("diff");
        let s = d.render_pretty(true);
        assert!(s.contains("\x1b["));
    }
}
