//! What changed between two JSON documents, as a reader of the old one sees it: members
//! and entries that went away, values that changed, and members and entries that are new.
//!
//! An array whose entries are objects named by one member (`name`, `file`, `id`, …) is
//! compared entry by entry under that name, so a corpus that gains a vector shows one added
//! entry, not a shifted list. Any other array is compared position by position, and one
//! that only grew at its end shows the new positions as added. Prose members (`about`,
//! `description`, `note`, `notes`, `comment`) are not compared: rewording them changes no
//! byte a reader acts on.

use serde_json::Value;

/// Members never compared: prose about the document, not data a reader acts on.
pub const PROSE: &[&str] = &["about", "description", "note", "notes", "comment"];

/// The members an array's entries may be named by, in the order they are tried.
const ENTRY_KEYS: &[&str] = &["name", "file", "id", "capability", "identifier", "label", "case"];

/// Paths (`$.vectors[name=x].expected`) of what was removed, changed and added.
#[derive(Default, Debug)]
pub struct Diff {
    pub removed: Vec<String>,
    pub changed: Vec<String>,
    pub added: Vec<String>,
}

impl Diff {
    pub fn breaks(&self) -> bool {
        !self.removed.is_empty() || !self.changed.is_empty()
    }
    pub fn is_empty(&self) -> bool {
        !self.breaks() && self.added.is_empty()
    }
}

/// The difference from `old` to `new`.
pub fn diff(old: &Value, new: &Value) -> Diff {
    let mut d = Diff::default();
    walk(old, new, "$", &mut d);
    d
}

/// A value shortened for a message.
pub fn short(v: &Value) -> String {
    let s = v.to_string();
    if s.chars().count() > 72 { format!("{}…", s.chars().take(72).collect::<String>()) } else { s }
}

fn entry_key(a: &[Value], b: &[Value]) -> Option<&'static str> {
    ENTRY_KEYS.iter().copied().find(|k| {
        let named = |xs: &[Value]| {
            let mut seen = std::collections::HashSet::new();
            xs.iter().all(|x| x.get(k).and_then(Value::as_str).is_some_and(|n| seen.insert(n.to_string())))
        };
        !(a.is_empty() && b.is_empty()) && named(a) && named(b)
    })
}

fn walk(old: &Value, new: &Value, path: &str, d: &mut Diff) {
    match (old, new) {
        (Value::Object(a), Value::Object(b)) => {
            for (k, av) in a {
                if PROSE.contains(&k.as_str()) {
                    continue;
                }
                let p = format!("{path}.{k}");
                match b.get(k) {
                    None => d.removed.push(p),
                    Some(bv) => walk(av, bv, &p, d),
                }
            }
            for k in b.keys() {
                if !a.contains_key(k) && !PROSE.contains(&k.as_str()) {
                    d.added.push(format!("{path}.{k}"));
                }
            }
        }
        (Value::Array(a), Value::Array(b)) => {
            if let Some(key) = entry_key(a, b) {
                let name = |x: &Value| x.get(key).and_then(Value::as_str).unwrap_or_default().to_string();
                let bm: std::collections::HashMap<String, &Value> = b.iter().map(|x| (name(x), x)).collect();
                let am: std::collections::HashSet<String> = a.iter().map(name).collect();
                for x in a {
                    let n = name(x);
                    let p = format!("{path}[{key}={n}]");
                    match bm.get(&n) {
                        None => d.removed.push(p),
                        Some(y) => walk(x, y, &p, d),
                    }
                }
                for y in b {
                    let n = name(y);
                    if !am.contains(&n) {
                        d.added.push(format!("{path}[{key}={n}]"));
                    }
                }
            } else {
                for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
                    walk(x, y, &format!("{path}[{i}]"), d);
                }
                for i in b.len()..a.len() {
                    d.removed.push(format!("{path}[{i}]"));
                }
                for i in a.len()..b.len() {
                    d.added.push(format!("{path}[{i}]"));
                }
            }
        }
        (x, y) if x == y => {}
        (x, y) => d.changed.push(format!("{path}: {} → {}", short(x), short(y))),
    }
}

/// The difference between two JSON Lines documents: lines that are gone, and lines that
/// are new. Lines are compared as text.
pub fn diff_lines(old: &str, new: &str) -> Diff {
    let a: std::collections::HashSet<&str> = old.lines().filter(|l| !l.trim().is_empty()).collect();
    let b: std::collections::HashSet<&str> = new.lines().filter(|l| !l.trim().is_empty()).collect();
    let mut d = Diff::default();
    for (i, l) in old.lines().enumerate() {
        if !l.trim().is_empty() && !b.contains(l) {
            d.removed.push(format!("line {}: {}", i + 1, short(&Value::String(l.to_string()))));
        }
    }
    for (i, l) in new.lines().enumerate() {
        if !l.trim().is_empty() && !a.contains(l) {
            d.added.push(format!("line {}", i + 1));
        }
    }
    d
}

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

    #[test]
    fn named_entries_are_matched_by_name() {
        let a = json!({"about": "x", "vectors": [{"name": "a", "expected": "accept"}, {"name": "b", "expected": "reject"}]});
        let b = json!({"about": "y", "vectors": [{"name": "c", "expected": "accept"}, {"name": "a", "expected": "accept"}, {"name": "b", "expected": "reject"}]});
        let d = diff(&a, &b);
        assert!(!d.breaks(), "{d:?}");
        assert_eq!(d.added, vec!["$.vectors[name=c]"]);
        let c = json!({"about": "x", "vectors": [{"name": "a", "expected": "reject"}]});
        let d = diff(&a, &c);
        assert_eq!(d.removed, vec!["$.vectors[name=b]"]);
        assert_eq!(d.changed, vec![r#"$.vectors[name=a].expected: "accept" → "reject""#]);
    }

    #[test]
    fn a_list_that_grew_at_its_end_only_added() {
        let d = diff(&json!([1, 2]), &json!([1, 2, 3]));
        assert!(!d.breaks());
        let d = diff(&json!([1, 2]), &json!([2, 1]));
        assert!(d.breaks());
    }
}
