//! Predicate IR tests.

use super::types::*;

#[cfg(all(test, feature = "wire"))]
#[test]
fn predicate_round_trips_through_postcard() {
    use crate::{EntityId, PoolId};

    let p = Predicate::All(vec![
        Predicate::Cmp(
            Value::Pool {
                entity: EntityRef::Self_,
                key: PoolId::new("test:level", "Level"),
                mode: PoolMode::Base,
            },
            Op::Ge,
            Value::Const(2),
        ),
        Predicate::Exists(EntityRef::Owner),
    ]);
    // Touch unused-import suppression on non-wire builds.
    let _ = EntityId::from_raw(0);

    let bytes = postcard::to_allocvec(&p).unwrap();
    let back: Predicate = postcard::from_bytes(&bytes).unwrap();
    assert_eq!(p, back);
}

#[cfg(all(test, feature = "wire"))]
#[test]
fn entity_ref_variants_are_serializable() {
    use crate::EntityId;

    for er in [
        EntityRef::Self_,
        EntityRef::Owner,
        EntityRef::Observer,
        EntityRef::Target,
        EntityRef::It,
        EntityRef::ById(EntityId::from_raw(42)),
    ] {
        let bytes = postcard::to_allocvec(&er).unwrap();
        let back: EntityRef = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(er, back);
    }
}

#[cfg(all(test, feature = "wire"))]
#[test]
fn setting_value_serializes_round_trip() {
    let p = Predicate::Cmp(
        Value::Setting(SettingKey::new("test:rule:bool_setting")),
        Op::Eq,
        Value::Const(1),
    );
    let bytes = postcard::to_allocvec(&p).unwrap();
    let back: Predicate = postcard::from_bytes(&bytes).unwrap();
    assert_eq!(p, back);
}

#[cfg(all(test, feature = "wire"))]
#[test]
fn source_loaded_value_serializes_round_trip() {
    let p = Predicate::Cmp(
        Value::SourceLoaded(SourceKey::new("test:source_a")),
        Op::Eq,
        Value::Const(1),
    );
    let bytes = postcard::to_allocvec(&p).unwrap();
    let back: Predicate = postcard::from_bytes(&bytes).unwrap();
    assert_eq!(p, back);
}

/// Smoke test that the IR builds without `wire` feature, too.
#[test]
fn predicate_constructs_without_wire() {
    let p = Predicate::Not(Box::new(Predicate::All(vec![])));
    match p {
        Predicate::Not(inner) => match *inner {
            Predicate::All(v) => assert!(v.is_empty()),
            _ => panic!("unexpected variant"),
        },
        _ => panic!("unexpected variant"),
    }
}

// --------------------------------------------------------------------------
// Evaluator tests (Task A2)
// --------------------------------------------------------------------------

#[cfg(test)]
mod eval_tests {
    use super::*;
    use crate::predicate::eval::EvalContext;
    use crate::{EntityId, EntityTypeId, PoolId};
    use std::collections::{HashMap, HashSet};

    /// HashMap-backed test double for [`EvalContext`].
    ///
    /// `pub(super)` so the sibling `explain_tests` module can reuse it.
    #[derive(Default)]
    pub(super) struct MockCtx {
        self_id: Option<EntityId>,
        observer: Option<EntityId>,
        target: Option<EntityId>,
        owners: HashMap<EntityId, EntityId>,
        types: HashMap<EntityId, EntityTypeId>,
        // Keyed by (entity, pool key, is_base). `PoolMode` is not Hash, so we
        // collapse it to a bool here.
        pools: HashMap<(EntityId, String, bool), i64>,
        attached: HashMap<EntityId, Vec<EntityId>>,
        contained: HashMap<EntityId, Vec<EntityId>>,
        settings: HashMap<String, i64>,
        sources: HashSet<String>,
    }

    impl MockCtx {
        pub(super) fn new() -> Self {
            Self::default()
        }

        pub(super) fn with_self(mut self, id: EntityId) -> Self {
            self.self_id = Some(id);
            self
        }

        #[allow(dead_code)]
        pub(super) fn with_observer(mut self, id: EntityId) -> Self {
            self.observer = Some(id);
            self
        }

        #[allow(dead_code)]
        pub(super) fn with_target(mut self, id: EntityId) -> Self {
            self.target = Some(id);
            self
        }

        #[allow(dead_code)]
        pub(super) fn with_owner(mut self, of: EntityId, owner: EntityId) -> Self {
            self.owners.insert(of, owner);
            self
        }

        pub(super) fn with_type(mut self, id: EntityId, ty: EntityTypeId) -> Self {
            self.types.insert(id, ty);
            self
        }

        pub(super) fn with_pool(mut self, e: EntityId, key: &str, mode: PoolMode, val: i64) -> Self {
            self.pools
                .insert((e, key.to_string(), matches!(mode, PoolMode::Base)), val);
            self
        }

        pub(super) fn with_attached(mut self, parent: EntityId, kids: Vec<EntityId>) -> Self {
            self.attached.insert(parent, kids);
            self
        }

        #[allow(dead_code)]
        pub(super) fn with_contained(mut self, container: EntityId, kids: Vec<EntityId>) -> Self {
            self.contained.insert(container, kids);
            self
        }

        #[allow(dead_code)]
        pub(super) fn with_setting(mut self, key: impl Into<String>, value: i64) -> Self {
            self.settings.insert(key.into(), value);
            self
        }

        #[allow(dead_code)]
        pub(super) fn with_source(mut self, key: impl Into<String>) -> Self {
            self.sources.insert(key.into());
            self
        }
    }

    impl EvalContext for MockCtx {
        fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
        fn resolve_self(&self) -> Option<EntityId> {
            self.self_id
        }

        fn resolve_owner(&self, of: EntityId) -> Option<EntityId> {
            self.owners.get(&of).copied()
        }

