//! Executor unit tests — part 2.
//!
//! Sibling to `tests.rs`; both files share fixtures from `super::test_support`.
//! The split exists only to keep each file ≤ 600 lines (`CODING_GUIDELINES` §9).

#![cfg(test)]
#![allow(clippy::unwrap_used)]

use std::sync::RwLock;

use roaring::RoaringBitmap;

use super::test_support::{add_item, make_executor, setup_registry, test_schema};
use crate::{
    entities::{CreatorItemsBitmap, HardNegIndex, InteractionLedger, UserStateIndex},
    query::retrieve::Retrieve,
    ranking::executor::ScoredCandidate,
    schema::{EntityId, EntityKind, Timestamp},
    signals::{NoopWalWriter, SignalLedger},
    storage::{
        Tag, encode_key,
        indexes::{bitmap::BitmapIndex, filter::FilterExpr, range::RangeIndex},
    },
};

#[test]
fn liked_filter_retains_only_liked_items() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let mut universe_bm = RoaringBitmap::new();

    for i in 1..=10u64 {
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            "jazz",
            "video",
            1,
        );
    }

    let universe = RwLock::new(universe_bm);

    // User 42 has liked items 2, 5, and 9.
    let user_state = UserStateIndex::new();
    user_state.add_like(42, 2);
    user_state.add_like(42, 5);
    user_state.add_like(42, 9);

    let hard_neg = HardNegIndex::new();
    let interaction = InteractionLedger::new();
    let creator_items = CreatorItemsBitmap::new();

    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_user_context(&user_state, &hard_neg, &interaction, &creator_items);

    let query = Retrieve::builder()
        .profile("new")
        .for_user(42)
        .filter(FilterExpr::Liked(42))
        .limit(20)
        .build()
        .unwrap();

    let results = exec.execute(&query).unwrap();
    let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    assert_eq!(ids.len(), 3);
    assert!(ids.contains(&2));
    assert!(ids.contains(&5));
    assert!(ids.contains(&9));
}

#[test]
fn in_progress_filter_retains_partially_consumed() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let mut universe_bm = RoaringBitmap::new();

    for i in 1..=5u64 {
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            "jazz",
            "video",
            1,
        );
    }

    let universe = RwLock::new(universe_bm);

    // User 42: item 2 at 30% (in progress), item 4 at 90% (past threshold).
    let user_state = UserStateIndex::new();
    user_state.record_completion(42, 2, 0.3);
    user_state.record_completion(42, 4, 0.9);

    let hard_neg = HardNegIndex::new();
    let interaction = InteractionLedger::new();
    let creator_items = CreatorItemsBitmap::new();

    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_user_context(&user_state, &hard_neg, &interaction, &creator_items);

    // threshold=0.8: only item 2 (0.3 < 0.8) qualifies. Item 4 (0.9 >= 0.8) does not.
    let query = Retrieve::builder()
        .profile("new")
        .for_user(42)
        .filter(FilterExpr::InProgress {
            user_id: 42,
            threshold: 0.8,
        })
        .limit(20)
        .build()
        .unwrap();

    let results = exec.execute(&query).unwrap();
    let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    assert_eq!(ids.len(), 1);
    assert!(ids.contains(&2));
}

#[test]
fn saved_filter_combined_with_category_filter() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let mut universe_bm = RoaringBitmap::new();

    // Items 1-5 are "jazz", items 6-10 are "blues".
    for i in 1..=10u64 {
        let c = if i <= 5 { "jazz" } else { "blues" };
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            c,
            "video",
            1,
        );
    }

    let universe = RwLock::new(universe_bm);

    // User 42 saved items 3 (jazz), 7 (blues), 9 (blues).
    let user_state = UserStateIndex::new();
    user_state.add_save(42, 3);
    user_state.add_save(42, 7);
    user_state.add_save(42, 9);

    let hard_neg = HardNegIndex::new();
    let interaction = InteractionLedger::new();
    let creator_items = CreatorItemsBitmap::new();

    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_user_context(&user_state, &hard_neg, &interaction, &creator_items);

    // Saved(42) AND CategoryEq("blues") -> only blues items that are saved = 7, 9.
    let query = Retrieve::builder()
        .profile("new")
        .for_user(42)
        .filter(FilterExpr::Saved(42))
        .filter(FilterExpr::CategoryEq("blues".into()))
        .limit(20)
        .build()
        .unwrap();

    let results = exec.execute(&query).unwrap();
    let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    assert_eq!(ids.len(), 2);
    assert!(ids.contains(&7));
    assert!(ids.contains(&9));
}

