//! API: the HTTP services' OpenAPI documents (the room service's
//! `joule-moq-room/openapi.json`, and any `openapi*.json` under `wai/`). A client written
//! against the old document must still work. So a route (a path and method) that is gone
//! breaks; so does an operation whose `operationId` changed (the generated clients and
//! tools are named by it), a parameter that is gone, moved or newly required, a request
//! body that becomes required, a success response that is gone, and a schema a request
//! carries that gains a required property, or one a response carries that loses a
//! property or changes a property's type. New routes, optional parameters and response
//! properties are compatible.

use super::git::Repo;
use super::super::envelope::OpError;
use super::{Category, Finding};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};

/// The documents read, by their path in the repository.
pub const PATHSPECS: &[&str] = &["joule-code/joule-code-rs/crates/joule-moq-room/openapi.json", ":(glob)wai/**/openapi*.json"];

const METHODS: &[&str] = &["get", "put", "post", "delete", "options", "head", "patch", "trace"];

/// A short name for a document: its crate's directory, else its path.
fn service(path: &str) -> String {
    let parts: Vec<&str> = path.split('/').collect();
    if parts.len() >= 2 { parts[parts.len() - 2].to_string() } else { path.to_string() }
}

fn resolve<'a>(doc: &'a Value, v: &'a Value) -> &'a Value {
    let mut v = v;
    for _ in 0..16 {
        let Some(r) = v.get("$ref").and_then(Value::as_str) else { break };
        let Some(p) = r.strip_prefix("#/") else { break };
        let mut t = doc;
        for seg in p.split('/') {
            match t.get(seg.replace("~1", "/").replace("~0", "~")) {
                Some(x) => t = x,
                None => return v,
            }
        }
        v = t;
    }
    v
}

fn params(doc: &Value, path_item: &Value, op: &Value) -> BTreeMap<(String, String), bool> {
    let mut m = BTreeMap::new();
    for list in [path_item.get("parameters"), op.get("parameters")].into_iter().flatten() {
        for p in list.as_array().into_iter().flatten() {
            let p = resolve(doc, p);
            if let (Some(n), Some(i)) = (p.get("name").and_then(Value::as_str), p.get("in").and_then(Value::as_str)) {
                m.insert((n.to_string(), i.to_string()), p.get("required").and_then(Value::as_bool).unwrap_or(i == "path"));
            }
        }
    }
    m
}

/// The properties of a schema, resolved: each name with its type (or `$ref` name) and
/// whether it is required.
fn props(doc: &Value, schema: &Value) -> BTreeMap<String, (String, bool)> {
    let s = resolve(doc, schema);
    let required: BTreeSet<String> = s.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.as_str().map(String::from)).collect();
    let mut m = BTreeMap::new();
    if let Some(p) = s.get("properties").and_then(Value::as_object) {
        for (k, v) in p {
            let ty = v.get("$ref").and_then(Value::as_str).map(String::from).or_else(|| v.get("type").map(|t| t.to_string())).unwrap_or_default();
            m.insert(k.clone(), (ty, required.contains(k)));
        }
    }
    m
}

fn json_schema<'a>(doc: &'a Value, body: &'a Value) -> Option<&'a Value> {
    let body = resolve(doc, body);
    let content = body.get("content")?.as_object()?;
    content.get("application/json").or_else(|| content.values().next())?.get("schema")
}

fn ops(doc: &Value) -> BTreeMap<String, (&Value, &Value)> {
    let mut m = BTreeMap::new();
    for (path, item) in doc.get("paths").and_then(Value::as_object).into_iter().flatten() {
        for meth in METHODS {
            if let Some(op) = item.get(*meth) {
                m.insert(format!("{} {path}", meth.to_uppercase()), (item, op));
            }
        }
    }
    m
}

