//! Executor unit tests — part 2.
//!
//! Sibling to `tests.rs`; test helpers are re-declared here.
//! This keeps each file ≤ 600 lines.

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

use std::sync::RwLock;
use std::time::Duration;

use roaring::RoaringBitmap;

use super::*;
use crate::query::retrieve::Retrieve;
use crate::ranking::builtins::register_builtins;
use crate::ranking::executor::ScoredCandidate;
use crate::ranking::registry::ProfileRegistry;
use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp, Window};
use crate::signals::{NoopWalWriter, SignalLedger};
use crate::storage::indexes::bitmap::BitmapIndex;
use crate::storage::indexes::filter::FilterExpr;
use crate::storage::indexes::range::RangeIndex;

fn test_schema() -> crate::schema::Schema {
    let mut builder = SchemaBuilder::new();
    for sig in &["view", "like", "share", "skip", "completion"] {
        let _ = builder
            .signal(
                sig,
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
            .velocity(true)
            .add();
    }
    builder.build().unwrap()
}

fn setup_registry() -> ProfileRegistry {
    let mut reg = ProfileRegistry::new();
    register_builtins(&mut reg).unwrap();
    reg
}

/// Add an item to the in-memory indexes and universe bitmap.
#[allow(clippy::too_many_arguments)]
fn add_item(
    category_idx: &BitmapIndex,
    format_idx: &BitmapIndex,
    creator_idx: &BitmapIndex,
    duration_idx: &RangeIndex<u32>,
    created_at_idx: &RangeIndex<u64>,
    universe: &mut RoaringBitmap,
    id: u64,
    category: &str,
    format: &str,
    creator: u64,
) {
    #[allow(clippy::cast_possible_truncation)]
    let id_u32 = id as u32;
    category_idx.insert(id_u32, category);
    format_idx.insert(id_u32, format);
    creator_idx.insert(id_u32, creator.to_string());
    duration_idx.insert(id_u32, 0u32);
    created_at_idx.insert(id_u32, Timestamp::now().as_nanos());
    universe.insert(id_u32);
}

/// Helper: build executor from test indexes.
#[allow(clippy::too_many_arguments)]
fn make_executor<'a>(
    ledger: &'a SignalLedger,
    profile_reg: &'a ProfileRegistry,
    cat: &'a BitmapIndex,
    fmt: &'a BitmapIndex,
    creator: &'a BitmapIndex,
    tag: &'a BitmapIndex,
    dur: &'a RangeIndex<u32>,
    ts: &'a RangeIndex<u64>,
    universe: &'a RwLock<RoaringBitmap>,
) -> RetrieveExecutor<'a> {
    RetrieveExecutor::new(
        ledger,
        profile_reg,
        Some(cat),
        Some(fmt),
        Some(creator),
        Some(tag),
        Some(dur),
        Some(ts),
        Some(universe),
        None, // embedding_registry
    )
}

#[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 ─────────────────────────────────────

#[test]
fn exploration_injects_random_candidates() {
    // Use inject_exploration directly to verify that exploration candidates
    // from the full candidate list appear in the scored output.

    // Scored list: entities 1-5 (the "top ranked" items).
    let mut scored: Vec<ScoredCandidate> = (1..=5)
        .map(|i| ScoredCandidate {
            entity_id: EntityId::new(i),
            score: 1.0 - (i as f64 * 0.1),
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        })
        .collect();

    // Full candidate list: entities 1-10 (so 6-10 are available for exploration).
    let all_candidates: Vec<EntityId> = (1..=10).map(EntityId::new).collect();

    // 40% exploration -> ceil(0.4 * 5) = 2 exploration slots.
    candidate_gen::inject_exploration(&mut scored, &all_candidates, 0.4);

    assert_eq!(scored.len(), 5, "total output should remain 5");

    // At least one of the exploration candidates (6-10) should be present.
    let exploration_ids: Vec<u64> = scored
        .iter()
        .filter(|c| c.entity_id.as_u64() > 5)
        .map(|c| c.entity_id.as_u64())
        .collect();
    assert_eq!(
        exploration_ids.len(),
        2,
        "expected 2 exploration candidates, got {}",
        exploration_ids.len()
    );

    // Exploration candidates should have score 0.0.
    for c in &scored {
        if c.entity_id.as_u64() > 5 {
            assert_eq!(c.score, 0.0, "exploration candidates should have score 0.0");
        }
    }
}

#[test]
fn exploration_zero_fraction_is_noop() {
    let mut scored: Vec<ScoredCandidate> = (1..=5)
        .map(|i| ScoredCandidate {
            entity_id: EntityId::new(i),
            score: 1.0,
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        })
        .collect();

    let all: Vec<EntityId> = (1..=10).map(EntityId::new).collect();
    candidate_gen::inject_exploration(&mut scored, &all, 0.0);

    // No change when exploration fraction is 0.
    assert_eq!(scored.len(), 5);
    assert!(scored.iter().all(|c| c.entity_id.as_u64() <= 5));
}

#[test]
fn exploration_no_extra_candidates_is_noop() {
    // All candidates are already scored -- no exploration pool.
    let mut scored: Vec<ScoredCandidate> = (1..=5)
        .map(|i| ScoredCandidate {
            entity_id: EntityId::new(i),
            score: 1.0,
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        })
        .collect();

    let all: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    candidate_gen::inject_exploration(&mut scored, &all, 0.5);

    // No change when there are no extra candidates for exploration.
    assert_eq!(scored.len(), 5);
}
