use anyhow::{Context, Result};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

// ── Data Structures ─────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct DiffResult {
    pub rules_added: Vec<String>,
    pub rules_removed: Vec<String>,
    pub rules_modified: Vec<RuleChange>,
    pub sources_added: Vec<String>,
    pub sources_removed: Vec<String>,
    pub sources_modified: Vec<SourceChange>,
    pub entity_changed: bool,
    pub entity_changes: Vec<FieldChange>,
    pub blocking_changed: bool,
    pub blocking_changes: Vec<FieldChange>,
    pub thresholds_changed: bool,
    pub survivorship_changed: bool,
    pub survivorship_changes: Vec<FieldChange>,
    pub scoring_changed: bool,
    pub scoring_changes: Vec<FieldChange>,
    pub decision_changes: Vec<FieldChange>,
    pub metadata_changed: bool,
    pub metadata_changes: Vec<FieldChange>,
    pub version_changed: bool,
    pub summary: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct RuleChange {
    pub name: String,
    pub field: String,
    pub old_value: String,
    pub new_value: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SourceChange {
    pub name: String,
    pub field: String,
    pub old_value: String,
    pub new_value: String,
}

/// Generic field-level change for sections that are not rules/sources.
#[derive(Debug, Serialize, Deserialize)]
pub struct FieldChange {
    pub path: String,
    pub old_value: String,
    pub new_value: String,
}

// ── CLI entry point ─────────────────────────────────────────────────

pub fn run(file1: &Path, file2: &Path) -> Result<()> {
    let content1 = fs::read_to_string(file1)
        .with_context(|| format!("Failed to read file: {}", file1.display()))?;
    let content2 = fs::read_to_string(file2)
        .with_context(|| format!("Failed to read file: {}", file2.display()))?;

    let diff = compute_diff(&content1, &content2)?;

    println!(
        "{} {} vs {}",
        "Comparing:".bold(),
        file1.display(),
        file2.display()
    );
    println!();

    let mut any_change = false;

    // Version
    if diff.version_changed {
        any_change = true;
        println!("{}", "Version Changed".cyan().bold());
        println!();
    }

    // Entity
    if diff.entity_changed {
        any_change = true;
        println!("{}:", "Entity Changed".magenta().bold());
        for c in &diff.entity_changes {
            println!("  ~ {} : {} -> {}", c.path.yellow(), c.old_value.red(), c.new_value.green());
        }
        println!();
    }

    // Sources added
    if !diff.sources_added.is_empty() {
        any_change = true;
        println!("{}:", "Sources Added".green().bold());
        for s in &diff.sources_added {
            println!("  + {}", s.green());
        }
        println!();
    }

    // Sources removed
    if !diff.sources_removed.is_empty() {
        any_change = true;
        println!("{}:", "Sources Removed".red().bold());
        for s in &diff.sources_removed {
            println!("  - {}", s.red());
        }
        println!();
    }

    // Sources modified
    if !diff.sources_modified.is_empty() {
        any_change = true;
        println!("{}:", "Sources Modified".yellow().bold());
        for m in &diff.sources_modified {
            println!(
                "  ~ {}.{} : {} -> {}",
                m.name.bold(),
                m.field.yellow(),
                m.old_value.red(),
                m.new_value.green()
            );
        }
        println!();
    }

    // Blocking
    if diff.blocking_changed {
        any_change = true;
        println!("{}:", "Blocking Changed".magenta().bold());
        for c in &diff.blocking_changes {
            println!("  ~ {} : {} -> {}", c.path.yellow(), c.old_value.red(), c.new_value.green());
        }
        println!();
    }

    // Rules added
    if !diff.rules_added.is_empty() {
        any_change = true;
        println!("{}:", "Rules Added".green().bold());
        for r in &diff.rules_added {
            println!("  + {}", r.green());
        }
        println!();
    }

    // Rules removed
    if !diff.rules_removed.is_empty() {
        any_change = true;
        println!("{}:", "Rules Removed".red().bold());
        for r in &diff.rules_removed {
            println!("  - {}", r.red());
        }
        println!();
    }

    // Rules modified
    if !diff.rules_modified.is_empty() {
        any_change = true;
        println!("{}:", "Rules Modified".yellow().bold());
        for m in &diff.rules_modified {
            println!(
                "  ~ {}.{} : {} -> {}",
                m.name.bold(),
                m.field.yellow(),
                m.old_value.red(),
                m.new_value.green()
            );
        }
        println!();
    }

    // Survivorship
    if diff.survivorship_changed {
        any_change = true;
        println!("{}:", "Survivorship Changed".magenta().bold());
        for c in &diff.survivorship_changes {
            println!("  ~ {} : {} -> {}", c.path.yellow(), c.old_value.red(), c.new_value.green());
        }
        println!();
    }

    // Decision / Scoring / Thresholds
    if diff.scoring_changed || diff.thresholds_changed || !diff.decision_changes.is_empty() {
        any_change = true;
        println!("{}:", "Decision Changed".cyan().bold());
        if diff.thresholds_changed {
            println!("  {} Thresholds changed", "~".yellow());
        }
        if diff.scoring_changed {
            println!("  {} Scoring changed", "~".yellow());
        }
        for c in &diff.scoring_changes {
            println!("    ~ {} : {} -> {}", c.path.yellow(), c.old_value.red(), c.new_value.green());
        }
        for c in &diff.decision_changes {
            println!("  ~ {} : {} -> {}", c.path.yellow(), c.old_value.red(), c.new_value.green());
        }
        println!();
    }

    // Metadata
    if diff.metadata_changed {
        any_change = true;
        println!("{}:", "Metadata Changed".blue().bold());
        for c in &diff.metadata_changes {
            println!("  ~ {} : {} -> {}", c.path.yellow(), c.old_value.red(), c.new_value.green());
        }
        println!();
    }

    if !any_change {
        println!("{}", "No changes detected.".dimmed());
    }

    println!("{}: {}", "Summary".bold(), diff.summary);
    Ok(())
}

// ── Core diff computation ───────────────────────────────────────────

pub fn compute_diff(content1: &str, content2: &str) -> Result<DiffResult> {
    let spec1: Value = serde_yaml::from_str(content1)?;
    let spec2: Value = serde_yaml::from_str(content2)?;

    let mut diff = DiffResult::default();

    // ── Version ─────────────────────────────────────────────────
    let v1 = spec1.get("identity_version").and_then(|v| v.as_str()).unwrap_or("unknown");
    let v2 = spec2.get("identity_version").and_then(|v| v.as_str()).unwrap_or("unknown");
    if v1 != v2 {
        diff.version_changed = true;
    }

    // ── Entity ──────────────────────────────────────────────────
    diff_entity(&spec1, &spec2, &mut diff);

    // ── Sources ─────────────────────────────────────────────────
    diff_sources(&spec1, &spec2, &mut diff);

    // ── Blocking ────────────────────────────────────────────────
    diff_blocking(&spec1, &spec2, &mut diff);

    // ── Rules ───────────────────────────────────────────────────
    diff_rules(&spec1, &spec2, &mut diff);

    // ── Survivorship ────────────────────────────────────────────
    diff_survivorship(&spec1, &spec2, &mut diff);

    // ── Decision (thresholds, scoring, and other keys) ──────────
    diff_decision(&spec1, &spec2, &mut diff);

    // ── Metadata ────────────────────────────────────────────────
    diff_metadata(&spec1, &spec2, &mut diff);

    // ── Build summary ───────────────────────────────────────────
    let mut parts: Vec<String> = Vec::new();
    if diff.version_changed {
        parts.push(format!("version: {} -> {}", v1, v2));
    }
    if diff.entity_changed {
        parts.push(format!("{} entity change(s)", diff.entity_changes.len()));
    }
    if !diff.sources_added.is_empty() || !diff.sources_removed.is_empty() || !diff.sources_modified.is_empty() {
        parts.push(format!(
            "sources: +{} -{} ~{}",
            diff.sources_added.len(),
            diff.sources_removed.len(),
            diff.sources_modified.len()
        ));
    }
    if diff.blocking_changed {
        parts.push(format!("{} blocking change(s)", diff.blocking_changes.len()));
    }
    if !diff.rules_added.is_empty() || !diff.rules_removed.is_empty() || !diff.rules_modified.is_empty() {
        parts.push(format!(
            "rules: +{} -{} ~{}",
            diff.rules_added.len(),
            diff.rules_removed.len(),
            diff.rules_modified.len()
        ));
    }
    if diff.survivorship_changed {
        parts.push(format!("{} survivorship change(s)", diff.survivorship_changes.len()));
    }
    if diff.thresholds_changed {
        parts.push("thresholds changed".to_string());
    }
    if diff.scoring_changed {
        parts.push(format!("{} scoring change(s)", diff.scoring_changes.len()));
    }
    if !diff.decision_changes.is_empty() {
        parts.push(format!("{} other decision change(s)", diff.decision_changes.len()));
    }
    if diff.metadata_changed {
        parts.push(format!("{} metadata change(s)", diff.metadata_changes.len()));
    }

    diff.summary = if parts.is_empty() {
        "No changes detected.".to_string()
    } else {
        parts.join("; ")
    };

    Ok(diff)
}

// ── Section diffing helpers ─────────────────────────────────────────

/// Compare the `entity` section (name, description, compliance, hierarchy, etc.)
fn diff_entity(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let e1 = spec1.get("entity");
    let e2 = spec2.get("entity");

    match (e1, e2) {
        (Some(a), Some(b)) if a != b => {
            diff.entity_changed = true;
            collect_value_changes("entity", a, b, &mut diff.entity_changes);
        }
        (None, Some(_)) => {
            diff.entity_changed = true;
            diff.entity_changes.push(FieldChange {
                path: "entity".to_string(),
                old_value: "<absent>".to_string(),
                new_value: "<present>".to_string(),
            });
        }
        (Some(_), None) => {
            diff.entity_changed = true;
            diff.entity_changes.push(FieldChange {
                path: "entity".to_string(),
                old_value: "<present>".to_string(),
                new_value: "<absent>".to_string(),
            });
        }
        _ => {}
    }
}

/// Compare the `sources` array. Match sources by `name` field.
fn diff_sources(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let sources1 = spec1.get("sources").and_then(|v| v.as_array());
    let sources2 = spec2.get("sources").and_then(|v| v.as_array());

    let empty = vec![];
    let s1 = sources1.unwrap_or(&empty);
    let s2 = sources2.unwrap_or(&empty);

    let names1: Vec<&str> = s1.iter().filter_map(|s| source_name(s)).collect();
    let names2: Vec<&str> = s2.iter().filter_map(|s| source_name(s)).collect();

    // Added
    for name in &names2 {
        if !names1.contains(name) {
            diff.sources_added.push(name.to_string());
        }
    }

    // Removed
    for name in &names1 {
        if !names2.contains(name) {
            diff.sources_removed.push(name.to_string());
        }
    }

    // Modified — compare all fields on matching sources
    for name in &names1 {
        if !names2.contains(name) {
            continue;
        }
        let src1 = s1.iter().find(|s| source_name(s) == Some(name)).unwrap();
        let src2 = s2.iter().find(|s| source_name(s) == Some(name)).unwrap();

        if src1 == src2 {
            continue;
        }

        // Collect all keys from both sources
        let keys = merged_object_keys(src1, src2);

        for key in &keys {
            if key == "name" {
                continue; // name is the identity key, skip
            }
            let v1 = src1.get(key.as_str());
            let v2 = src2.get(key.as_str());
            if v1 != v2 {
                diff.sources_modified.push(SourceChange {
                    name: name.to_string(),
                    field: key.clone(),
                    old_value: format_value(v1),
                    new_value: format_value(v2),
                });
            }
        }
    }
}

/// Compare the `blocking` section as a whole.
fn diff_blocking(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let b1 = spec1.get("blocking");
    let b2 = spec2.get("blocking");

    match (b1, b2) {
        (Some(a), Some(b)) if a != b => {
            diff.blocking_changed = true;
            collect_value_changes("blocking", a, b, &mut diff.blocking_changes);
        }
        (None, Some(_)) => {
            diff.blocking_changed = true;
            diff.blocking_changes.push(FieldChange {
                path: "blocking".to_string(),
                old_value: "<absent>".to_string(),
                new_value: "<present>".to_string(),
            });
        }
        (Some(_), None) => {
            diff.blocking_changed = true;
            diff.blocking_changes.push(FieldChange {
                path: "blocking".to_string(),
                old_value: "<present>".to_string(),
                new_value: "<absent>".to_string(),
            });
        }
        _ => {}
    }
}

/// Compare the `rules` array. Match rules by `name`, then compare ALL fields.
fn diff_rules(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let rules1 = spec1.get("rules").and_then(|r| r.as_array());
    let rules2 = spec2.get("rules").and_then(|r| r.as_array());

    // Also support `matching` as an alternative key for rules
    let rules1 = rules1.or_else(|| spec1.get("matching").and_then(|r| r.as_array()));
    let rules2 = rules2.or_else(|| spec2.get("matching").and_then(|r| r.as_array()));

    let empty = vec![];
    let r1 = rules1.unwrap_or(&empty);
    let r2 = rules2.unwrap_or(&empty);

    let names1: Vec<&str> = r1.iter().filter_map(|r| rule_name(r)).collect();
    let names2: Vec<&str> = r2.iter().filter_map(|r| rule_name(r)).collect();

    // Added
    for name in &names2 {
        if !names1.contains(name) {
            diff.rules_added.push(name.to_string());
        }
    }

    // Removed
    for name in &names1 {
        if !names2.contains(name) {
            diff.rules_removed.push(name.to_string());
        }
    }

    // Modified — compare ALL fields on matching rules
    for name in &names1 {
        if !names2.contains(name) {
            continue;
        }
        let rule1 = r1.iter().find(|r| rule_name(r) == Some(name)).unwrap();
        let rule2 = r2.iter().find(|r| rule_name(r) == Some(name)).unwrap();

        if rule1 == rule2 {
            continue;
        }

        // Gather every key present on either rule object
        let keys = merged_object_keys(rule1, rule2);

        for key in &keys {
            // Skip the identity key itself
            if key == "name" || key == "rule" {
                continue;
            }
            let v1 = rule1.get(key.as_str());
            let v2 = rule2.get(key.as_str());
            if v1 != v2 {
                diff.rules_modified.push(RuleChange {
                    name: name.to_string(),
                    field: key.clone(),
                    old_value: format_value(v1),
                    new_value: format_value(v2),
                });
            }
        }
    }
}

/// Compare the `survivorship` section.
fn diff_survivorship(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let s1 = spec1.get("survivorship");
    let s2 = spec2.get("survivorship");

    match (s1, s2) {
        (Some(a), Some(b)) if a != b => {
            diff.survivorship_changed = true;
            collect_value_changes("survivorship", a, b, &mut diff.survivorship_changes);
        }
        (None, Some(_)) => {
            diff.survivorship_changed = true;
            diff.survivorship_changes.push(FieldChange {
                path: "survivorship".to_string(),
                old_value: "<absent>".to_string(),
                new_value: "<present>".to_string(),
            });
        }
        (Some(_), None) => {
            diff.survivorship_changed = true;
            diff.survivorship_changes.push(FieldChange {
                path: "survivorship".to_string(),
                old_value: "<present>".to_string(),
                new_value: "<absent>".to_string(),
            });
        }
        _ => {}
    }
}

/// Compare the `decision` section: thresholds, scoring, and all other keys.
fn diff_decision(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let d1 = spec1.get("decision");
    let d2 = spec2.get("decision");

    match (d1, d2) {
        (Some(dec1), Some(dec2)) if dec1 != dec2 => {
            // Thresholds sub-section
            let t1 = dec1.get("thresholds");
            let t2 = dec2.get("thresholds");
            if t1 != t2 {
                diff.thresholds_changed = true;
            }

            // Scoring sub-section
            let s1 = dec1.get("scoring");
            let s2 = dec2.get("scoring");
            if s1 != s2 {
                diff.scoring_changed = true;
                if let (Some(a), Some(b)) = (s1, s2) {
                    collect_value_changes("decision.scoring", a, b, &mut diff.scoring_changes);
                } else {
                    diff.scoring_changes.push(FieldChange {
                        path: "decision.scoring".to_string(),
                        old_value: format_value(s1),
                        new_value: format_value(s2),
                    });
                }
            }

            // All other decision keys (conflict_strategy, review_queue, audit, etc.)
            let keys = merged_object_keys(dec1, dec2);
            for key in &keys {
                if key == "thresholds" || key == "scoring" {
                    continue; // handled above
                }
                let v1 = dec1.get(key.as_str());
                let v2 = dec2.get(key.as_str());
                if v1 != v2 {
                    diff.decision_changes.push(FieldChange {
                        path: format!("decision.{}", key),
                        old_value: format_value(v1),
                        new_value: format_value(v2),
                    });
                }
            }
        }
        (None, Some(_)) => {
            diff.thresholds_changed = true;
            diff.decision_changes.push(FieldChange {
                path: "decision".to_string(),
                old_value: "<absent>".to_string(),
                new_value: "<present>".to_string(),
            });
        }
        (Some(_), None) => {
            diff.thresholds_changed = true;
            diff.decision_changes.push(FieldChange {
                path: "decision".to_string(),
                old_value: "<present>".to_string(),
                new_value: "<absent>".to_string(),
            });
        }
        _ => {}
    }
}

/// Compare the `metadata` section.
fn diff_metadata(spec1: &Value, spec2: &Value, diff: &mut DiffResult) {
    let m1 = spec1.get("metadata");
    let m2 = spec2.get("metadata");

    match (m1, m2) {
        (Some(a), Some(b)) if a != b => {
            diff.metadata_changed = true;
            collect_value_changes("metadata", a, b, &mut diff.metadata_changes);
        }
        (None, Some(_)) => {
            diff.metadata_changed = true;
            diff.metadata_changes.push(FieldChange {
                path: "metadata".to_string(),
                old_value: "<absent>".to_string(),
                new_value: "<present>".to_string(),
            });
        }
        (Some(_), None) => {
            diff.metadata_changed = true;
            diff.metadata_changes.push(FieldChange {
                path: "metadata".to_string(),
                old_value: "<present>".to_string(),
                new_value: "<absent>".to_string(),
            });
        }
        _ => {}
    }
}

// ── Utility helpers ─────────────────────────────────────────────────

/// Extract the name of a source object. Supports `name` key.
fn source_name(v: &Value) -> Option<&str> {
    v.get("name").and_then(|n| n.as_str())
}

/// Extract the name of a rule object. Supports both `name` and `rule` keys.
fn rule_name(v: &Value) -> Option<&str> {
    v.get("name")
        .or_else(|| v.get("rule"))
        .and_then(|n| n.as_str())
}

/// Collect all unique keys from two JSON objects, sorted for deterministic output.
fn merged_object_keys(a: &Value, b: &Value) -> Vec<String> {
    let mut keys = BTreeSet::new();
    if let Some(obj) = a.as_object() {
        for k in obj.keys() {
            keys.insert(k.clone());
        }
    }
    if let Some(obj) = b.as_object() {
        for k in obj.keys() {
            keys.insert(k.clone());
        }
    }
    keys.into_iter().collect()
}

/// Format a JSON Value for display. `None` becomes `<absent>`.
fn format_value(v: Option<&Value>) -> String {
    match v {
        None => "<absent>".to_string(),
        Some(Value::String(s)) => s.clone(),
        Some(Value::Null) => "null".to_string(),
        Some(val) => {
            // For compact display, use single-line JSON.
            serde_json::to_string(val).unwrap_or_else(|_| format!("{:?}", val))
        }
    }
}

/// Recursively collect leaf-level changes between two JSON values, producing
/// `FieldChange` entries with dotted paths. For objects, descends into each key.
/// For arrays and scalars, reports the top-level difference.
fn collect_value_changes(prefix: &str, a: &Value, b: &Value, out: &mut Vec<FieldChange>) {
    if a == b {
        return;
    }

    match (a, b) {
        (Value::Object(map_a), Value::Object(map_b)) => {
            let mut keys = BTreeSet::new();
            for k in map_a.keys() {
                keys.insert(k.clone());
            }
            for k in map_b.keys() {
                keys.insert(k.clone());
            }

            for key in keys {
                let path = format!("{}.{}", prefix, key);
                let v1 = map_a.get(&key);
                let v2 = map_b.get(&key);
                match (v1, v2) {
                    (Some(va), Some(vb)) => {
                        collect_value_changes(&path, va, vb, out);
                    }
                    _ => {
                        if v1 != v2 {
                            out.push(FieldChange {
                                path,
                                old_value: format_value(v1),
                                new_value: format_value(v2),
                            });
                        }
                    }
                }
            }
        }
        _ => {
            // Scalar or array — report the difference at this level
            out.push(FieldChange {
                path: prefix.to_string(),
                old_value: format_value(Some(a)),
                new_value: format_value(Some(b)),
            });
        }
    }
}

// ── Tests ───────────────────────────────────────────────────────────

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

    fn minimal_spec() -> &'static str {
        r#"
api_version: kanoniv/v2
identity_version: retail_v1.0
entity:
  name: customer
sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
decision:
  thresholds:
    match: 0.9
"#
    }

    #[test]
    fn no_changes_detected() {
        let diff = compute_diff(minimal_spec(), minimal_spec()).unwrap();
        assert!(diff.rules_added.is_empty());
        assert!(diff.rules_removed.is_empty());
        assert!(diff.rules_modified.is_empty());
        assert!(diff.sources_added.is_empty());
        assert!(diff.sources_removed.is_empty());
        assert!(diff.sources_modified.is_empty());
        assert!(!diff.entity_changed);
        assert!(!diff.blocking_changed);
        assert!(!diff.thresholds_changed);
        assert!(!diff.survivorship_changed);
        assert!(!diff.scoring_changed);
        assert!(!diff.version_changed);
        assert!(diff.summary.contains("No changes"));
    }

    #[test]
    fn detects_rule_addition_and_removal() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: phone_exact
    type: exact
    field: phone
    weight: 0.9
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: name_fuzzy
    type: fuzzy
    field: name
    algorithm: jaro_winkler
    threshold: 0.85
    weight: 0.6
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert_eq!(diff.rules_added, vec!["name_fuzzy"]);
        assert_eq!(diff.rules_removed, vec!["phone_exact"]);
    }

    #[test]
    fn detects_all_rule_field_changes() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules:
  - name: name_match
    type: exact
    field: name
    weight: 1.0
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules:
  - name: name_match
    type: fuzzy
    field: full_name
    algorithm: jaro_winkler
    threshold: 0.85
    weight: 0.7
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.rules_added.is_empty());
        assert!(diff.rules_removed.is_empty());
        let fields: Vec<&str> = diff.rules_modified.iter().map(|c| c.field.as_str()).collect();
        assert!(fields.contains(&"type"), "expected type change: {:?}", fields);
        assert!(fields.contains(&"field"), "expected field change: {:?}", fields);
        assert!(fields.contains(&"algorithm"), "expected algorithm change: {:?}", fields);
        assert!(fields.contains(&"threshold"), "expected threshold change: {:?}", fields);
        assert!(fields.contains(&"weight"), "expected weight change: {:?}", fields);
    }

    #[test]
    fn detects_source_changes() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email
  - name: erp
    system: sap
    table: customers
    id: cust_id
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources:
  - name: crm
    system: hubspot
    table: contacts
    id: contact_id
    attributes:
      email: email_address
  - name: ecommerce
    system: shopify
    table: orders
    id: order_id
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert_eq!(diff.sources_added, vec!["ecommerce"]);
        assert_eq!(diff.sources_removed, vec!["erp"]);
        assert!(!diff.sources_modified.is_empty());
        let modified_fields: Vec<(&str, &str)> = diff.sources_modified.iter()
            .map(|c| (c.name.as_str(), c.field.as_str()))
            .collect();
        assert!(modified_fields.contains(&("crm", "system")));
        assert!(modified_fields.contains(&("crm", "attributes")));
    }

    #[test]
    fn detects_entity_change() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: account
  description: "Business account"
