//! Pairwise diff helpers for [`super::diff`].
//!
//! Split out of `diff.rs` so the public entry point and the per-pair
//! comparison helpers each stay well under the 400-line module
//! ceiling. The helpers operate on one `(baseline, candidate)` pair at
//! a time and push entries into the caller's accumulating
//! [`super::diff::BaselineDiff`].

use std::collections::BTreeMap;

use crate::model_compat::diff::{push, BaselineDiff, ChangeCategory, DiffEntry, Severity};
use crate::model_compat::invariants::{check_invariants, Invariant};
use crate::model_compat::model::{
    CapturedExchange, CapturedRun, ContentBlock, FinishReason, ToolCall,
};

/// Compare one baseline/candidate pair and push entries into `diff`.
pub(super) fn diff_pair(
    id: &str,
    a: &CapturedExchange,
    b: &CapturedExchange,
    invariants: &[Invariant],
    diff: &mut BaselineDiff,
) {
    diff_tool_calls(id, &a.tool_calls, &b.tool_calls, diff);
    diff_content(id, &a.content, &b.content, diff);

    if a.finish_reason != b.finish_reason {
        push(
            diff,
            DiffEntry {
                test_id: id.into(),
                category: ChangeCategory::FinishReason,
                severity: Severity::Fail,
                message: format!(
                    "finish reason changed: {:?} vs {:?}",
                    a.finish_reason, b.finish_reason
                ),
            },
        );
    } else if (a.finish_reason == FinishReason::Stop || a.finish_reason == FinishReason::ToolUse)
        && a == b
    {
        push(
            diff,
            DiffEntry {
                test_id: id.into(),
                category: ChangeCategory::ResponseShape,
                severity: Severity::Pass,
                message: "byte-identical exchange".into(),
            },
        );
    }

    let run = CapturedRun::new(b.clone(), 0);
    let results = check_invariants(invariants, &run);
    for r in results {
        if !r.passed {
            push(
                diff,
                DiffEntry {
                    test_id: id.into(),
                    category: ChangeCategory::Invariant,
                    severity: Severity::Fail,
                    message: format!("invariant `{}` violated: {}", r.name, r.detail),
                },
            );
        }
        diff.invariant_results.push((id.into(), r));
    }
}

fn diff_tool_calls(id: &str, a: &[ToolCall], b: &[ToolCall], diff: &mut BaselineDiff) {
    let names_a: Vec<&str> = a.iter().map(|c| c.name.as_str()).collect();
    let names_b: Vec<&str> = b.iter().map(|c| c.name.as_str()).collect();

    if names_a == names_b {
        diff_args(id, a, b, diff);
        return;
    }

    let mut set_a = names_a.clone();
    let mut set_b = names_b.clone();
    set_a.sort();
    set_b.sort();
    if set_a == set_b {
        push(
            diff,
            DiffEntry {
                test_id: id.into(),
                category: ChangeCategory::ToolCalls,
                severity: Severity::Fail,
                message: format!("tool call order changed: {names_a:?} vs {names_b:?}"),
            },
        );
        return;
    }

    for name in &set_a {
        if !set_b.contains(name) {
            push(
                diff,
                DiffEntry {
                    test_id: id.into(),
                    category: ChangeCategory::ToolCalls,
                    severity: Severity::Fail,
                    message: format!("required tool `{name}` not called in candidate"),
                },
            );
        }
    }
    for name in &set_b {
        if !set_a.contains(name) {
            push(
                diff,
                DiffEntry {
                    test_id: id.into(),
                    category: ChangeCategory::ToolCalls,
                    severity: Severity::Fail,
                    message: format!("candidate calls unexpected tool `{name}`"),
                },
            );
        }
    }
}

fn diff_args(id: &str, a: &[ToolCall], b: &[ToolCall], diff: &mut BaselineDiff) {
    for (left, right) in a.iter().zip(b.iter()) {
        if left.arguments == right.arguments {
            // Values are structurally equal. `serde_json::Value`'s
            // default `BTreeMap` backing collapses source key order, so
            // we fall back to captured raw bytes to surface key-reorder
            // drift as required by ADR 0026 (`structurally-equivalent`).
            if raw_args_diverge(
                left.raw_arguments.as_deref(),
                right.raw_arguments.as_deref(),
            ) {
                push(
                    diff,
                    DiffEntry {
                        test_id: id.into(),
                        category: ChangeCategory::ToolArgs,
                        severity: Severity::Drift,
                        message: format!(
                            "tool `{}` args: object keys reordered (structurally-equivalent)",
                            left.name
                        ),
                    },
                );
            }
            continue;
        }
        let key_diff = classify_args_change(&left.arguments, &right.arguments);
        push(
            diff,
            DiffEntry {
                test_id: id.into(),
                category: ChangeCategory::ToolArgs,
                severity: key_diff.severity,
                message: format!(
                    "tool `{}` args: {} ({:?} -> {:?})",
                    left.name, key_diff.note, left.arguments, right.arguments
                ),
            },
        );
    }
}

/// Decide whether two captured raw-JSON snippets warrant a key-reorder
/// drift entry. We only signal when both sides carry source bytes and
/// they disagree; absent raw evidence we stay silent (a caller that
/// never captured raw bytes gets the legacy PASS behavior). The
/// captured strings come from before BTreeMap reorders the parsed
/// `Value`, so a difference here means source-level reorder or
/// whitespace drift (both `structurally-equivalent` in ADR 0026 terms).
fn raw_args_diverge(a: Option<&str>, b: Option<&str>) -> bool {
    match (a, b) {
        (Some(x), Some(y)) => x != y,
        _ => false,
    }
}