        fn resolve_observer(&self) -> Option<EntityId> {
            self.observer
        }

        fn resolve_target(&self) -> Option<EntityId> {
            self.target
        }

        fn type_of(&self, e: EntityId) -> Option<EntityTypeId> {
            self.types.get(&e).copied()
        }

        fn pool(&self, e: EntityId, key: &PoolId, mode: PoolMode) -> Option<i64> {
            self.pools
                .get(&(e, key.key().to_string(), matches!(mode, PoolMode::Base)))
                .copied()
        }

        fn attached_to(&self, parent: EntityId) -> Vec<EntityId> {
            self.attached.get(&parent).cloned().unwrap_or_default()
        }

        fn contained_in(&self, container: EntityId) -> Vec<EntityId> {
            self.contained.get(&container).cloned().unwrap_or_default()
        }

        fn setting(&self, key: &SettingKey) -> Option<i64> {
            self.settings.get(&key.0).copied()
        }

        fn source_loaded(&self, key: &SourceKey) -> bool {
            self.sources.contains(&key.0)
        }
    }

    #[test]
    fn evaluator_resolves_simple_pool_compare() {
        let p = Predicate::Cmp(
            Value::Pool {
                entity: EntityRef::Self_,
                key: PoolId::new("test:counter", "Counter"),
                mode: PoolMode::Base,
            },
            Op::Ge,
            Value::Const(10),
        );
        let ctx = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_pool(EntityId::from_raw(1), "test:counter", PoolMode ::Base, 10);
        assert!(p.evaluate(&ctx));

        // Below threshold → false.
        let ctx_low = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_pool(EntityId::from_raw(1), "test:counter", PoolMode ::Base, 9);
        assert!(!p.evaluate(&ctx_low));
    }

    #[test]
    fn evaluator_handles_anyof_with_it_binding() {
        let p = Predicate::AnyOf(
            EntitySet::AttachedTo(EntityRef::Self_),
            Box::new(Predicate::Cmp(
                Value::TypeOf(EntityRef::It),
                Op::Eq,
                Value::ConstType(EntityTypeId::from_raw(5002)),
            )),
        );
        let ctx = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_attached(EntityId::from_raw(1), vec![EntityId::from_raw(99)])
            .with_type(EntityId::from_raw(99), EntityTypeId::from_raw(5002));
        assert!(p.evaluate(&ctx));

        // Wrong type on the only attached entity → false.
        let ctx_no = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_attached(EntityId::from_raw(1), vec![EntityId::from_raw(99)])
            .with_type(EntityId::from_raw(99), EntityTypeId::from_raw(1));
        assert!(!p.evaluate(&ctx_no));
    }