/// One document's findings (`None` where a tree has none).
pub fn compare_doc(path: &str, old: Option<&Value>, new: Option<&Value>) -> Vec<Finding> {
    let svc = service(path);
    let mut out = Vec::new();
    let empty = Value::Null;
    let (a, b) = (old.unwrap_or(&empty), new.unwrap_or(&empty));
    let (oa, ob) = (ops(a), ops(b));
    for (route, (ia, xa)) in &oa {
        let item = format!("{svc}:{route}");
        let Some((ib, xb)) = ob.get(route) else {
            out.push(Finding::broke(Category::Api, "openapi", item, "removed".into(), format!("the route {route} is gone from {path}"), "a client that calls it gets no such route".into()));
            continue;
        };
        let mut what = Vec::new();
        if xa.get("operationId") != xb.get("operationId") && xa.get("operationId").is_some() {
            what.push(format!("operationId {} → {}", xa.get("operationId").unwrap_or(&empty), xb.get("operationId").unwrap_or(&empty)));
        }
        let (pa, pb) = (params(a, ia, xa), params(b, ib, xb));
        for ((n, i), req) in &pa {
            match pb.get(&(n.clone(), i.clone())) {
                None => what.push(format!("the {i} parameter {n} is gone")),
                Some(r) if *r && !req => what.push(format!("the {i} parameter {n} is now required")),
                _ => {}
            }
        }
        for ((n, i), req) in &pb {
            if *req && !pa.contains_key(&(n.clone(), i.clone())) {
                what.push(format!("a required {i} parameter {n} is new"));
            }
        }
        let body_required = |x: &Value, d: &Value| x.get("requestBody").map(|r| resolve(d, r).get("required").and_then(Value::as_bool).unwrap_or(false)).unwrap_or(false);
        if body_required(xb, b) && !body_required(xa, a) {
            what.push("the request body is now required".into());
        }
        if let (Some(sa), Some(sb)) = (xa.get("requestBody").and_then(|r| json_schema(a, r)), xb.get("requestBody").and_then(|r| json_schema(b, r))) {
            let (qa, qb) = (props(a, sa), props(b, sb));
            for (k, (_, req)) in &qb {
                if *req && !qa.get(k).is_some_and(|x| x.1) {
                    what.push(format!("the request property {k} is now required"));
                }
            }
        }
        let codes = |x: &Value| -> Vec<String> { x.get("responses").and_then(Value::as_object).map(|r| r.keys().cloned().collect()).unwrap_or_default() };
        let (ca, cb) = (codes(xa), codes(xb));
        for c in ca.iter().filter(|c| c.starts_with('2') && !cb.contains(c)) {
            what.push(format!("the {c} response is gone"));
        }
        for c in ca.iter().filter(|c| c.starts_with('2') && cb.contains(c)) {
            let schema = |x: &Value, d: &Value| x.get("responses").and_then(|r| r.get(c.as_str())).and_then(|r| json_schema(d, r)).map(|s| props(d, s));
            if let (Some(ra), Some(rb)) = (schema(xa, a), schema(xb, b)) {
                for (k, (ty, _)) in &ra {
                    match rb.get(k) {
                        None => what.push(format!("the {c} response's property {k} is gone")),
                        Some((t, _)) if t != ty => what.push(format!("the {c} response's property {k}: {ty} → {t}")),
                        _ => {}
                    }
                }
            }
        }
        if !what.is_empty() {
            out.push(Finding::broke(Category::Api, "openapi", item, "changed".into(), format!("{route}: {}", what.join("; ")), "a client written against the old document sends what is now refused, or reads what is no longer sent".into()));
        }
    }
    for route in ob.keys() {
        if !oa.contains_key(route) {
            out.push(Finding::compatible(Category::Api, "openapi", format!("{svc}:{route}"), format!("the route {route} is new in {path}")));
        }
    }
    out
}

/// Every document's findings between `base` and the working tree.
pub fn compare(repo: &Repo, base: &str) -> Result<Vec<Finding>, OpError> {
    let mut paths: BTreeSet<String> = repo.ls_base(base, PATHSPECS)?.into_iter().collect();
    paths.extend(repo.ls_head(PATHSPECS)?);
    let list: Vec<String> = paths.into_iter().collect();
    let olds = repo.base_files(base, &list)?;
    let mut out = Vec::new();
    for p in &list {
        let old: Option<Value> = olds.get(p).and_then(|b| serde_json::from_slice(b).ok());
        let new: Option<Value> = repo.head_file(p).and_then(|b| serde_json::from_slice(&b).ok());
        out.extend(compare_doc(p, old.as_ref(), new.as_ref()));
    }
    Ok(out)
}