struct ArgClassification {
    severity: Severity,
    note: &'static str,
}

fn classify_args_change(a: &serde_json::Value, b: &serde_json::Value) -> ArgClassification {
    let (Some(am), Some(bm)) = (a.as_object(), b.as_object()) else {
        return ArgClassification {
            severity: Severity::Fail,
            note: "argument value changed",
        };
    };

    let keys_a: BTreeMap<_, _> = am.iter().collect();
    let keys_b: BTreeMap<_, _> = bm.iter().collect();
    if keys_a == keys_b {
        return ArgClassification {
            severity: Severity::Drift,
            note: "object keys reordered",
        };
    }

    if am.keys().all(|k| bm.get(k) == am.get(k)) && bm.len() > am.len() {
        return ArgClassification {
            severity: Severity::Drift,
            note: "new optional field added",
        };
    }

    if bm.keys().all(|k| am.get(k) == bm.get(k)) && am.len() > bm.len() {
        return ArgClassification {
            severity: Severity::Fail,
            note: "argument removed",
        };
    }

    for (k, va) in am {
        if let Some(vb) = bm.get(k) {
            if va != vb {
                if va.is_number() != vb.is_number() || va.is_string() != vb.is_string() {
                    return ArgClassification {
                        severity: Severity::Fail,
                        note: "argument type changed",
                    };
                }
                return ArgClassification {
                    severity: Severity::Fail,
                    note: "argument value changed",
                };
            }
        }
    }

    ArgClassification {
        severity: Severity::Fail,
        note: "argument bag changed",
    }
}

fn diff_content(id: &str, a: &[ContentBlock], b: &[ContentBlock], diff: &mut BaselineDiff) {
    if a == b {
        return;
    }
    if a.len() != b.len() {
        push(
            diff,
            DiffEntry {
                test_id: id.into(),
                category: ChangeCategory::ResponseShape,
                severity: Severity::Fail,
                message: format!("content block count: {} vs {}", a.len(), b.len()),
            },
        );
        return;
    }
    for (left, right) in a.iter().zip(b.iter()) {
        match (left, right) {
            (ContentBlock::Text { text: t1 }, ContentBlock::Text { text: t2 }) => {
                let sev = classify_text_change(t1, t2);
                push(
                    diff,
                    DiffEntry {
                        test_id: id.into(),
                        category: ChangeCategory::ResponseSemantics,
                        severity: sev.0,
                        message: format!("{}: {t1:?} -> {t2:?}", sev.1),
                    },
                );
            }
            _ => {
                push(
                    diff,
                    DiffEntry {
                        test_id: id.into(),
                        category: ChangeCategory::ResponseShape,
                        severity: Severity::Fail,
                        message: "content block type changed".into(),
                    },
                );
            }
        }
    }
}

fn classify_text_change(a: &str, b: &str) -> (Severity, &'static str) {
    if a == b {
        (Severity::Pass, "text identical")
    } else if a.eq_ignore_ascii_case(b) {
        (Severity::Drift, "case-only change")
    } else if collapse_ws(a) == collapse_ws(b) {
        (Severity::Drift, "whitespace-only change")
    } else {
        // Surface text rephrasing as DRIFT (semantic-equivalent in the
        // OSS engine). A jury backend would refine this verdict; the
        // OSS default keeps it visible without failing the run.
        (Severity::Drift, "text rephrased")
    }
}

fn collapse_ws(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

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

    fn call(name: &str, args: serde_json::Value, raw: Option<&str>) -> ToolCall {
        ToolCall {
            name: name.into(),
            arguments: args,
            raw_arguments: raw.map(str::to_owned),
        }
    }

    #[test]
    fn structurally_equal_args_with_reordered_raw_bytes_yield_drift() {
        let left = vec![call(
            "search_users",
            serde_json::json!({"name": "alice", "limit": 10}),
            Some(r#"{"name":"alice","limit":10}"#),
        )];
        let right = vec![call(
            "search_users",
            serde_json::json!({"limit": 10, "name": "alice"}),
            Some(r#"{"limit":10,"name":"alice"}"#),
        )];
        let mut diff = BaselineDiff::default();
        diff_args("t::a::1", &left, &right, &mut diff);
        assert_eq!(diff.entries.len(), 1);
        assert_eq!(diff.entries[0].severity, Severity::Drift);
        assert!(diff.entries[0].message.contains("object keys reordered"));
    }

    #[test]
    fn structurally_equal_args_without_raw_bytes_emit_nothing() {
        // No raw evidence on either side: the engine cannot see the
        // reorder and stays silent (legacy PASS behavior). This keeps
        // old baselines without raw_arguments from spuriously failing.
        let left = vec![call("f", serde_json::json!({"a": 1, "b": 2}), None)];
        let right = vec![call("f", serde_json::json!({"b": 2, "a": 1}), None)];
        let mut diff = BaselineDiff::default();
        diff_args("t::a::1", &left, &right, &mut diff);
        assert!(diff.entries.is_empty());
    }

    #[test]
    fn equal_raw_bytes_emit_nothing() {
        let left = vec![call("f", serde_json::json!({"a": 1}), Some(r#"{"a":1}"#))];
        let right = vec![call("f", serde_json::json!({"a": 1}), Some(r#"{"a":1}"#))];
        let mut diff = BaselineDiff::default();
        diff_args("t::a::1", &left, &right, &mut diff);
        assert!(diff.entries.is_empty());
    }
}