sources: []
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.entity_changed);
        assert!(!diff.entity_changes.is_empty());
    }

    #[test]
    fn detects_blocking_change() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
blocking:
  keys:
    - [email_domain]
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
blocking:
  strategy: composite
  keys:
    - [email_domain]
    - [phone_area_code, last_name_initial]
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.blocking_changed);
    }

    #[test]
    fn detects_survivorship_change() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
survivorship:
  default: most_recent
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
survivorship:
  default: source_priority
  overrides:
    - field: email
      strategy: most_complete
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.survivorship_changed);
        assert!(!diff.survivorship_changes.is_empty());
    }

    #[test]
    fn detects_scoring_and_decision_changes() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
decision:
  scoring:
    method: weighted_sum
  thresholds:
    match: 0.9
    review: 0.7
  conflict_strategy: prefer_high_confidence
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
entity:
  name: customer
sources: []
rules: []
decision:
  scoring:
    method: ml_ensemble
    normalization: z_score
  thresholds:
    match: 0.85
    review: 0.7
  conflict_strategy: manual_review
  review_queue:
    enabled: true
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.thresholds_changed);
        assert!(diff.scoring_changed);
        assert!(!diff.scoring_changes.is_empty());
        assert!(!diff.decision_changes.is_empty());
        let decision_paths: Vec<&str> = diff.decision_changes.iter()
            .map(|c| c.path.as_str())
            .collect();
        assert!(decision_paths.contains(&"decision.conflict_strategy"));
        assert!(decision_paths.contains(&"decision.review_queue"));
    }

    #[test]
    fn detects_version_change() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1.0