#[test]
fn signal_ranked_strategy_returns_candidates() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let mut universe_bm = RoaringBitmap::new();

    let base_ns = 1_708_000_000_000_000_000u64;
    for i in 1..=5u64 {
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            "jazz",
            "video",
            1,
        );
        let t = Timestamp::from_nanos(base_ns - i * 60_000_000_000);
        #[allow(clippy::cast_precision_loss)]
        ledger
            .record_signal("view", EntityId::new(i), (6 - i) as f64, t)
            .unwrap();
    }

    let universe = RwLock::new(universe_bm);
    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    // Use "hot" profile which scores based on views.
    let query = Retrieve::builder().profile("hot").limit(5).build().unwrap();
    let results = exec.execute(&query).unwrap();
    // Hot has no restrictive gates, so all 5 should come through.
    assert!(!results.items.is_empty());
}

// Exploration-injection tests live in `candidate_gen::tests` (the module that
// owns `inject_exploration`); they are intentionally NOT duplicated here.

// ── Cohort rescore coverage (WARNING: re-normalize after cohort rescore) ──────

#[test]
fn cohort_rescore_normalizes_score_to_unit_range_and_reorders() {
    use crate::{
        cohort::{CohortDef, CohortRegistry, CohortSignalLedger, Predicate},
        ranking::profile::{Boost, SignalAgg},
        schema::Window,
    };

    let schema = test_schema();
    let cohort_ledger = CohortSignalLedger::new(&schema);
    let global_ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let view_id = global_ledger.resolve_signal_type("view").unwrap();

    // Cohort "tech" has high view signal on item 2, low on item 1. Record at
    // "now" so the decay score read back in rescore (which uses Timestamp::now)
    // is essentially undecayed and the magnitudes are comparable.
    let now_ns = Timestamp::now().as_nanos();
    let tech: std::sync::Arc<str> = std::sync::Arc::from("tech");
    cohort_ledger.record(&tech, EntityId::new(1), view_id, 1.0, now_ns);
    cohort_ledger.record(&tech, EntityId::new(2), view_id, 100.0, now_ns);

    let registry = CohortRegistry::new();
    registry
        .define(CohortDef {
            name: "tech".into(),
            predicate: Predicate::Eq {
                field: "x".into(),
                value: "1".into(),
            },
        })
        .unwrap();

    // Build the executor solely to call rescore_with_cohort (a pub(super) method).
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let universe = RwLock::new(RoaringBitmap::new());
    let exec = make_executor(
        &global_ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_cohort_context(&cohort_ledger, &registry);

    // Pre-scored candidates with arbitrary normalized scores (item 1 ranked first).
    let mut scored: Vec<ScoredCandidate> = vec![
        ScoredCandidate {
            entity_id: EntityId::new(1),
            score: 0.9,
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        },
        ScoredCandidate {
            entity_id: EntityId::new(2),
            score: 0.1,
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        },
    ];

    let mut profile = crate::ranking::test_fixtures::minimal_profile("cohort_rescore");
    profile.boosts = vec![Boost {
        signal: "view".into(),
        agg: SignalAgg::DecayScore,
        window: Window::AllTime,
        weight: 1.0,
    }];

    exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
        .unwrap();

    // After rescore: item 2 (high cohort view) must rank first, and EVERY score
    // must be back in [0, 1] (the WARNING was that rescore left un-normalized
    // value*weight sums, breaking the RetrieveResult.score contract).
    assert_eq!(scored[0].entity_id, EntityId::new(2));
    assert_eq!(scored[1].entity_id, EntityId::new(1));
    for c in &scored {
        assert!(
            (0.0..=1.0).contains(&c.score),
            "cohort rescore must re-normalize to [0,1], got {}",
            c.score
        );
    }
    // Top score is exactly 1.0 after min-max normalization.
    assert!((scored[0].score - 1.0).abs() < 1e-12);
}

// ── Geo post-filter coverage (Stage 2.4) ──────────────────────────────────────

#[test]
fn geo_filter_retains_only_items_within_radius() {
    use crate::storage::{StorageEngine, memory::InMemoryBackend};

    // Encode item metadata in the items-only length-prefixed format that
    // `db::deserialize_metadata` (used by Stage 2.4) reads back. Mirrors the
    // documented format in `db::metadata::serialize_metadata`, which is not
    // visible outside the db module.
    fn encode_meta(pairs: &[(&str, &str)]) -> Vec<u8> {
        let mut buf = Vec::new();
        buf.extend_from_slice(&(pairs.len() as u32).to_le_bytes());
        for (k, v) in pairs {
            buf.extend_from_slice(&(k.len() as u32).to_le_bytes());
            buf.extend_from_slice(k.as_bytes());
            buf.extend_from_slice(&(v.len() as u32).to_le_bytes());
            buf.extend_from_slice(v.as_bytes());
        }
        buf
    }

    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let mut universe_bm = RoaringBitmap::new();

    for i in 1..=3u64 {
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            "food",
            "video",
            1,
        );
    }
    let universe = RwLock::new(universe_bm);

    // Item 1 is at NYC; item 2 at LA; item 3 has no coordinates.
    let storage = InMemoryBackend::new();
    let put_geo = |id: u64, lat: &str, lng: &str| {
        let bytes = encode_meta(&[("latitude", lat), ("longitude", lng)]);
        let key = encode_key(EntityId::new(id), Tag::Meta, b"");
        storage.put(&key, &bytes).unwrap();
    };
    put_geo(1, "40.7128", "-74.0060");
    put_geo(2, "34.0522", "-118.2437");
    // Item 3: metadata with no coordinates.
    {
        let bytes = encode_meta(&[]);
        let key = encode_key(EntityId::new(3), Tag::Meta, b"");
        storage.put(&key, &bytes).unwrap();
    }

    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_items_storage(&storage);

    // NearLocation NYC, 50km radius -> only item 1 qualifies (item 2 is in LA,
    // item 3 has no coordinates).
    let query = Retrieve::builder()
        .entity(EntityKind::Item)
        .profile("new")
        .filter(FilterExpr::near_location(40.7128, -74.0060, 50.0))
        .limit(20)
        .build()
        .unwrap();

    let results = exec.execute(&query).unwrap();
    let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    assert_eq!(ids, vec![1]);
}

