//! Behavioral checks for command semantics that the table fixtures do not cover.
//!
//! These pin down `$toggle` truthiness, command chaining into `$apply` and
//! custom commands, `$unset` on non-object targets, and `$merge` on array and
//! Map targets.

use std::rc::Rc;

use immutability_helper::{
    is_equals, set_default_is_equals, update, Command, Spec, SpecEntry, Value,
};

fn obj(pairs: Vec<(&str, Value)>) -> Value {
    Value::object(pairs)
}

#[test]
fn toggle_negates_by_truthiness() {
    // Each key flips to the logical negation of its current value under JS
    // truthiness, so truthy numbers and strings go to false.
    let original = obj(vec![
        ("zero", Value::from(0)),
        ("one", Value::from(1)),
        ("str", Value::from("x")),
        ("emptystr", Value::from("")),
        ("t", Value::from(true)),
        ("f", Value::from(false)),
    ]);
    let keys = vec![
        "zero".to_string(),
        "one".to_string(),
        "str".to_string(),
        "emptystr".to_string(),
        "t".to_string(),
        "f".to_string(),
    ];
    let result = update(&original, &Spec::toggle(keys)).unwrap();

    let expected = obj(vec![
        ("zero", Value::from(true)),
        ("one", Value::from(false)),
        ("str", Value::from(false)),
        ("emptystr", Value::from(true)),
        ("t", Value::from(false)),
        ("f", Value::from(true)),
    ]);
    assert_eq!(result, expected);
}

#[test]
fn toggle_missing_key_becomes_true() {
    // A key not present reads as undefined, which is falsy, so it toggles to
    // true.
    let original = obj(vec![("a", Value::from(1))]);
    let result = update(&original, &Spec::toggle(vec!["z".to_string()])).unwrap();
    let Value::Object(map) = &result else {
        unreachable!()
    };
    assert_eq!(map.get("z"), Some(&Value::from(true)));
}

#[test]
fn toggle_repeated_key_reads_the_source() {
    // Both negations read the source value, so a repeated key produces the same
    // result both times.
    let original = obj(vec![("a", Value::from(false))]);
    let result = update(
        &original,
        &Spec::toggle(vec!["a".to_string(), "a".to_string()]),
    )
    .unwrap();
    let Value::Object(map) = &result else {
        unreachable!()
    };
    assert_eq!(map.get("a"), Some(&Value::from(true)));
}

#[test]
fn apply_after_push_sees_the_working_value() {
    // $push runs first, so $apply must receive [1, 2], not the original [1].
    let original = Value::array(vec![Value::from(1)]);
    let spec = Spec::Object(vec![
        SpecEntry::Command(Command::Push(vec![Value::from(2)])),
        SpecEntry::Command(Command::Apply(Rc::new(|v: &Value| {
            let Value::Array(items) = v else {
                return v.clone();
            };
            // Append the current length so the test can read what $apply saw.
            let mut out = (**items).clone();
            out.push(Value::from(items.len() as i32));
            Value::array(out)
        }))),
    ]);
    let result = update(&original, &spec).unwrap();
    let expected = Value::array(vec![Value::from(1), Value::from(2), Value::from(2)]);
    assert_eq!(result, expected);
}

#[test]
fn custom_command_after_push_sees_the_working_value() {
    use immutability_helper::Context;

    let mut ctx = Context::new();
    // The directive returns its current value untouched, so the result reflects
    // whatever the chain produced before it.
    ctx.extend("$noop", |_param, current, _orig| Ok(current.clone()));

    let original = Value::array(vec![Value::from(1)]);
    let spec = Spec::Object(vec![
        SpecEntry::Command(Command::Push(vec![Value::from(2)])),
        SpecEntry::Command(Command::Custom("$noop".to_string(), Value::Null)),
    ]);
    let result = ctx.update(&original, &spec).unwrap();
    assert_eq!(result, Value::array(vec![Value::from(1), Value::from(2)]));
}

#[test]
fn unset_on_array_is_a_no_op() {
    // An array has nothing to remove by string key, so $unset keeps the
    // reference.
    let original = Value::array(vec![Value::from(1), Value::from(2)]);
    let result = update(&original, &Spec::unset(vec!["0".to_string()])).unwrap();
    assert!(result.same_value(&original));
    assert_eq!(result, original);
}

#[test]
fn unset_removes_a_map_entry() {
    let original = Value::map(vec![
        (Value::from("a"), Value::from(1)),
        (Value::from("b"), Value::from(2)),
    ]);
    let result = update(&original, &Spec::unset(vec!["a".to_string()])).unwrap();
    assert!(!result.same_value(&original));
    let Value::Map(map) = &result else {
        unreachable!()
    };
    assert_eq!(map.len(), 1);
    assert_eq!(map.get(&Value::from("b")), Some(&Value::from(2)));
    assert_eq!(map.get(&Value::from("a")), None);
}

#[test]
fn merge_into_array_writes_an_index() {
    // $merge accepts an array target and writes by index, keeping it an array.
    let original = Value::array(vec![Value::from(1), Value::from(2)]);
    let result = update(&original, &Spec::merge(vec![("0", Value::from(9))])).unwrap();
    assert_eq!(result, Value::array(vec![Value::from(9), Value::from(2)]));
}

#[test]
fn merge_into_map_writes_a_string_entry() {
    let original = Value::map(vec![(Value::from("a"), Value::from(1))]);
    let result = update(&original, &Spec::merge(vec![("b", Value::from(2))])).unwrap();
    let Value::Map(map) = &result else {
        unreachable!()
    };
    assert_eq!(map.get(&Value::from("a")), Some(&Value::from(1)));
    assert_eq!(map.get(&Value::from("b")), Some(&Value::from(2)));
}

#[test]
fn merge_rejects_a_primitive_target() {
    let result = update(&Value::from(7), &Spec::merge(vec![("a", Value::from(1))]));
    let err = result.expect_err("merge on a number should fail");
    assert_eq!(
        err.message(),
        "update(): $merge expects a target of type 'object'; got 7"
    );
}

#[test]
fn is_equals_tracks_the_default_comparator() {
    // The default comparator is identity, so two equal allocations differ.
    let a = obj(vec![("b", Value::from(1))]);
    let b = obj(vec![("b", Value::from(1))]);
    assert!(!is_equals(&a, &b));

    // After installing deep equality, the same pair reports equal, matching how
    // update collapses a deeply-equal set.
    set_default_is_equals(|x, y| x == y);
    assert!(is_equals(&a, &b));
    let collapsed = update(&a, &Spec::set(b.clone())).unwrap();
    assert!(collapsed.same_value(&a));
}
