//! World-state mechanics for the scenario harness: dotted-path reads and
//! writes over a JSON object plus a seed-to-final diff.
//!
//! A scenario's world is a plain JSON object (the seed). Transitions mutate
//! it through dotted paths like `inventory.widgets`, and the final report
//! diffs the mutated state against the seed so an operator can see exactly
//! what the run changed. Keeping the state as `serde_json::Value` lets the
//! same assertion grammar the tool path uses resolve `state.inventory.widgets`
//! without a bespoke accessor.

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

/// One leaf-level change between the seed world and the final world.
///
/// `from` is `None` when the path did not exist in the seed (the run
/// created it); `to` is `None` when the path was removed. The diff is the
/// primary "what did the run do to the world?" oracle in the report.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StateChange {
    /// Dotted path of the leaf that changed, e.g. `inventory.widgets`.
    pub path: String,
    /// Value in the seed, or `None` if the path was created by the run.
    pub from: Option<Value>,
    /// Value in the final state, or `None` if the path was removed.
    pub to: Option<Value>,
}

/// Read a dotted path out of a JSON object. Returns `None` when any segment
/// is missing or a non-object is traversed, so a guard on a missing path
/// fails cleanly rather than panicking.
pub fn get_path(state: &Value, path: &str) -> Option<Value> {
    let mut cursor = state;
    for segment in path.split('.') {
        cursor = cursor.get(segment)?;
    }
    Some(cursor.clone())
}

/// Write a value at a dotted path, creating intermediate objects as needed.
///
/// A segment that currently holds a non-object is overwritten with a fresh
/// object so a transition can always land its effect; the world is the
/// harness's own model, not user data, so clobbering is the right call.
pub fn set_path(state: &mut Value, path: &str, value: Value) {
    if !state.is_object() {
        *state = Value::Object(Map::new());
    }
    let segments: Vec<&str> = path.split('.').collect();
    let mut cursor = state;
    for segment in &segments[..segments.len() - 1] {
        let map = cursor.as_object_mut().expect("ensured object above");
        cursor = map
            .entry((*segment).to_string())
            .or_insert_with(|| Value::Object(Map::new()));
        if !cursor.is_object() {
            *cursor = Value::Object(Map::new());
        }
    }
    let last = segments[segments.len() - 1];
    cursor
        .as_object_mut()
        .expect("ensured object above")
        .insert(last.to_string(), value);
}

/// Flatten a JSON object into dotted leaf paths. Arrays and scalars are
/// leaves; only nested objects expand. Used by [`diff`] so two worlds can
/// be compared path by path.
pub fn flatten(state: &Value) -> Vec<(String, Value)> {
    let mut out = Vec::new();
    flatten_into(state, String::new(), &mut out);
    out
}

fn flatten_into(value: &Value, prefix: String, out: &mut Vec<(String, Value)>) {
    match value {
        Value::Object(map) if !map.is_empty() => {
            for (key, child) in map {
                let path = if prefix.is_empty() {
                    key.clone()
                } else {
                    format!("{prefix}.{key}")
                };
                flatten_into(child, path, out);
            }
        }
        _ => out.push((prefix, value.clone())),
    }
}

/// Diff two worlds leaf by leaf, in sorted path order. Reports created,
/// removed, and changed leaves; unchanged leaves are omitted.
pub fn diff(seed: &Value, final_state: &Value) -> Vec<StateChange> {
    let before: std::collections::BTreeMap<String, Value> = flatten(seed).into_iter().collect();
    let after: std::collections::BTreeMap<String, Value> =
        flatten(final_state).into_iter().collect();
    let mut paths: std::collections::BTreeSet<&String> = before.keys().collect();
    paths.extend(after.keys());

    let mut changes = Vec::new();
    for path in paths {
        let from = before.get(path);
        let to = after.get(path);
        if from != to {
            changes.push(StateChange {
                path: path.clone(),
                from: from.cloned(),
                to: to.cloned(),
            });
        }
    }
    changes
}

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

    #[test]
    fn reads_a_nested_dotted_path() {
        let state = json!({ "inventory": { "widgets": 5 } });
        assert_eq!(get_path(&state, "inventory.widgets"), Some(json!(5)));
        assert_eq!(get_path(&state, "inventory.missing"), None);
        assert_eq!(get_path(&state, "missing.deep"), None);
    }

    #[test]
    fn writes_and_creates_intermediate_objects() {
        let mut state = json!({});
        set_path(&mut state, "inventory.widgets", json!(3));
        assert_eq!(state, json!({ "inventory": { "widgets": 3 } }));
        set_path(&mut state, "inventory.widgets", json!(4));
        assert_eq!(get_path(&state, "inventory.widgets"), Some(json!(4)));
    }

    #[test]
    fn overwrites_a_scalar_when_descending_through_it() {
        let mut state = json!({ "shelf": 1 });
        set_path(&mut state, "shelf.full", json!(true));
        assert_eq!(state, json!({ "shelf": { "full": true } }));
    }

    #[test]
    fn diff_reports_created_changed_and_removed_leaves() {
        let seed = json!({ "a": 1, "b": 2, "nested": { "x": 1 } });
        let final_state = json!({ "a": 1, "b": 3, "c": 9 });
        let changes = diff(&seed, &final_state);
        assert_eq!(
            changes,
            vec![
                StateChange {
                    path: "b".into(),
                    from: Some(json!(2)),
                    to: Some(json!(3))
                },
                StateChange {
                    path: "c".into(),
                    from: None,
                    to: Some(json!(9))
                },
                StateChange {
                    path: "nested.x".into(),
                    from: Some(json!(1)),
                    to: None
                },
            ]
        );
    }

    #[test]
    fn identical_worlds_have_no_diff() {
        let state = json!({ "a": 1, "nested": { "x": [1, 2] } });
        assert!(diff(&state, &state).is_empty());
    }
}