entity:
  name: customer
sources: []
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v2.0
entity:
  name: customer
sources: []
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.version_changed);
        assert!(diff.summary.contains("v1.0"));
        assert!(diff.summary.contains("v2.0"));
    }

    #[test]
    fn detects_metadata_change() {
        let spec_a = r#"
api_version: kanoniv/v2
identity_version: v1
metadata:
  name: "Customer Identity"
  owner: team-a
entity:
  name: customer
sources: []
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let spec_b = r#"
api_version: kanoniv/v2
identity_version: v1
metadata:
  name: "Customer Identity v2"
  owner: team-b
  tags: [retail, b2c]
entity:
  name: customer
sources: []
rules: []
decision:
  thresholds:
    match: 0.9
"#;
        let diff = compute_diff(spec_a, spec_b).unwrap();
        assert!(diff.metadata_changed);
        assert!(!diff.metadata_changes.is_empty());
    }

    #[test]
    fn backward_compatible_serialization() {
        // Ensure DiffResult can round-trip through serde_json (PyO3 bridge requirement)
        let diff = DiffResult {
            rules_added: vec!["rule_a".into()],
            rules_removed: vec!["rule_b".into()],
            rules_modified: vec![RuleChange {
                name: "r1".into(),
                field: "weight".into(),
                old_value: "0.5".into(),
                new_value: "0.8".into(),
            }],
            sources_added: vec!["src_a".into()],
            sources_removed: vec![],
            sources_modified: vec![SourceChange {
                name: "crm".into(),
                field: "system".into(),
                old_value: "salesforce".into(),
                new_value: "hubspot".into(),
            }],
            entity_changed: true,
            entity_changes: vec![FieldChange {
                path: "entity.name".into(),
                old_value: "customer".into(),
                new_value: "account".into(),
            }],
            blocking_changed: false,
            blocking_changes: vec![],
            thresholds_changed: true,
            survivorship_changed: false,
            survivorship_changes: vec![],
            scoring_changed: false,
            scoring_changes: vec![],
            decision_changes: vec![],
            metadata_changed: false,
            metadata_changes: vec![],
            version_changed: true,
            summary: "test summary".into(),
        };

        let json = serde_json::to_value(&diff).unwrap();
        assert!(json.is_object());
        // Ensure new fields are present in serialized output
        assert!(json.get("sources_added").is_some());
        assert!(json.get("sources_modified").is_some());
        assert!(json.get("entity_changed").is_some());
        assert!(json.get("blocking_changed").is_some());
        assert!(json.get("survivorship_changed").is_some());
        assert!(json.get("scoring_changed").is_some());
        assert!(json.get("metadata_changed").is_some());
        assert!(json.get("version_changed").is_some());

        // Ensure it can be deserialized back
        let round_tripped: DiffResult = serde_json::from_value(json).unwrap();
        assert_eq!(round_tripped.rules_added, vec!["rule_a"]);
        assert!(round_tripped.entity_changed);
        assert!(round_tripped.version_changed);
    }
}
