//! Leaf stability diffing and structural schema inference for proposals.
//!
//! Why this lives apart from the orchestration in the parent module: the two
//! observed responses are compared at the leaf level (which leaves are equal,
//! which drifted) and at the shape level (what schema both responses
//! satisfy), and both walks share the same path grammar as the executor's
//! `resolve_target` so every emitted path is directly usable as an assertion
//! target. The cassette normalizer solves a different problem (placeholder
//! substitution for well-known volatile kinds); stability here is decided
//! purely by diffing what the two calls actually returned, so an
//! app-specific counter is caught just as well as a UUID.

use serde_json::Value;

/// One leaf that held the same value across both observed calls.
#[derive(Debug, Clone)]
pub(crate) struct StableLeaf {
    /// Executor-syntax path from the result root, e.g. `result.content[0].text`.
    pub path: String,
    /// Number of path segments below `result`; exact-assertion selection
    /// prefers shallow leaves.
    pub depth: usize,
    /// The observed (identical) value.
    pub value: Value,
}

/// The outcome of diffing two observed results leaf by leaf.
#[derive(Debug, Clone)]
pub(crate) struct LeafDiff {
    /// Leaves equal across both observations, in deterministic path order.
    pub stable: Vec<StableLeaf>,
    /// Paths that differed in value or presence, sorted and deduplicated.
    pub volatile: Vec<String>,
}

/// Diff two observed results: leaves equal in both are stable, leaves that
/// differ or exist in only one are volatile. Presence counts as part of the
/// contract because an exact assertion on a sometimes-absent leaf would fail
/// half the time.
pub(crate) fn diff_leaves(first: &Value, second: &Value) -> LeafDiff {
    let first_leaves = collect_leaves(first);
    let second_leaves = collect_leaves(second);
    let second_by_path: std::collections::BTreeMap<&str, &Value> = second_leaves
        .iter()
        .map(|leaf| (leaf.path.as_str(), &leaf.value))
        .collect();

    let mut stable = Vec::new();
    let mut volatile = Vec::new();
    for leaf in &first_leaves {
        match second_by_path.get(leaf.path.as_str()) {
            Some(other) if **other == leaf.value => stable.push(leaf.clone()),
            _ => volatile.push(leaf.path.clone()),
        }
    }
    let first_paths: std::collections::BTreeSet<&str> =
        first_leaves.iter().map(|l| l.path.as_str()).collect();
    for leaf in &second_leaves {
        if !first_paths.contains(leaf.path.as_str()) {
            volatile.push(leaf.path.clone());
        }
    }
    volatile.sort();
    volatile.dedup();
    LeafDiff { stable, volatile }
}

/// Collect every scalar leaf of `result` with its executor-syntax path.
/// Containers are structure (the schema's job), not leaves; empty containers
/// contribute no leaf.
fn collect_leaves(result: &Value) -> Vec<StableLeaf> {
    let mut out = Vec::new();
    walk("result", 0, result, &mut out);
    out
}

fn walk(path: &str, depth: usize, value: &Value, out: &mut Vec<StableLeaf>) {
    match value {
        Value::Object(map) => {
            for (key, child) in map {
                let child_path = format!("{path}{}", key_segment(key));
                walk(&child_path, depth + 1, child, out);
            }
        }
        Value::Array(items) => {
            for (i, child) in items.iter().enumerate() {
                let child_path = format!("{path}[{i}]");
                walk(&child_path, depth + 1, child, out);
            }
        }
        leaf => out.push(StableLeaf {
            path: path.to_string(),
            depth,
            value: leaf.clone(),
        }),
    }
}

/// Render one object key as an executor path segment: dot form for plain
/// identifiers, bracket-quoted otherwise, matching the grammar of
/// [`crate::executor::resolve_target`].
fn key_segment(key: &str) -> String {
    let plain = !key.is_empty()
        && !key.starts_with(|c: char| c.is_ascii_digit())
        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
    if plain {
        format!(".{key}")
    } else {
        format!("[\"{key}\"]")
    }
}

