//! Milestone 6 Phase 6 Integration Tests: Creator Profile Modes + Notification Caps.
//!
//! Tests:
//! 1. `for_creator` + `for_you` ranks creator's items by user preferences.
//! 2. `for_creator` + `hot` ranks hot-sorted items within creator's catalog.
//! 3. Notification caps limit per-creator results.
//! 4. Notification caps enforce total daily limit.
//! 5. Cold-start user sees creator top content.
//! 6. Regression: `for_creator` with high-ID items uses `CreatorItemsBitmap`.

#![allow(
    clippy::unwrap_used,
    clippy::cast_precision_loss,
    clippy::similar_names
)]

use std::collections::HashMap;
use std::time::Duration;

use tidaldb::TidalDb;
use tidaldb::query::retrieve::{NotificationCaps, Retrieve};
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};

// ── Schema ──────────────────────────────────────────────────────────────────

fn test_schema() -> tidaldb::schema::Schema {
    let mut builder = SchemaBuilder::new();
    for &(name, half_life_days) in &[
        ("view", 7),
        ("like", 14),
        ("share", 7),
        ("skip", 1),
        ("completion", 30),
    ] {
        let _ = builder
            .signal(
                name,
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(half_life_days * 24 * 3600),
                },
            )
            .windows(&[
                Window::OneHour,
                Window::TwentyFourHours,
                Window::SevenDays,
                Window::AllTime,
            ])
            .velocity(true)
            .add();
    }
    builder.build().expect("schema must be valid")
}

fn open_ephemeral() -> TidalDb {
    TidalDb::builder()
        .ephemeral()
        .with_schema(test_schema())
        .open()
        .expect("ephemeral open")
}

fn write_items_for_creator(db: &TidalDb, creator_id: u64, item_ids: &[u64]) {
    for &id in item_ids {
        let mut meta = HashMap::new();
        meta.insert("title".to_string(), format!("Item {id}"));
        meta.insert("category".to_string(), "jazz".to_string());
        meta.insert("format".to_string(), "audio".to_string());
        meta.insert("creator_id".to_string(), creator_id.to_string());
        db.write_item_with_metadata(EntityId::new(id), &meta)
            .unwrap();
    }
}

// ── Test 1: for_creator + for_you ───────────────────────────────────────────

#[test]
fn for_creator_for_you_ranks_within_creator_catalog() {
    let db = open_ephemeral();

    // Creator A has items 1-5.
    write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
    // Creator B has items 6-10.
    write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);

    // Signal items 1 and 2 heavily for view (creator A).
    let now = Timestamp::now();
    for _ in 0..20 {
        db.signal("view", EntityId::new(1), 1.0, now).unwrap();
        db.signal("view", EntityId::new(2), 1.0, now).unwrap();
    }

    // Query for_creator(100) + for_you profile + for_user(42).
    let query = Retrieve::builder()
        .profile("for_you")
        .for_user(42)
        .for_creator(EntityId::new(100))
        .limit(10)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    // Only creator A's items should appear.
    for item in &results.items {
        let id = item.entity_id.as_u64();
        assert!(
            (1..=5).contains(&id),
            "expected item from creator A, got id={id}"
        );
    }
    assert!(!results.items.is_empty(), "should return some items");
}

// ── Test 2: for_creator + hot ───────────────────────────────────────────────

#[test]
fn for_creator_hot_ranks_by_hotness_within_catalog() {
    let db = open_ephemeral();

    // Creator A: items 1-5.
    write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
    // Creator B: items 6-10.
    write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);

    // Item 1 gets many recent views, item 2 gets few.
    let now = Timestamp::now();
    for _ in 0..50 {
        db.signal("view", EntityId::new(1), 1.0, now).unwrap();
    }
    db.signal("view", EntityId::new(2), 1.0, now).unwrap();

    // Query for_creator(100) + hot profile.
    let query = Retrieve::builder()
        .profile("hot")
        .for_creator(EntityId::new(100))
        .limit(10)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    // Only creator A's items should appear.
    for item in &results.items {
        let id = item.entity_id.as_u64();
        assert!(
            (1..=5).contains(&id),
            "expected item from creator A, got id={id}"
        );
    }
    assert!(!results.items.is_empty(), "should return some items");
}