// ── Social-graph scoping coverage (Stage 2.5) ─────────────────────────────────

#[test]
fn social_graph_filter_scopes_to_followed_creator_items() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let mut universe_bm = RoaringBitmap::new();
    for i in 1..=4u64 {
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            "jazz",
            "video",
            1,
        );
    }
    let universe = RwLock::new(universe_bm);

    // User 42 follows creator 10. Creator 10 owns items 1, 2 (but not 3, 4).
    let user_state = UserStateIndex::new();
    user_state.add_follow(42, 10);
    user_state.add_creator_follower(10, 42);
    let creator_items = CreatorItemsBitmap::new();
    creator_items.add_item(10, 1);
    creator_items.add_item(10, 2);

    let hard_neg = HardNegIndex::new();
    let interaction = InteractionLedger::new();

    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_user_context(&user_state, &hard_neg, &interaction, &creator_items);

    let query = Retrieve::builder()
        .profile("new")
        .for_user(42)
        .filter(FilterExpr::social_graph(42, 1))
        .limit(20)
        .build()
        .unwrap();

    let results = exec.execute(&query).unwrap();
    let mut ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    ids.sort_unstable();
    // Only items from followed creator 10 (items 1, 2) survive social scoping.
    assert_eq!(ids, vec![1, 2]);
}