/// Infer a JSON Schema that every sample satisfies, descending at most
/// `depth` object levels. Constraints come only from what was observed:
/// objects pin the keys present in every sample as `required` (plus their
/// property shapes), arrays pin a uniform item shape when one exists, and a
/// type mismatch across samples yields no constraint at all rather than a
/// guess.
pub(crate) fn infer_schema(samples: &[&Value], depth: usize) -> Value {
    match shared_kind(samples) {
        Some(Kind::Object) => object_schema(samples, depth),
        Some(Kind::Array) => array_schema(samples, depth),
        Some(Kind::String) => serde_json::json!({ "type": "string" }),
        Some(Kind::Bool) => serde_json::json!({ "type": "boolean" }),
        Some(Kind::Null) => serde_json::json!({ "type": "null" }),
        Some(Kind::Integer) => serde_json::json!({ "type": "integer" }),
        Some(Kind::Float) => serde_json::json!({ "type": "number" }),
        None => serde_json::json!({}),
    }
}

/// JSON kinds for schema inference. Integer and float split so an observed
/// whole number proposes `integer` while mixed numeric samples relax to
/// `number`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
    Object,
    Array,
    String,
    Bool,
    Null,
    Integer,
    Float,
}

fn kind_of(value: &Value) -> Kind {
    match value {
        Value::Object(_) => Kind::Object,
        Value::Array(_) => Kind::Array,
        Value::String(_) => Kind::String,
        Value::Bool(_) => Kind::Bool,
        Value::Null => Kind::Null,
        Value::Number(n) if n.is_i64() || n.is_u64() => Kind::Integer,
        Value::Number(_) => Kind::Float,
    }
}

/// The kind every sample shares, with mixed integer/float relaxing to float.
/// `None` when the samples disagree on shape.
fn shared_kind(samples: &[&Value]) -> Option<Kind> {
    let mut kinds = samples.iter().map(|v| kind_of(v));
    let first = kinds.next()?;
    let mut shared = first;
    for kind in kinds {
        shared = match (shared, kind) {
            (a, b) if a == b => a,
            (Kind::Integer, Kind::Float) | (Kind::Float, Kind::Integer) => Kind::Float,
            _ => return None,
        };
    }
    Some(shared)
}

/// Object schema: `required` and `properties` cover only the keys present in
/// every sample; keys present in some samples stay unconstrained. At depth
/// zero the descent stops with a bare type so deep structure is never
/// guessed past the inference budget.
fn object_schema(samples: &[&Value], depth: usize) -> Value {
    if depth == 0 {
        return serde_json::json!({ "type": "object" });
    }
    let maps: Vec<&serde_json::Map<String, Value>> =
        samples.iter().filter_map(|v| v.as_object()).collect();
    let Some(first) = maps.first() else {
        return serde_json::json!({ "type": "object" });
    };
    let common_keys: Vec<&String> = first
        .keys()
        .filter(|key| maps.iter().all(|m| m.contains_key(*key)))
        .collect();
    if common_keys.is_empty() {
        return serde_json::json!({ "type": "object" });
    }
    let mut properties = serde_json::Map::new();
    for key in &common_keys {
        let child_samples: Vec<&Value> = maps.iter().filter_map(|m| m.get(*key)).collect();
        properties.insert((*key).clone(), infer_schema(&child_samples, depth - 1));
    }
    serde_json::json!({
        "type": "object",
        "required": common_keys,
        "properties": properties,
    })
}