// ── Test 3: notification caps per-creator ───────────────────────────────────

#[test]
fn notification_caps_enforce_per_creator_limit() {
    let db = open_ephemeral();

    // Write 5 items from creator A and 5 from creator B.
    write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
    write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);

    // User 1 follows both creators (required for notification profile).
    db.write_relationship(
        EntityId::new(1),
        tidaldb::entities::RelationshipType::Follows,
        EntityId::new(100),
        1.0,
        Timestamp::now(),
    )
    .unwrap();
    db.write_relationship(
        EntityId::new(1),
        tidaldb::entities::RelationshipType::Follows,
        EntityId::new(200),
        1.0,
        Timestamp::now(),
    )
    .unwrap();

    // Signal all items with views so they rank.
    let now = Timestamp::now();
    for id in 1..=10u64 {
        for _ in 0..5 {
            db.signal("view", EntityId::new(id), 1.0, now).unwrap();
        }
    }

    // Retrieve with notification caps: max 1 per creator, max 3 total.
    let query = Retrieve::builder()
        .profile("notification")
        .for_user(1)
        .notification_caps(NotificationCaps {
            max_per_creator_per_day: 1,
            max_total_per_day: 3,
        })
        .limit(20)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    // Verify total <= 3.
    assert!(
        results.items.len() <= 3,
        "expected at most 3 results, got {}",
        results.items.len()
    );

    // Verify no creator appears more than once (need to check creator_id
    // from item metadata -- but we can verify by item ID ranges).
    let creator_a_count = results
        .items
        .iter()
        .filter(|r| r.entity_id.as_u64() <= 5)
        .count();
    let creator_b_count = results
        .items
        .iter()
        .filter(|r| r.entity_id.as_u64() > 5)
        .count();
    assert!(
        creator_a_count <= 1,
        "expected at most 1 item from creator A, got {creator_a_count}"
    );
    assert!(
        creator_b_count <= 1,
        "expected at most 1 item from creator B, got {creator_b_count}"
    );
}

// ── Test 4 (UC-08): cold-start user sees top content on creator page ─────────

/// A user with no preference history (cold-start) queries a creator's catalog
/// via `for_creator`. The system should still return results -- falling back to
/// engagement signals (e.g., view counts) rather than returning empty.
///
/// This validates the UC-08 cold-start behavior: new visitors to a creator's
/// profile page see the creator's most engaging content even without
/// personalization context.
#[test]
fn cold_start_user_sees_creator_top_content() {
    let db = open_ephemeral();

    // Creator has 5 items.
    write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);

    // Signal items 1 and 2 heavily so they rank at the top.
    let now = Timestamp::now();
    for _ in 0..30 {
        db.signal("view", EntityId::new(1), 1.0, now).unwrap();
        db.signal("view", EntityId::new(2), 1.0, now).unwrap();
    }

    // User 999 has never interacted with the system -- pure cold-start.
    // Deliberately no db.write_item(EntityId::new(999)) or any signals.
    let query = Retrieve::builder()
        .profile("for_you")
        .for_user(999)
        .for_creator(EntityId::new(100))
        .limit(10)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    // Cold-start must not return empty -- the creator has items and signals.
    assert!(
        !results.items.is_empty(),
        "cold-start user should still see creator content"
    );

    // All results must belong to creator 100 (items 1-5).
    for item in &results.items {
        let id = item.entity_id.as_u64();
        assert!(
            (1..=5).contains(&id),
            "expected item from creator 100, got id={id}"
        );
    }
}