    #[test]
    fn evaluator_target_is_null_in_selection_context() {
        // No Target set → Exists(Target) is false.
        let p = Predicate::Exists(EntityRef::Target);
        let ctx = MockCtx::new();
        assert!(!p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_empty_all_is_true() {
        let p = Predicate::All(vec![]);
        let ctx = MockCtx::new();
        assert!(p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_empty_any_is_false() {
        let p = Predicate::Any(vec![]);
        let ctx = MockCtx::new();
        assert!(!p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_allof_vacuously_true_on_empty_set() {
        // AllOf over an empty attached set: vacuously true regardless of body.
        let p = Predicate::AllOf(
            EntitySet::AttachedTo(EntityRef::Self_),
            Box::new(Predicate::Cmp(
                Value::Const(0),
                Op::Eq,
                Value::Const(1), // body would be false if ever evaluated
            )),
        );
        let ctx = MockCtx::new().with_self(EntityId::from_raw(1));
        // No `with_attached` for EntityId::from_raw(1) → empty set.
        assert!(p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_handles_nested_anyof() {
        // Outer: AnyOf entities attached to Self_.
        //   Inner: AnyOf entities attached to It (the outer iterator binding).
        //     Body: TypeOf(It) == ConstType(7) — innermost It must shadow outer.
        //
        // Topology:
        //   Self_ = 1
        //   attached(1) = [10, 11]
        //   attached(10) = [100]   type(100) = 7
        //   attached(11) = [101]   type(101) = 99
        // Outer first hits 10 → inner finds 100 with type 7 → true.
        let inner = Predicate::AnyOf(
            EntitySet::AttachedTo(EntityRef::It),
            Box::new(Predicate::Cmp(
                Value::TypeOf(EntityRef::It),
                Op::Eq,
                Value::ConstType(EntityTypeId::from_raw(7)),
            )),
        );
        let p = Predicate::AnyOf(
            EntitySet::AttachedTo(EntityRef::Self_),
            Box::new(inner),
        );
        let ctx = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_attached(EntityId::from_raw(1), vec![EntityId::from_raw(10), EntityId::from_raw(11)])
            .with_attached(EntityId::from_raw(10), vec![EntityId::from_raw(100)])
            .with_attached(EntityId::from_raw(11), vec![EntityId::from_raw(101)])
            .with_type(EntityId::from_raw(100), EntityTypeId::from_raw(7))
            .with_type(EntityId::from_raw(101), EntityTypeId::from_raw(99));
        assert!(p.evaluate(&ctx));

        // Now flip both inner types away from 7 → no nested match.
        let ctx_no = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_attached(EntityId::from_raw(1), vec![EntityId::from_raw(10), EntityId::from_raw(11)])
            .with_attached(EntityId::from_raw(10), vec![EntityId::from_raw(100)])
            .with_attached(EntityId::from_raw(11), vec![EntityId::from_raw(101)])
            .with_type(EntityId::from_raw(100), EntityTypeId::from_raw(8))
            .with_type(EntityId::from_raw(101), EntityTypeId::from_raw(99));
        assert!(!p.evaluate(&ctx_no));
    }

    #[test]
    fn evaluator_type_mismatched_compare_is_false() {
        // EntityId vs Int → false (no panic).
        let p = Predicate::Cmp(
            Value::ConstId(EntityId::from_raw(1)),
            Op::Eq,
            Value::Const(1),
        );
        let ctx = MockCtx::new();
        assert!(!p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_null_value_compare_is_false() {
        // Pool reading on a Self_ that doesn't resolve → NULL → false.
        let p = Predicate::Cmp(
            Value::Pool {
                entity: EntityRef::Self_,
                key: PoolId::new("test:counter", "Counter"),
                mode: PoolMode::Base,
            },
            Op::Ge,
            Value::Const(0),
        );
        let ctx = MockCtx::new(); // no self set
        assert!(!p.evaluate(&ctx));
    }

    // ----------------------------------------------------------------------
    // Phase B: Setting + SourceLoaded
    // ----------------------------------------------------------------------

    #[test]
    fn evaluator_reads_setting() {
        let p = Predicate::Cmp(
            Value::Setting(SettingKey::new("rule:flanking")),
            Op::Eq,
            Value::Const(1),
        );
        let ctx = MockCtx::new().with_setting("rule:flanking", 1);
        assert!(p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_returns_false_for_missing_setting() {
        let p = Predicate::Cmp(
            Value::Setting(SettingKey::new("missing:setting")),
            Op::Eq,
            Value::Const(1),
        );
        let ctx = MockCtx::new();
        assert!(!p.evaluate(&ctx));
    }

    #[test]
    fn evaluator_reads_source_loaded() {
        let yes = Predicate::Cmp(
            Value::SourceLoaded(SourceKey::new("test:source_a")),
            Op::Eq,
            Value::Const(1),
        );
        assert!(yes.evaluate(&MockCtx::new().with_source("test:source_a")));
        assert!(!yes.evaluate(&MockCtx::new()));
    }

    #[test]
    fn evaluator_default_impls_return_none_and_false() {
        // A bare-bones EvalContext relying on default trait impls should see
        // setting → None, source_loaded → false.
        struct Bare;
        impl EvalContext for Bare {
            fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
            fn resolve_self(&self) -> Option<EntityId> {
                None
            }
            fn resolve_owner(&self, _of: EntityId) -> Option<EntityId> {
                None
            }
            fn resolve_observer(&self) -> Option<EntityId> {
                None
            }
            fn resolve_target(&self) -> Option<EntityId> {
                None
            }
            fn type_of(&self, _e: EntityId) -> Option<EntityTypeId> {
                None
            }
            fn pool(&self, _e: EntityId, _key: &PoolId, _mode: PoolMode) -> Option<i64> {
                None
            }
            fn attached_to(&self, _parent: EntityId) -> Vec<EntityId> {
                Vec::new()
            }
            fn contained_in(&self, _container: EntityId) -> Vec<EntityId> {
                Vec::new()
            }
            // setting / source_loaded intentionally NOT overridden — exercise defaults.
        }
        let bare = Bare;
        assert_eq!(bare.setting(&SettingKey::new("anything")), None);
        assert!(!bare.source_loaded(&SourceKey::new("anything")));
    }
}

// --------------------------------------------------------------------------
// Explain tests (Task A3)
// --------------------------------------------------------------------------

#[cfg(test)]
mod explain_tests {
    use super::eval_tests::*;
    use super::*;
    use crate::predicate::explain::{ExplainNode, ResolvedValue};
    use crate::{EntityId, EntityTypeId, PoolId};

    /// Convenience: a predicate that always evaluates to true.
    fn p_true() -> Predicate {
        Predicate::All(vec![])
    }

    /// Convenience: a predicate that always evaluates to false.
    fn p_false() -> Predicate {
        Predicate::Any(vec![])
    }

    #[test]
    fn explain_locates_failing_leaf_in_all() {
        let p = Predicate::All(vec![p_true(), p_false()]);
        let ctx = MockCtx::new();
        let node = p.explain(&ctx);
        match node {
            ExplainNode::All { children, result } => {
                assert!(!result);
                assert_eq!(children.len(), 2);
                assert!(children[0].result());
                assert!(!children[1].result());
            }
            _ => panic!("expected ExplainNode::All, got {:?}", node),
        }
    }

    #[test]
    fn explain_anyof_records_per_member_evaluation() {
        // AnyOf entities attached to Self_, body: TypeOf(It) == ConstType(WIZARD).
        const WIZARD: EntityTypeId = EntityTypeId::from_raw(5002);
        let p = Predicate::AnyOf(
            EntitySet::AttachedTo(EntityRef::Self_),
            Box::new(Predicate::Cmp(
                Value::TypeOf(EntityRef::It),
                Op::Eq,
                Value::ConstType(WIZARD),
            )),
        );
        let ctx = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_attached(EntityId::from_raw(1), vec![EntityId::from_raw(10), EntityId::from_raw(11)])
            .with_type(EntityId::from_raw(10), WIZARD)
            .with_type(EntityId::from_raw(11), EntityTypeId::from_raw(9999));
        let node = p.explain(&ctx);
        match node {
            ExplainNode::AnyOf {
                set,
                body_evals,
                result,
            } => {
                assert!(result);
                assert_eq!(set.members, vec![EntityId::from_raw(10), EntityId::from_raw(11)]);
                assert_eq!(body_evals.len(), 2);
                assert!(body_evals[0].result(), "wizard at idx 0 should pass");
                assert!(!body_evals[1].result(), "non-wizard at idx 1 should fail");
            }
            _ => panic!("expected ExplainNode::AnyOf, got {:?}", node),
        }
    }

    #[test]
    fn explain_cmp_records_resolved_values() {
        let p = Predicate::Cmp(
            Value::Pool {
                entity: EntityRef::Self_,
                key: PoolId::new("test:counter", "Counter"),
                mode: PoolMode::Base,
            },
            Op::Ge,
            Value::Const(20),
        );
        let ctx = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_pool(EntityId::from_raw(1), "test:counter", PoolMode ::Base, 15);
        let node = p.explain(&ctx);
        match node {
            ExplainNode::Cmp {
                lhs,
                op,
                rhs,
                result,
            } => {
                assert!(!result);
                assert_eq!(op, Op::Ge);
                match lhs {
                    ResolvedValue::Pool {
                        entity,
                        value,
                        mode,
                        ref key,
                    } => {
                        assert_eq!(entity, Some(EntityId::from_raw(1)));
                        assert_eq!(value, Some(15));
                        assert_eq!(mode, PoolMode::Base);
                        assert_eq!(key.key(), "test:counter");
                    }
                    other => panic!("expected ResolvedValue::Pool, got {:?}", other),
                }
                match rhs {
                    ResolvedValue::Const(20) => {}
                    other => panic!("expected ResolvedValue::Const(20), got {:?}", other),
                }
            }
            _ => panic!("expected ExplainNode::Cmp, got {:?}", node),
        }
    }

    #[test]
    fn explain_target_unresolved_yields_false_exists() {
        let p = Predicate::Exists(EntityRef::Target);
        let ctx = MockCtx::new(); // no target set
        let node = p.explain(&ctx);
        match node {
            ExplainNode::Exists {
                ref_,
                resolved_to,
                result,
            } => {
                assert_eq!(ref_, EntityRef::Target);
                assert_eq!(resolved_to, None);
                assert!(!result);
            }
            _ => panic!("expected ExplainNode::Exists, got {:?}", node),
        }
    }

    #[test]
    fn explain_agrees_with_evaluate() {
        // Build several predicates exercising every variant and confirm
        // explain(ctx).result() == evaluate(ctx) for each.
        const COUNTER: &str = "test:counter";
        const WIZARD: EntityTypeId = EntityTypeId::from_raw(5002);

        let predicates: Vec<Predicate> = vec![
            // simple true / false
            Predicate::All(vec![]),
            Predicate::Any(vec![]),
            // not
            Predicate::Not(Box::new(Predicate::All(vec![]))),
            Predicate::Not(Box::new(Predicate::Any(vec![]))),
            // exists (resolvable + unresolvable)
            Predicate::Exists(EntityRef::Self_),
            Predicate::Exists(EntityRef::Target),
            // pool compare passing + failing
            Predicate::Cmp(
                Value::Pool {
                    entity: EntityRef::Self_,
                    key: PoolId::new(COUNTER, "Counter"),
                    mode: PoolMode::Base,
                },
                Op::Ge,
                Value::Const(10),
            ),
            Predicate::Cmp(
                Value::Pool {
                    entity: EntityRef::Self_,
                    key: PoolId::new(COUNTER, "Counter"),
                    mode: PoolMode::Base,
                },
                Op::Ge,
                Value::Const(100),
            ),
            // type compare
            Predicate::Cmp(
                Value::TypeOf(EntityRef::Self_),
                Op::Eq,
                Value::ConstType(WIZARD),
            ),
            // anyof / allof
            Predicate::AnyOf(
                EntitySet::AttachedTo(EntityRef::Self_),
                Box::new(Predicate::Cmp(
                    Value::TypeOf(EntityRef::It),
                    Op::Eq,
                    Value::ConstType(WIZARD),
                )),
            ),
            Predicate::AllOf(
                EntitySet::AttachedTo(EntityRef::Self_),
                Box::new(Predicate::Cmp(
                    Value::TypeOf(EntityRef::It),
                    Op::Eq,
                    Value::ConstType(WIZARD),
                )),
            ),
            // type-mismatched compare → false in both passes
            Predicate::Cmp(Value::ConstId(EntityId::from_raw(7)), Op::Eq, Value::Const(7)),
            // nested
            Predicate::All(vec![
                Predicate::Exists(EntityRef::Self_),
                Predicate::Not(Box::new(Predicate::Exists(EntityRef::Target))),
                Predicate::Any(vec![
                    Predicate::Cmp(
                        Value::Pool {
                            entity: EntityRef::Self_,
                            key: PoolId::new(COUNTER, "Counter"),
                            mode: PoolMode::Base,
                        },
                        Op::Lt,
                        Value::Const(5),
                    ),
                    Predicate::Cmp(
                        Value::Pool {
                            entity: EntityRef::Self_,
                            key: PoolId::new(COUNTER, "Counter"),
                            mode: PoolMode::Base,
                        },
                        Op::Ge,
                        Value::Const(5),
                    ),
                ]),
            ]),
        ];

        let ctx = MockCtx::new()
            .with_self(EntityId::from_raw(1))
            .with_type(EntityId::from_raw(1), WIZARD)
            .with_pool(EntityId::from_raw(1), COUNTER, PoolMode::Base, 12)
            .with_attached(EntityId::from_raw(1), vec![EntityId::from_raw(10), EntityId::from_raw(11)])
            .with_type(EntityId::from_raw(10), WIZARD)
            .with_type(EntityId::from_raw(11), EntityTypeId::from_raw(9999));

        for p in &predicates {
            let direct = p.evaluate(&ctx);
            let explained = p.explain(&ctx).result();
            assert_eq!(
                direct, explained,
                "explain disagreed with evaluate on predicate {:?}",
                p
            );
        }
    }

    // ----------------------------------------------------------------------
    // Phase B: Setting + SourceLoaded
    // ----------------------------------------------------------------------

    #[test]
    fn explain_setting_records_resolved_value() {
        let p = Predicate::Cmp(
            Value::Setting(SettingKey::new("rule:flanking")),
            Op::Eq,
            Value::Const(1),
        );
        let tree = p.explain(&MockCtx::new()); // no setting set
        match tree {
            ExplainNode::Cmp {
                lhs: ResolvedValue::Setting { ref key, value },
                result,
                ..
            } => {
                assert_eq!(key.key(), "rule:flanking");
                assert_eq!(value, None);
                assert!(!result);
            }
            other => panic!("expected Cmp/Setting, got {:?}", other),
        }
    }

    #[test]
    fn explain_setting_with_value_records_int() {
        let p = Predicate::Cmp(
            Value::Setting(SettingKey::new("rule:flanking")),
            Op::Eq,
            Value::Const(1),
        );
        let ctx = MockCtx::new().with_setting("rule:flanking", 1);
        let tree = p.explain(&ctx);
        match tree {
            ExplainNode::Cmp {
                lhs: ResolvedValue::Setting { ref key, value },
                result,
                ..
            } => {
                assert_eq!(key.key(), "rule:flanking");
                assert_eq!(value, Some(1));
                assert!(result);
            }
            other => panic!("expected Cmp/Setting, got {:?}", other),
        }
    }

    #[test]
    fn explain_source_loaded_records_state() {
        let p = Predicate::Cmp(
            Value::SourceLoaded(SourceKey::new("test:source_a")),
            Op::Eq,
            Value::Const(1),
        );
        let tree = p.explain(&MockCtx::new());
        match tree {
            ExplainNode::Cmp {
                lhs: ResolvedValue::SourceLoaded { ref key, loaded },
                result,
                ..
            } => {
                assert_eq!(key.key(), "test:source_a");
                assert!(!loaded);
                assert!(!result);
            }
            other => panic!("expected Cmp/SourceLoaded, got {:?}", other),
        }
    }

    #[test]
    fn explain_source_loaded_active() {
        let p = Predicate::Cmp(
            Value::SourceLoaded(SourceKey::new("test:source_a")),
            Op::Eq,
            Value::Const(1),
        );
        let ctx = MockCtx::new().with_source("test:source_a");
        let tree = p.explain(&ctx);
        match tree {
            ExplainNode::Cmp {
                lhs: ResolvedValue::SourceLoaded { loaded, .. },
                result,
                ..
            } => {
                assert!(loaded);
                assert!(result);
            }
            other => panic!("expected Cmp/SourceLoaded, got {:?}", other),
        }
    }

    // -----------------------------------------------------------------------
    // UC-29 Phase A: flatten_explain
    // -----------------------------------------------------------------------

    use crate::predicate::explain::flatten_explain;

    #[test]
    fn flatten_explain_returns_empty_string_when_predicate_passes() {
        // UC-29-A4 baseline: a passing predicate yields the empty string.
        let p = p_true();
        let ctx = MockCtx::new();
        let node = p.explain(&ctx);
        assert!(node.result());
        assert_eq!(flatten_explain(&node), "");
    }

    #[test]
    fn flatten_explain_all_recurses_into_first_failing_child() {
        // UC-29-A4: nested All -> first failing leaf wins.
        // Two failing branches: pool < 1 (fails) AND const 0 == 1 (fails).
        // The first child fails on its own pool comparison; flatten should
        // pick THAT message, not the second one.
        let self_id = EntityId::from_raw(1);
        let p = Predicate::All(vec![
            Predicate::Cmp(
                Value::Pool {
                    entity: EntityRef::Self_,
                    key: PoolId::from_owned("dnd5e:spell_slot:1".to_string(), "Slot1"),
                    mode: PoolMode::Effective,
                },
                Op::Ge,
                Value::Const(1),
            ),
            Predicate::Cmp(Value::Const(0), Op::Eq, Value::Const(1)),
        ]);
        let ctx = MockCtx::new()
            .with_self(self_id)
            .with_pool(self_id, "dnd5e:spell_slot:1", PoolMode::Effective, 0);
        let node = p.explain(&ctx);
        assert!(!node.result());
        let msg = flatten_explain(&node);
        // Picks the first failing branch: pool spell_slot:1 (value 0) >= 1.
        assert!(msg.contains("dnd5e:spell_slot:1"), "got: {msg}");
        assert!(msg.contains(">="), "got: {msg}");
        // And does NOT mention the const equality.
        assert!(!msg.contains("0 == 1"), "got: {msg}");
    }

    #[test]
    fn flatten_explain_anyof_joins_failing_branch_messages() {
        // UC-29-A4b: Any (logical) failure -> "needs one of: <m1>, <m2>".
        let self_id = EntityId::from_raw(1);
        let p = Predicate::Any(vec![
            Predicate::Cmp(
                Value::Pool {
                    entity: EntityRef::Self_,
                    key: PoolId::from_owned("dnd5e:ranged_weapon".to_string(), "Ranged Weapon"),
                    mode: PoolMode::Effective,
                },
                Op::Ge,
                Value::Const(1),
            ),
            Predicate::Cmp(
                Value::Pool {
                    entity: EntityRef::Self_,
                    key: PoolId::from_owned("dnd5e:ranged_spell".to_string(), "Ranged Spell"),
                    mode: PoolMode::Effective,
                },
                Op::Ge,
                Value::Const(1),
            ),
        ]);
        let ctx = MockCtx::new()
            .with_self(self_id)
            .with_pool(self_id, "dnd5e:ranged_weapon", PoolMode::Effective, 0)
            .with_pool(self_id, "dnd5e:ranged_spell", PoolMode::Effective, 0);
        let node = p.explain(&ctx);
        assert!(!node.result());
        let msg = flatten_explain(&node);
        assert!(msg.starts_with("needs one of: "), "got: {msg}");
        assert!(msg.contains("dnd5e:ranged_weapon"), "got: {msg}");
        assert!(msg.contains("dnd5e:ranged_spell"), "got: {msg}");
        // Comma-separated.
        assert_eq!(msg.matches(',').count(), 1, "got: {msg}");
    }

    #[test]
    fn flatten_explain_not_wraps_with_must_not() {
        // UC-29-A4c: Not(child) fails when child passes; output is
        // "must not: <child-message>".
        // Construct: Not(pool stunned >= 1) where stunned == 1 -> child passes,
        // Not fails. flatten_explain renders the failing child's leaf message.
        let self_id = EntityId::from_raw(1);
        let p = Predicate::Not(Box::new(Predicate::Cmp(
            Value::Pool {
                entity: EntityRef::Self_,
                key: PoolId::from_owned("dnd5e:condition:stunned".to_string(), "Stunned"),
                mode: PoolMode::Effective,
            },
            Op::Ge,
            Value::Const(1),
        )));
        let ctx = MockCtx::new()
            .with_self(self_id)
            .with_pool(self_id, "dnd5e:condition:stunned", PoolMode::Effective, 1);
        let node = p.explain(&ctx);
        assert!(!node.result());
        let msg = flatten_explain(&node);
        assert!(msg.starts_with("must not: "), "got: {msg}");
        assert!(msg.contains("dnd5e:condition:stunned"), "got: {msg}");
    }
}

// --------------------------------------------------------------------------
// Distance tests (UC-04 Phase A)
// --------------------------------------------------------------------------

#[cfg(test)]
mod distance_eval_tests {
    use super::*;
    use crate::predicate::eval::EvalContext;
    use crate::{EntityId, EntityTypeId, PoolId};
    use std::collections::HashMap;

    /// Minimal EvalContext that overrides only the required methods, leaving
    /// `distance` to fall through to the default impl.
    struct DefaultCtx;
    impl EvalContext for DefaultCtx {
        fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
        fn resolve_self(&self) -> Option<EntityId> {
            Some(EntityId::from_raw(1))
        }
        fn resolve_owner(&self, _: EntityId) -> Option<EntityId> {
            None
        }
        fn resolve_observer(&self) -> Option<EntityId> {
            None
        }
        fn resolve_target(&self) -> Option<EntityId> {
            Some(EntityId::from_raw(2))
        }
        fn type_of(&self, _: EntityId) -> Option<EntityTypeId> {
            None
        }
        fn pool(&self, _: EntityId, _: &PoolId, _: PoolMode) -> Option<i64> {
            None
        }
        fn attached_to(&self, _: EntityId) -> Vec<EntityId> {
            vec![]
        }
        fn contained_in(&self, _: EntityId) -> Vec<EntityId> {
            vec![]
        }
        // distance intentionally NOT overridden — exercises the default.
    }

    #[test]
    fn distance_value_returns_none_with_default_eval_context() {
        let ctx = DefaultCtx;
        // Sanity-check the default returns None.
        assert_eq!(
            ctx.distance(EntityId::from_raw(1), EntityId::from_raw(2), MetricKind::MotionLimited),
            None
        );
        // Predicate using Value::Distance against the default impl → NULL → false.
        let p = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(6),
        );
        assert!(!p.evaluate(&ctx));
    }

    /// Test double that returns seeded distances per (from, to, kind).
    struct SeededCtx {
        self_id: EntityId,
        target_id: EntityId,
        distances: HashMap<(EntityId, EntityId, MetricKind), i64>,
    }
    impl EvalContext for SeededCtx {
        fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
        fn resolve_self(&self) -> Option<EntityId> {
            Some(self.self_id)
        }
        fn resolve_owner(&self, _: EntityId) -> Option<EntityId> {
            None
        }
        fn resolve_observer(&self) -> Option<EntityId> {
            None
        }
        fn resolve_target(&self) -> Option<EntityId> {
            Some(self.target_id)
        }
        fn type_of(&self, _: EntityId) -> Option<EntityTypeId> {
            None
        }
        fn pool(&self, _: EntityId, _: &PoolId, _: PoolMode) -> Option<i64> {
            None
        }
        fn attached_to(&self, _: EntityId) -> Vec<EntityId> {
            vec![]
        }
        fn contained_in(&self, _: EntityId) -> Vec<EntityId> {
            vec![]
        }
        fn distance(&self, from: EntityId, to: EntityId, kind: MetricKind) -> Option<i64> {
            self.distances.get(&(from, to, kind)).copied()
        }
    }

    #[test]
    fn distance_value_evaluates_via_eval_context() {
        let ctx = SeededCtx {
            self_id: EntityId::from_raw(1),
            target_id: EntityId::from_raw(2),
            distances: HashMap::from([(
                (EntityId::from_raw(1), EntityId::from_raw(2), MetricKind::MotionLimited),
                4,
            )]),
        };

        // distance = 4, threshold 6 → in range.
        let in_range = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(6),
        );
        assert!(in_range.evaluate(&ctx));

        // distance = 4, threshold 3 → out of range.
        let out_of_range = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(3),
        );
        assert!(!out_of_range.evaluate(&ctx));
    }

    #[test]
    fn distance_value_unresolved_ref_returns_false() {
        // Even with a working `distance` method, an unresolved EntityRef
        // should produce NULL → Cmp false (matches every other Value variant).
        struct NoTargetCtx;
        impl EvalContext for NoTargetCtx {
            fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
            fn resolve_self(&self) -> Option<EntityId> {
                Some(EntityId::from_raw(1))
            }
            fn resolve_owner(&self, _: EntityId) -> Option<EntityId> {
                None
            }
            fn resolve_observer(&self) -> Option<EntityId> {
                None
            }
            fn resolve_target(&self) -> Option<EntityId> {
                None // Target is NULL.
            }
            fn type_of(&self, _: EntityId) -> Option<EntityTypeId> {
                None
            }
            fn pool(&self, _: EntityId, _: &PoolId, _: PoolMode) -> Option<i64> {
                None
            }
            fn attached_to(&self, _: EntityId) -> Vec<EntityId> {
                vec![]
            }
            fn contained_in(&self, _: EntityId) -> Vec<EntityId> {
                vec![]
            }
            fn distance(&self, _: EntityId, _: EntityId, _: MetricKind) -> Option<i64> {
                Some(0)
            }
        }
        let p = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(6),
        );
        assert!(!p.evaluate(&NoTargetCtx));
    }
}

#[cfg(test)]
mod distance_explain_tests {
    use super::*;
    use crate::predicate::eval::EvalContext;
    use crate::predicate::explain::{ExplainNode, ResolvedValue};
    use crate::{EntityId, EntityTypeId, PoolId};

    /// Seeded EvalContext that returns a fixed distance for any pair.
    struct SeededCtx {
        self_id: EntityId,
        target_id: EntityId,
        d: i64,
    }
    impl EvalContext for SeededCtx {
        fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
        fn resolve_self(&self) -> Option<EntityId> {
            Some(self.self_id)
        }
        fn resolve_owner(&self, _: EntityId) -> Option<EntityId> {
            None
        }
        fn resolve_observer(&self) -> Option<EntityId> {
            None
        }
        fn resolve_target(&self) -> Option<EntityId> {
            Some(self.target_id)
        }
        fn type_of(&self, _: EntityId) -> Option<EntityTypeId> {
            None
        }
        fn pool(&self, _: EntityId, _: &PoolId, _: PoolMode) -> Option<i64> {
            None
        }
        fn attached_to(&self, _: EntityId) -> Vec<EntityId> {
            vec![]
        }
        fn contained_in(&self, _: EntityId) -> Vec<EntityId> {
            vec![]
        }
        fn distance(&self, _: EntityId, _: EntityId, _: MetricKind) -> Option<i64> {
            Some(self.d)
        }
    }

    #[test]
    fn explain_distance_records_resolved_value() {
        // distance = 8, threshold 6 → predicate fails. Explain should record
        // ResolvedValue::Distance { kind: MotionLimited, value: Some(8), .. }
        // on the lhs and result: false.
        let ctx = SeededCtx {
            self_id: EntityId::from_raw(1),
            target_id: EntityId::from_raw(2),
            d: 8,
        };
        let p = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(6),
        );
        let tree = p.explain(&ctx);
        match tree {
            ExplainNode::Cmp {
                lhs:
                    ResolvedValue::Distance {
                        kind,
                        from_entity,
                        to_entity,
                        value,
                    },
                result,
                ..
            } => {
                assert_eq!(kind, MetricKind::MotionLimited);
                assert_eq!(from_entity, Some(EntityId::from_raw(1)));
                assert_eq!(to_entity, Some(EntityId::from_raw(2)));
                assert_eq!(value, Some(8));
                assert!(!result, "8 <= 6 is false");
            }
            other => panic!("expected Cmp/Distance, got {:?}", other),
        }
    }

    #[test]
    fn explain_distance_records_null_when_target_unresolved() {
        // No target → from resolves, to does not, value is None, result false.
        struct NoTargetCtx;
        impl EvalContext for NoTargetCtx {
            fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
            fn resolve_self(&self) -> Option<EntityId> {
                Some(EntityId::from_raw(1))
            }
            fn resolve_owner(&self, _: EntityId) -> Option<EntityId> {
                None
            }
            fn resolve_observer(&self) -> Option<EntityId> {
                None
            }
            fn resolve_target(&self) -> Option<EntityId> {
                None
            }
            fn type_of(&self, _: EntityId) -> Option<EntityTypeId> {
                None
            }
            fn pool(&self, _: EntityId, _: &PoolId, _: PoolMode) -> Option<i64> {
                None
            }
            fn attached_to(&self, _: EntityId) -> Vec<EntityId> {
                vec![]
            }
            fn contained_in(&self, _: EntityId) -> Vec<EntityId> {
                vec![]
            }
            // distance NOT overridden — but won't be called because to_entity is None.
        }
        let p = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(6),
        );
        let tree = p.explain(&NoTargetCtx);
        match tree {
            ExplainNode::Cmp {
                lhs:
                    ResolvedValue::Distance {
                        from_entity,
                        to_entity,
                        value,
                        ..
                    },
                result,
                ..
            } => {
                assert_eq!(from_entity, Some(EntityId::from_raw(1)));
                assert_eq!(to_entity, None);
                assert_eq!(value, None);
                assert!(!result);
            }
            other => panic!("expected Cmp/Distance, got {:?}", other),
        }
    }
}

#[cfg(all(test, feature = "wire"))]
mod distance_tests {
    use super::*;

    #[test]
    fn distance_value_round_trips() {
        let p = Predicate::Cmp(
            Value::Distance {
                from: EntityRef::Self_,
                to: EntityRef::Target,
                kind: MetricKind::MotionLimited,
            },
            Op::Le,
            Value::Const(6),
        );
        let bytes = postcard::to_allocvec(&p).unwrap();
        let back: Predicate = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(p, back);
    }

    #[test]
    fn metric_kind_round_trips_all_variants() {
        for k in [
            MetricKind::Unlimited,
            MetricKind::MotionLimited,
            MetricKind::VisionLimited,
        ] {
            let bytes = postcard::to_allocvec(&k).unwrap();
            let back: MetricKind = postcard::from_bytes(&bytes).unwrap();
            assert_eq!(k, back);
        }
    }
}

// --------------------------------------------------------------------------
// UC-29-E1 — OwnerSeatMatchesObserver + ObserverHasRole atoms
// --------------------------------------------------------------------------

#[cfg(test)]
mod uc_29_e1_atom_tests {
    use super::*;
    use crate::config::SeatRole;
    use crate::predicate::eval::EvalContext;
    use crate::predicate::explain::{flatten_explain, ExplainNode};
    use crate::types::{EntityId, SeatId, PoolId};
    use crate::predicate::types::PoolMode;
    use std::collections::HashMap;

    /// Extension to the local MockCtx that adds observer-seat / role /
    /// owner-seat-of mappings. We compose a sibling Mock here rather
    /// than touching the shared one above so this test mod can evolve
    /// independently.
    #[derive(Default)]
    struct E1Ctx {
        self_id: Option<EntityId>,
        observer_seat: Option<SeatId>,
        owner_seats: HashMap<EntityId, SeatId>,
        seat_roles: HashMap<SeatId, SeatRole>,
    }

    impl E1Ctx {
        fn new() -> Self { Self::default() }
        fn with_self(mut self, e: EntityId) -> Self { self.self_id = Some(e); self }
        fn with_observer_seat(mut self, s: SeatId) -> Self { self.observer_seat = Some(s); self }
        fn with_owner_seat(mut self, e: EntityId, s: SeatId) -> Self {
            self.owner_seats.insert(e, s); self
        }
        fn with_role(mut self, s: SeatId, r: SeatRole) -> Self {
            self.seat_roles.insert(s, r); self
        }
    }

    impl EvalContext for E1Ctx {
        fn visible(&self, _observer: EntityId, _target: EntityId) -> bool { false }
        fn resolve_self(&self) -> Option<EntityId> { self.self_id }
        fn resolve_owner(&self, _: EntityId) -> Option<EntityId> { None }
        fn resolve_observer(&self) -> Option<EntityId> { None }
        fn resolve_target(&self) -> Option<EntityId> { None }
        fn type_of(&self, _: EntityId) -> Option<crate::EntityTypeId> { None }
        fn pool(&self, _: EntityId, _: &PoolId, _: PoolMode) -> Option<i64> { None }
        fn attached_to(&self, _: EntityId) -> Vec<EntityId> { vec![] }
        fn contained_in(&self, _: EntityId) -> Vec<EntityId> { vec![] }

        fn observer_seat(&self) -> Option<SeatId> { self.observer_seat }
        fn role_of(&self, seat: SeatId) -> SeatRole {
            self.seat_roles.get(&seat).copied().unwrap_or_default()
        }
        fn owner_seat_of(&self, e: EntityId) -> Option<SeatId> {
            self.owner_seats.get(&e).copied()
        }
    }

    fn e(id: u64) -> EntityId { EntityId::from_raw(id) }
    fn s(id: u64) -> SeatId { SeatId::from_raw(id) }

    // ---- E1-P1 ----------------------------------------------------------

    #[test]
    fn uc_29_e1_p1_owner_seat_matches_observer_true_when_same_seat() {
        let ctx = E1Ctx::new()
            .with_self(e(7))
            .with_observer_seat(s(0))
            .with_owner_seat(e(7), s(0));
        assert!(Predicate::OwnerSeatMatchesObserver.evaluate(&ctx));
    }

    #[test]
    fn uc_29_e1_p1_owner_seat_matches_observer_false_when_owner_differs() {
        let ctx = E1Ctx::new()
            .with_self(e(7))
            .with_observer_seat(s(0))
            .with_owner_seat(e(7), s(1));
        assert!(!Predicate::OwnerSeatMatchesObserver.evaluate(&ctx));
    }

    #[test]
    fn uc_29_e1_p1_owner_seat_matches_observer_false_when_no_observer() {
        let ctx = E1Ctx::new()
            .with_self(e(7))
            .with_owner_seat(e(7), s(0));
        assert!(!Predicate::OwnerSeatMatchesObserver.evaluate(&ctx));
    }

    #[test]
    fn uc_29_e1_p1_owner_seat_matches_observer_false_when_no_owner() {
        let ctx = E1Ctx::new()
            .with_self(e(7))
            .with_observer_seat(s(0));
        assert!(!Predicate::OwnerSeatMatchesObserver.evaluate(&ctx));
    }

    // ---- E1-P2 ----------------------------------------------------------

    #[test]
    fn uc_29_e1_p2_observer_has_role_true_when_assigned() {
        let ctx = E1Ctx::new()
            .with_observer_seat(s(0))
            .with_role(s(0), SeatRole::Gamemaster);
        assert!(Predicate::ObserverHasRole(SeatRole::Gamemaster).evaluate(&ctx));
    }

    #[test]
    fn uc_29_e1_p2_observer_has_role_false_when_role_differs() {
        let ctx = E1Ctx::new()
            .with_observer_seat(s(0))
            .with_role(s(0), SeatRole::Player);
        assert!(!Predicate::ObserverHasRole(SeatRole::Gamemaster).evaluate(&ctx));
    }

    #[test]
    fn uc_29_e1_p2_observer_has_role_false_when_no_observer() {
        let ctx = E1Ctx::new();
        assert!(!Predicate::ObserverHasRole(SeatRole::Gamemaster).evaluate(&ctx));
    }

    // ---- E1-P3 — explain + flatten -------------------------------------

    #[test]
    fn uc_29_e1_p3_explain_owner_seat_mismatch_message_mentions_ownership() {
        let ctx = E1Ctx::new()
            .with_self(e(7))
            .with_observer_seat(s(0))
            .with_owner_seat(e(7), s(1));
        let node = Predicate::OwnerSeatMatchesObserver.explain(&ctx);
        assert!(!node.result());
        assert!(matches!(node, ExplainNode::OwnerSeatMatchesObserver { .. }));
        let msg = flatten_explain(&node);
        assert!(
            msg.to_ascii_lowercase().contains("own"),
            "expected 'own' in failure message; got: {msg}",
        );
    }

    #[test]
    fn uc_29_e1_p3_explain_role_mismatch_message_mentions_role() {
        let ctx = E1Ctx::new()
            .with_observer_seat(s(0))
            .with_role(s(0), SeatRole::Player);
        let node = Predicate::ObserverHasRole(SeatRole::Gamemaster).explain(&ctx);
        assert!(!node.result());
        assert!(matches!(node, ExplainNode::ObserverHasRole { .. }));
        let msg = flatten_explain(&node);
        assert!(
            msg.to_ascii_lowercase().contains("role"),
            "expected 'role' in failure message; got: {msg}",
        );
    }
}
