//! Record: the JSON Schemas WAI publishes for its documents (`wai/**/*.schema.json`). A
//! document that validated before must validate after. So a schema breaks where a property
//! is gone, a property becomes required, a type or enumeration narrows, a pattern or
//! constant changes, or additional properties are no longer allowed; it is compatible
//! where it only gains optional properties, types or enumerated values.

use super::git::{Repo, Status};
use super::super::envelope::OpError;
use super::{Category, Finding};
use serde_json::Value;

pub const PATHSPECS: &[&str] = &[":(glob)wai/**/*.schema.json"];

fn strings(v: Option<&Value>) -> Vec<String> {
    match v {
        Some(Value::Array(a)) => a.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect(),
        Some(Value::String(s)) => vec![s.clone()],
        _ => Vec::new(),
    }
}

/// What a document valid under `old` may fail under `new` (`breaks`), and what `new` admits
/// that `old` did not (`adds`), each a line naming the schema path.
pub fn walk(old: &Value, new: &Value, path: &str, breaks: &mut Vec<String>, adds: &mut Vec<String>) {
    let (Some(a), Some(b)) = (old.as_object(), new.as_object()) else {
        if old != new {
            breaks.push(format!("{path}: {old} → {new}"));
        }
        return;
    };
    let (ta, tb) = (strings(a.get("type")), strings(b.get("type")));
    if (!tb.is_empty() && ta.iter().any(|t| !tb.contains(t))) || (ta.is_empty() && !tb.is_empty()) {
        breaks.push(format!("{path}: type {ta:?} → {tb:?}"));
    }
    if let (Some(Value::Array(ea)), Some(Value::Array(eb))) = (a.get("enum"), b.get("enum")) {
        for x in ea.iter().filter(|x| !eb.contains(x)) {
            breaks.push(format!("{path}: the value {x} is no longer allowed"));
        }
        for x in eb.iter().filter(|x| !ea.contains(x)) {
            adds.push(format!("{path}: the value {x} is allowed"));
        }
    } else if a.get("enum").is_none() && b.get("enum").is_some() {
        breaks.push(format!("{path}: values are now enumerated"));
    }
    for k in ["pattern", "const", "format", "maxLength", "maximum", "maxItems", "minLength", "minimum", "minItems"] {
        if a.get(k) != b.get(k) && b.get(k).is_some() {
            breaks.push(format!("{path}: {k} {} → {}", a.get(k).map(Value::to_string).unwrap_or_else(|| "absent".into()), b.get(k).map(Value::to_string).unwrap_or_default()));
        }
    }
    let (ra, rb) = (strings(a.get("required")), strings(b.get("required")));
    for r in rb.iter().filter(|r| !ra.contains(r)) {
        breaks.push(format!("{path}: {r} is now required"));
    }
    if b.get("additionalProperties") == Some(&Value::Bool(false)) && a.get("additionalProperties") != Some(&Value::Bool(false)) {
        breaks.push(format!("{path}: other properties are no longer allowed"));
    }
    for key in ["properties", "$defs", "definitions"] {
        let (pa, pb) = (a.get(key).and_then(Value::as_object), b.get(key).and_then(Value::as_object));
        if let Some(pa) = pa {
            for (name, sa) in pa {
                match pb.and_then(|pb| pb.get(name)) {
                    None => breaks.push(format!("{path}/{key}/{name}: removed")),
                    Some(sb) => walk(sa, sb, &format!("{path}/{key}/{name}"), breaks, adds),
                }
            }
        }
        if let Some(pb) = pb {
            for name in pb.keys().filter(|n| pa.is_none_or(|pa| !pa.contains_key(*n))) {
                adds.push(format!("{path}/{key}/{name}: new"));
            }
        }
    }
    for key in ["items", "additionalProperties", "not"] {
        if let (Some(x @ Value::Object(_)), Some(y @ Value::Object(_))) = (a.get(key), b.get(key)) {
            walk(x, y, &format!("{path}/{key}"), breaks, adds);
        }
    }
    for key in ["anyOf", "oneOf", "allOf"] {
        if a.get(key) != b.get(key) {
            breaks.push(format!("{path}: {key} changed"));
        }
    }
}

/// The schemas' findings between `base` and the working tree.
pub fn compare(repo: &Repo, base: &str) -> Result<Vec<Finding>, OpError> {
    let changed = repo.changed(base, PATHSPECS)?;
    let olds = repo.base_files(base, &changed.iter().map(|x| x.1.clone()).collect::<Vec<_>>())?;
    let mut out = Vec::new();
    for (status, path) in changed {
        match status {
            Status::Added => out.push(Finding::compatible(Category::Record, "schema", path, "a new schema".into())),
            Status::Removed => out.push(Finding::broke(Category::Record, "schema", path, "removed".into(), "the schema is gone".into(), "documents written against it lose their published shape".into())),
            Status::Modified => {
                let old: Option<Value> = olds.get(&path).and_then(|b| serde_json::from_slice(b).ok());
                let new: Option<Value> = repo.head_file(&path).and_then(|b| serde_json::from_slice(&b).ok());
                let (Some(old), Some(new)) = (old, new) else {
                    out.push(Finding::broke(Category::Record, "schema", path, "changed".into(), "the schema is no longer JSON".into(), "documents cannot be checked against it".into()));
                    continue;
                };
                let (mut breaks, mut adds) = (Vec::new(), Vec::new());
                walk(&old, &new, "#", &mut breaks, &mut adds);
                if !breaks.is_empty() {
                    out.push(Finding::broke(Category::Record, "schema", path, "changed".into(), breaks.join("; "), "a document valid under the old schema is refused under the new one".into()));
                } else if !adds.is_empty() {
                    out.push(Finding::compatible(Category::Record, "schema", path, adds.join("; ")));
                }
            }
        }
    }
    Ok(out)
}