// ── Test 5: notification caps total daily limit ─────────────────────────────

#[test]
fn notification_caps_enforce_total_limit() {
    let db = open_ephemeral();

    // 10 items from 10 different creators.
    for id in 1..=10u64 {
        write_items_for_creator(&db, id * 100, &[id]);
        // User follows each creator.
        db.write_relationship(
            EntityId::new(1),
            tidaldb::entities::RelationshipType::Follows,
            EntityId::new(id * 100),
            1.0,
            Timestamp::now(),
        )
        .unwrap();
    }

    // Signal all items.
    let now = Timestamp::now();
    for id in 1..=10u64 {
        for _ in 0..5 {
            db.signal("view", EntityId::new(id), 1.0, now).unwrap();
        }
    }

    let query = Retrieve::builder()
        .profile("notification")
        .for_user(1)
        .notification_caps(NotificationCaps {
            max_per_creator_per_day: 2,
            max_total_per_day: 3,
        })
        .limit(20)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    assert!(
        results.items.len() <= 3,
        "expected at most 3 total results, got {}",
        results.items.len()
    );
}

// ── Test 6: Regression -- for_creator uses CreatorItemsBitmap for high IDs ──

/// Regression test for the bug where `for_creator` was set by the builder
/// but never read by the executor. Stage 1 always called `scan_candidates`
/// which caps at `max(limit * multiplier, 200)` items from the universe
/// bitmap by ascending ID. For a large catalog where a creator's items have
/// IDs above that cap, those items were never generated as candidates.
///
/// This test writes 300 items from creator 999 at IDs 201-500 (above the
/// scan cap of 200), plus 200 filler items from other creators at IDs 1-200.
/// Before the fix, `for_creator(999)` would return empty because the scan
/// cap never reached IDs > 200. After the fix, the `CreatorItemsBitmap`
/// is used for O(k) candidate generation, returning all of creator 999's items.
#[test]
fn for_creator_high_id_items_uses_creator_bitmap() {
    let db = open_ephemeral();

    // Write 200 filler items from other creators (IDs 1-200).
    for id in 1..=200u64 {
        let mut meta = HashMap::new();
        meta.insert("title".to_string(), format!("Filler {id}"));
        meta.insert("category".to_string(), "pop".to_string());
        meta.insert("format".to_string(), "audio".to_string());
        meta.insert("creator_id".to_string(), "1".to_string());
        db.write_item_with_metadata(EntityId::new(id), &meta)
            .unwrap();
    }

    // Write 300 items from creator 999 at IDs 201-500.
    let creator_item_ids: Vec<u64> = (201..=500).collect();
    write_items_for_creator(&db, 999, &creator_item_ids);

    // Query for_creator(999) with limit=50.
    let query = Retrieve::builder()
        .profile("new")
        .for_creator(EntityId::new(999))
        .limit(50)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    // Must return results -- the bitmap path finds all 300 items regardless
    // of scan cap.
    assert!(
        !results.items.is_empty(),
        "for_creator must return results even when all items have IDs above the scan cap"
    );

    // All returned items must belong to creator 999 (IDs 201-500).
    for item in &results.items {
        let id = item.entity_id.as_u64();
        assert!(
            (201..=500).contains(&id),
            "expected item from creator 999 (IDs 201-500), got id={id}"
        );
    }

    // Verify we get up to limit items (creator has 300 items, limit=50).
    assert_eq!(
        results.items.len(),
        50,
        "should return exactly limit=50 items from the 300 available"
    );

    // Also verify unknown creator returns empty, not an error.
    let query_unknown = Retrieve::builder()
        .profile("new")
        .for_creator(EntityId::new(99999))
        .limit(10)
        .build()
        .unwrap();
    let results_unknown = db.retrieve(&query_unknown).unwrap();
    assert!(
        results_unknown.items.is_empty(),
        "unknown creator should return empty results"
    );
}