/// Array schema: when every item across every sample shares one shape, pin
/// it as `items`; otherwise (mixed or empty) the constraint stays at
/// "an array". Arrays do not consume the object-depth budget so
/// `content[].text` is reachable within the default depth.
fn array_schema(samples: &[&Value], depth: usize) -> Value {
    let items: Vec<&Value> = samples
        .iter()
        .filter_map(|v| v.as_array())
        .flatten()
        .collect();
    if items.is_empty() {
        return serde_json::json!({ "type": "array" });
    }
    if shared_kind(&items).is_none() {
        return serde_json::json!({ "type": "array" });
    }
    serde_json::json!({ "type": "array", "items": infer_schema(&items, depth) })
}

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

    #[test]
    fn equal_leaves_are_stable_and_keep_executor_paths() {
        let a = json!({ "content": [{ "text": "ok", "type": "text" }] });
        let diff = diff_leaves(&a, &a.clone());
        let paths: Vec<&str> = diff.stable.iter().map(|l| l.path.as_str()).collect();
        assert_eq!(
            paths,
            vec!["result.content[0].text", "result.content[0].type"]
        );
        assert!(diff.volatile.is_empty());
    }

    #[test]
    fn differing_and_one_sided_leaves_are_volatile() {
        let a = json!({ "serial": 1, "only_first": true, "same": "x" });
        let b = json!({ "serial": 2, "only_second": true, "same": "x" });
        let diff = diff_leaves(&a, &b);
        assert_eq!(
            diff.volatile,
            vec![
                "result.only_first".to_string(),
                "result.only_second".to_string(),
                "result.serial".to_string()
            ]
        );
        assert_eq!(diff.stable.len(), 1);
        assert_eq!(diff.stable[0].path, "result.same");
    }

    #[test]
    fn weird_keys_render_bracket_quoted_segments() {
        let a = json!({ "weird key": { "0digit": 1 } });
        let diff = diff_leaves(&a, &a.clone());
        assert_eq!(diff.stable[0].path, "result[\"weird key\"][\"0digit\"]");
    }

    #[test]
    fn object_schema_requires_only_keys_common_to_all_samples() {
        let a = json!({ "status": "ok", "extra": 1 });
        let b = json!({ "status": "ok" });
        let schema = infer_schema(&[&a, &b], 2);
        assert_eq!(schema["type"], "object");
        assert_eq!(schema["required"], json!(["status"]));
        assert_eq!(schema["properties"]["status"]["type"], "string");
        assert!(schema["properties"].get("extra").is_none());
    }

    #[test]
    fn array_of_uniform_objects_pins_item_shape_one_level_deep() {
        let a = json!([{ "type": "text", "text": "hi" }]);
        let schema = infer_schema(&[&a], 2);
        assert_eq!(schema["type"], "array");
        assert_eq!(schema["items"]["type"], "object");
        assert_eq!(schema["items"]["required"], json!(["text", "type"]));
        assert_eq!(schema["items"]["properties"]["text"]["type"], "string");
    }

    #[test]
    fn mixed_item_kinds_relax_to_a_bare_array() {
        let a = json!([1, "two"]);
        let schema = infer_schema(&[&a], 2);
        assert_eq!(schema, json!({ "type": "array" }));
    }

    #[test]
    fn mixed_sample_kinds_yield_no_constraint() {
        let a = json!({ "x": 1 });
        let b = json!([1]);
        assert_eq!(infer_schema(&[&a, &b], 2), json!({}));
    }

    #[test]
    fn whole_numbers_propose_integer_and_mixed_numerics_relax_to_number() {
        let int_a = json!(3);
        let int_b = json!(4);
        assert_eq!(infer_schema(&[&int_a, &int_b], 2)["type"], "integer");
        let float = json!(3.5);
        assert_eq!(infer_schema(&[&int_a, &float], 2)["type"], "number");
    }

    #[test]
    fn depth_budget_stops_object_descent_without_guessing() {
        let a = json!({ "outer": { "inner": { "leaf": 1 } } });
        let schema = infer_schema(&[&a], 2);
        let inner = &schema["properties"]["outer"]["properties"]["inner"];
        assert_eq!(inner["type"], "object");
        // Depth exhausted: no properties invented below the budget.
        assert!(inner.get("properties").is_none());
    }

    #[test]
    fn empty_array_proposes_only_the_array_type() {
        let a = json!([]);
        assert_eq!(infer_schema(&[&a], 2), json!({ "type": "array" }));
    }
}
