//! Executor unit tests — part 1.

#![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::{QueryError, Retrieve},
    schema::EntityId,
    signals::{NoopWalWriter, SignalLedger},
    storage::indexes::{bitmap::BitmapIndex, filter::FilterExpr, range::RangeIndex},
};

#[test]
fn empty_database_returns_empty_results() {
    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 = 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(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    let query = Retrieve::builder().profile("new").build().unwrap();
    let results = exec.execute(&query).unwrap();
    assert!(results.items.is_empty());
    assert_eq!(results.total_candidates, 0);
    assert!(results.constraints_satisfied);
}

#[test]
fn scan_returns_items_ranked_by_new() {
    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);
    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    let query = Retrieve::builder().profile("new").limit(5).build().unwrap();
    let results = exec.execute(&query).unwrap();
    assert_eq!(results.items.len(), 5);
    assert_eq!(results.total_candidates, 10);
    // "new" sorts by entity_id descending -- highest IDs first.
    assert_eq!(results.items[0].entity_id, EntityId::new(10));
    assert_eq!(results.items[0].rank, 1);
}

#[test]
fn filter_narrows_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();

    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);
    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    let query = Retrieve::builder()
        .profile("new")
        .filter(FilterExpr::CategoryEq("jazz".into()))
        .build()
        .unwrap();
    let results = exec.execute(&query).unwrap();
    // Only 5 jazz items should survive the filter.
    assert_eq!(results.total_candidates, 5);
    for item in &results.items {
        assert!(item.entity_id.as_u64() <= 5);
    }
}

#[test]
fn exclude_removes_ids() {
    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);
    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    let query = Retrieve::builder()
        .profile("new")
        .exclude(vec![EntityId::new(3), EntityId::new(5)])
        .build()
        .unwrap();
    let results = exec.execute(&query).unwrap();
    let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    assert!(!ids.contains(&3));
    assert!(!ids.contains(&5));
}

#[test]
fn pagination_works() {
    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",
            i,
        );
    }

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

    // Page 1
    let query = Retrieve::builder().profile("new").limit(3).build().unwrap();
    let page1 = exec.execute(&query).unwrap();
    assert_eq!(page1.items.len(), 3);
    assert!(page1.next_cursor.is_some());
    assert_eq!(page1.items[0].rank, 1);
    assert_eq!(page1.items[2].rank, 3);

    // Page 2
    let query = Retrieve::builder()
        .profile("new")
        .limit(3)
        .cursor(page1.next_cursor.unwrap())
        .build()
        .unwrap();
    let page2 = exec.execute(&query).unwrap();
    assert_eq!(page2.items.len(), 3);
    assert_eq!(page2.items[0].rank, 4);
}

#[test]
fn profile_not_found_returns_error() {
    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 exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    let query = Retrieve::builder()
        .profile("nonexistent_profile")
        .build()
        .unwrap();
    let result = exec.execute(&query);
    assert!(matches!(result, Err(QueryError::ProfileNotFound(_))));
}

#[test]
fn saved_filter_retains_only_saved_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();

    // Create 10 items.
    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 saved items 3 and 7.
    let user_state = UserStateIndex::new();
    user_state.add_save(42, 3);
    user_state.add_save(42, 7);

    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);

    // Query with Saved(42) filter and FOR USER 42.
    let query = Retrieve::builder()
        .profile("new")
        .for_user(42)
        .filter(FilterExpr::Saved(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(), 2);
    assert!(ids.contains(&3));
    assert!(ids.contains(&7));
}

#[test]
fn for_creator_without_creator_items_returns_empty_not_full_scan() {
    // Regression: when FOR CREATOR is requested but the creator-items index is
    // absent, the executor must return NO candidates (the scope cannot be
    // satisfied) and warn — never fall through to a full-universe scan that
    // silently drops the creator restriction and returns every creator's 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();

    // 10 items, all owned by creator 1 in the creator bitmap index.
    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);

    // `make_executor` wires every bitmap/range index but NOT `creator_items`
    // (that is only set via `with_user_context`), so `self.creator_items` is
    // None here — exactly the condition the fix guards.
    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    );

    let query = Retrieve::builder()
        .profile("new")
        .for_creator(EntityId::new(1))
        .limit(20)
        .build()
        .unwrap();

    let results = exec.execute(&query).unwrap();
    assert!(
        results.items.is_empty(),
        "for_creator with no creator-items index must yield no candidates, got {} items",
        results.items.len()
    );
    assert!(
        results
            .warnings
            .iter()
            .any(|w| w.contains("FOR CREATOR scope requested")),
        "expected a warning explaining the unsatisfiable for_creator scope, got {:?}",
        results.warnings
    );
}

#[test]
fn load_degradation_truncation_keeps_top_ranked_new_items() {
    // Regression: under ReducedCandidates load, the candidate cap is
    // `(limit*4).max(100)`. `scan_candidates` iterates the universe in ASCENDING
    // ID order, so a blind `truncate(cap)` would keep the LOWEST IDs. The "new"
    // profile sorts by DESCENDING ID, so the highest IDs are the top-ranked
    // items — a blind truncate would drop exactly the items that should rank #1.
    // The fix truncates by descending ID for `New`, so the highest IDs survive.
    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();

    // 150 items: more than the degradation cap of (1*4).max(100) = 100, so the
    // truncation path is exercised. The highest IDs (101..=150) would be dropped
    // by a blind ascending-order truncate.
    for i in 1..=150u64 {
        add_item(
            &cat,
            &fmt,
            &creator_idx,
            &dur,
            &ts,
            &mut universe_bm,
            i,
            "jazz",
            "video",
            1,
        );
    }

    let universe = RwLock::new(universe_bm);
    let exec = make_executor(
        &ledger,
        &profile_reg,
        &cat,
        &fmt,
        &creator_idx,
        &tag,
        &dur,
        &ts,
        &universe,
    )
    .with_degradation_level(crate::load::DegradationLevel::ReducedCandidates);

    let query = Retrieve::builder().profile("new").limit(1).build().unwrap();
    let results = exec.execute(&query).unwrap();

    // The #1 "new" item is the highest ID (150). A blind truncate would have
    // dropped 101..=150 before scoring and returned 100 instead.
    assert_eq!(results.items.len(), 1);
    assert_eq!(
        results.items[0].entity_id,
        EntityId::new(150),
        "top 'new' item must be the highest ID, not the highest surviving \
         ascending-order id after truncation"
    );
}