// ── Notification cap coverage (Stage 4.5) ─────────────────────────────────────

#[test]
fn notification_caps_limit_total_and_per_creator() {
    use std::sync::Arc;

    use crate::{db::notification_tracker::NotificationTracker, query::retrieve::NotificationCaps};

    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let profile_reg = setup_registry();
    let cat = BitmapIndex::new("category");
    let fmt = BitmapIndex::new("format");
    let creator_idx = BitmapIndex::new("creator");
    let tag = BitmapIndex::new("tags");
    let dur: RangeIndex<u32> = RangeIndex::new("duration");
    let ts: RangeIndex<u64> = RangeIndex::new("created_at");
    let universe = RwLock::new(RoaringBitmap::new());
    let tracker = Arc::new(NotificationTracker::new());

    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_notification_tracker(&tracker);

    // Four candidates: creators 100 (x2), 200, 300.
    let mk = |id: u64, creator: u64| ScoredCandidate {
        entity_id: EntityId::new(id),
        score: 1.0,
        signal_snapshot: vec![],
        creator_id: Some(EntityId::new(creator)),
        format: None,
    };
    let candidates = vec![mk(1, 100), mk(2, 100), mk(3, 200), mk(4, 300)];

    // Cap: 1 per creator, 2 total. Expect: item 1 (creator 100), item 3 (creator
    // 200) -> total cap 2 reached; item 2 dropped by per-creator cap, item 4 by
    // total cap.
    let caps = NotificationCaps {
        max_per_creator_per_day: 1,
        max_total_per_day: 2,
    };

    let retained = exec.apply_notification_caps(candidates, caps, 42, "notification");
    let ids: Vec<u64> = retained.iter().map(|c| c.entity_id.as_u64()).collect();
    assert_eq!(ids, vec![1, 3]);
}

// ── Social-graph scope resolution traversal (mod.rs) ──────────────────────────

#[test]
fn extract_social_graph_params_walks_and_or_but_not_not() {
    use super::extract_social_graph_params;

    // Bare SocialGraph.
    assert_eq!(
        extract_social_graph_params(&FilterExpr::social_graph(42, 2)),
        Some((42, 2))
    );

    // Nested under And (already worked before the fix).
    let and = FilterExpr::And(vec![
        FilterExpr::CategoryEq("jazz".into()),
        FilterExpr::social_graph(7, 1),
    ]);
    assert_eq!(extract_social_graph_params(&and), Some((7, 1)));

    // Nested under Or: the Stage 2.5 inclusion FILTER (`extract_user_state_filters`)
    // recurses into Or, so the scope resolver MUST too — otherwise filtering and
    // scoring scopes disagree. This is the case the fix added.
    let or = FilterExpr::Or(vec![
        FilterExpr::CategoryEq("jazz".into()),
        FilterExpr::social_graph(9, 2),
    ]);
    assert_eq!(extract_social_graph_params(&or), Some((9, 2)));

    // Deeply nested Or-within-And.
    let nested = FilterExpr::And(vec![
        FilterExpr::FormatEq("video".into()),
        FilterExpr::Or(vec![
            FilterExpr::CategoryEq("a".into()),
            FilterExpr::social_graph(3, 1),
        ]),
    ]);
    assert_eq!(extract_social_graph_params(&nested), Some((3, 1)));

    // Under Not: deliberately NOT extracted (a negated deferred filter has no
    // positive scope; `reject_negated_deferred_filters` rejects such queries up
    // front, mirroring `extract_user_state_filters`).
    let negated = FilterExpr::Not(Box::new(FilterExpr::social_graph(5, 1)));
    assert_eq!(extract_social_graph_params(&negated), None);
}
