//! Milestone 6 Phase 4 Integration Tests: Collections, Saved Searches, Cross-Session Preference.
//!
//! Exercises the M6p4 feature set end-to-end through `TidalDb`:
//!
//! 1. Collection CRUD (create, add, list, remove).
//! 2. `FilterExpr::InCollection` constrains retrieve candidates.
//! 3. `InProgress` user-state filter evaluation.
//! 4. Saved search CRUD (save, list, retrieve, delete).
//! 5. Collection persistence across restart.
//! 6. Saved search persistence across restart.
//! 7. Cross-session preference aggregation.
//! 8. `in_collection` filter performance.

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

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

use tidaldb::TidalDb;
use tidaldb::entities::Visibility;
use tidaldb::query::retrieve::Retrieve;
use tidaldb::schema::{
    AgentPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window,
};
use tidaldb::storage::indexes::filter::FilterExpr;

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

fn m6p4_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("m6p4 schema must be valid")
}

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

fn write_items(db: &TidalDb, ids: &[u64]) {
    for &id in 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());
        db.write_item_with_metadata(EntityId::new(id), &meta)
            .unwrap();
    }
}

// ── Test 1: Collection create, add, list ────────────────────────────────────

#[test]
fn collection_create_add_list() {
    let db = open_ephemeral();
    let owner = EntityId::new(1);

    let cid = db
        .create_collection(owner, "favorites", Visibility::Private)
        .unwrap();
    assert_eq!(cid.as_u64(), 1);

    db.add_to_collection(cid, EntityId::new(100)).unwrap();
    db.add_to_collection(cid, EntityId::new(200)).unwrap();
    // Idempotent: adding same item twice is fine.
    db.add_to_collection(cid, EntityId::new(100)).unwrap();

    let collections = db.list_collections(owner).unwrap();
    assert_eq!(collections.len(), 1);
    assert_eq!(collections[0].name, "favorites");
    assert_eq!(collections[0].item_count, 2); // 100 and 200
    assert_eq!(collections[0].visibility, Visibility::Private);
}

// ── Test 2: Remove from collection ──────────────────────────────────────────

#[test]
fn collection_remove_item() {
    let db = open_ephemeral();
    let owner = EntityId::new(1);
    let cid = db
        .create_collection(owner, "watchlist", Visibility::Public)
        .unwrap();

    db.add_to_collection(cid, EntityId::new(10)).unwrap();
    db.add_to_collection(cid, EntityId::new(20)).unwrap();
    db.add_to_collection(cid, EntityId::new(30)).unwrap();

    db.remove_from_collection(cid, EntityId::new(20)).unwrap();
    // Removing again is a no-op.
    db.remove_from_collection(cid, EntityId::new(20)).unwrap();

    let collections = db.list_collections(owner).unwrap();
    assert_eq!(collections[0].item_count, 2); // 10 and 30
}

// ── Test 3: FilterExpr::InCollection constrains retrieve candidates ─────────

#[test]
fn retrieve_with_in_collection_filter() {
    let db = open_ephemeral();
    let owner = EntityId::new(1);

    // Write 10 items.
    write_items(&db, &(1..=10).collect::<Vec<_>>());

    // Create a collection with items 3, 7, 9.
    let cid = db
        .create_collection(owner, "picks", Visibility::Private)
        .unwrap();
    db.add_to_collection(cid, EntityId::new(3)).unwrap();
    db.add_to_collection(cid, EntityId::new(7)).unwrap();
    db.add_to_collection(cid, EntityId::new(9)).unwrap();

    // Retrieve with InCollection filter.
    let query = Retrieve::builder()
        .profile("new")
        .filter(FilterExpr::in_collection(cid))
        .limit(20)
        .build()
        .unwrap();
    let results = db.retrieve(&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(&3));
    assert!(ids.contains(&7));
    assert!(ids.contains(&9));
}

// ── Test 4: InProgress user-state filter ────────────────────────────────────

#[test]
fn retrieve_in_progress_filter() {
    let db = open_ephemeral();
    let user_id = 42u64;

    // Write 5 items.
    write_items(&db, &[1, 2, 3, 4, 5]);

    // Record completion progress via user-state index.
    // Item 2: 30% (in progress), Item 4: 95% (complete), Item 5: 50% (in progress).
    db.user_state().record_completion(user_id, 2, 0.3);
    db.user_state().record_completion(user_id, 4, 0.95);
    db.user_state().record_completion(user_id, 5, 0.5);

    // Retrieve with InProgress filter (threshold=0.9).
    let query = Retrieve::builder()
        .profile("new")
        .for_user(user_id)
        .filter(FilterExpr::InProgress {
            user_id,
            threshold: 0.9,
        })
        .limit(20)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
    // Items 2 (0.3) and 5 (0.5) are in progress (0 < v < 0.9).
    // Item 4 (0.95) is complete (>= 0.9).
    assert_eq!(ids.len(), 2, "expected 2 in-progress items, got {ids:?}");
    assert!(ids.contains(&2));
    assert!(ids.contains(&5));
}

// ── Test 5: Saved search CRUD ───────────────────────────────────────────────

#[test]
fn saved_search_crud() {
    let db = open_ephemeral();
    let user = EntityId::new(1);

    db.save_search(user, "jazz-tunes", "jazz music", None)
        .unwrap();
    db.save_search(user, "rust-db", "rust database", None)
        .unwrap();

    let searches = db.list_saved_searches(user).unwrap();
    assert_eq!(searches.len(), 2);

    let names: Vec<&str> = searches.iter().map(|s| s.name.as_str()).collect();
    assert!(names.contains(&"jazz-tunes"));
    assert!(names.contains(&"rust-db"));
}

// ── Test 6: Delete saved search ─────────────────────────────────────────────

#[test]
fn saved_search_delete() {
    let db = open_ephemeral();
    let user = EntityId::new(1);

    db.save_search(user, "temp-search", "temporary query", None)
        .unwrap();

    let before = db.list_saved_searches(user).unwrap();
    assert_eq!(before.len(), 1);

    db.delete_saved_search(user, "temp-search").unwrap();

    let after = db.list_saved_searches(user).unwrap();
    assert!(after.is_empty());
}

// ── Test 7: Collections persist across restart ──────────────────────────────

#[test]
fn collections_persist_across_restart() {
    let dir = tempfile::tempdir().unwrap();
    let schema = m6p4_schema();

    // First open: create collection and add items.
    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema.clone())
            .open()
            .unwrap();

        let cid = db
            .create_collection(EntityId::new(1), "watchlist", Visibility::Private)
            .unwrap();
        db.add_to_collection(cid, EntityId::new(100)).unwrap();
        db.add_to_collection(cid, EntityId::new(200)).unwrap();

        db.close().unwrap();
    }

    // Second open: verify collection survives.
    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema)
            .open()
            .unwrap();

        let collections = db.list_collections(EntityId::new(1)).unwrap();
        assert_eq!(collections.len(), 1, "collection should survive restart");
        assert_eq!(collections[0].name, "watchlist");
        assert_eq!(collections[0].item_count, 2);

        db.close().unwrap();
    }
}

// ── Test 8: Saved searches persist across restart ───────────────────────────

#[test]
fn saved_searches_persist_across_restart() {
    let dir = tempfile::tempdir().unwrap();
    let schema = m6p4_schema();

    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema.clone())
            .open()
            .unwrap();

        db.save_search(EntityId::new(1), "my-query", "rust database", None)
            .unwrap();

        db.close().unwrap();
    }

    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema)
            .open()
            .unwrap();

        let searches = db.list_saved_searches(EntityId::new(1)).unwrap();
        assert_eq!(searches.len(), 1, "saved search should survive restart");
        assert_eq!(searches[0].name, "my-query");
        assert_eq!(searches[0].query_text, "rust database");

        db.close().unwrap();
    }
}

// ── Test 9: Cross-session preference aggregation ────────────────────────────

#[test]
fn cross_session_preference_aggregation() {
    let dir = tempfile::tempdir().unwrap();

    let mut builder = SchemaBuilder::new();
    let _ = builder
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(7 * 24 * 3600),
            },
        )
        .windows(&[Window::OneHour, Window::AllTime])
        .velocity(false)
        .add();
    // Declare the 4-dimensional embedding slot so PreferenceVectors initializes
    // with dim=4 (matching the embeddings written in this test).
    builder.embedding_slot("content", EntityKind::Item, 4);
    // Add a session policy so we can open sessions.
    builder.session_policy(
        "default",
        AgentPolicy {
            allowed_signals: vec!["view".to_string()],
            denied_signals: vec![],
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 100,
        },
    );
    let schema = builder.build().unwrap();

    let db = TidalDb::builder()
        .with_data_dir(dir.path())
        .with_schema(schema)
        .open()
        .unwrap();

    // Write an item with an embedding.
    let item_id = EntityId::new(42);
    let mut meta = HashMap::new();
    meta.insert("title".to_string(), "Test Item".to_string());
    meta.insert("category".to_string(), "jazz".to_string());
    meta.insert("format".to_string(), "audio".to_string());
    db.write_item_with_metadata(item_id, &meta).unwrap();

    // Write an embedding for the item.
    let embedding = vec![1.0f32, 0.0, 0.0, 0.0];
    db.write_item_embedding(item_id, &embedding).unwrap();

    // Verify no preference exists for user yet.
    let user_id = 1u64;
    assert!(
        db.preference_vectors().get(user_id).is_none(),
        "no preference before session"
    );

    // Open a session, signal the item, close session.
    let handle = db
        .start_session(user_id, "test-agent", "default", HashMap::new())
        .unwrap();

    db.session_signal(&handle, "view", item_id, 1.0, Timestamp::now(), None)
        .unwrap();

    db.close_session(handle).unwrap();

    // After close_session, the preference vector should be non-empty
    // (blended from the item's embedding with damping=0.1).
    let pref = db.preference_vectors().get(user_id);
    assert!(
        pref.is_some(),
        "preference should exist after cross-session aggregation"
    );
    let pref = pref.unwrap();
    assert_eq!(
        pref.len(),
        4,
        "preference should have same dim as embedding"
    );
    // The preference should be approximately [1, 0, 0, 0] (normalized).
    assert!(
        pref[0] > 0.5,
        "first component should be dominant, got {:.4}",
        pref[0]
    );

    db.close().unwrap();
}

// ── Test 10: InCollection filter performance ────────────────────────────────

#[test]
fn in_collection_filter_performance() {
    let db = open_ephemeral();
    let owner = EntityId::new(1);

    // Write 1000 items.
    for id in 1..=1000u64 {
        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());
        db.write_item_with_metadata(EntityId::new(id), &meta)
            .unwrap();
    }

    // Create a collection with 100 items.
    let cid = db
        .create_collection(owner, "bench-collection", Visibility::Private)
        .unwrap();
    for id in 1..=100u64 {
        db.add_to_collection(cid, EntityId::new(id)).unwrap();
    }

    // Time the retrieve with InCollection filter.
    let start = std::time::Instant::now();
    let query = Retrieve::builder()
        .profile("new")
        .filter(FilterExpr::in_collection(cid))
        .limit(100)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();
    let elapsed = start.elapsed();

    assert_eq!(results.total_candidates, 100);
    assert!(
        elapsed.as_millis() < 10,
        "in_collection filter took {}ms, expected < 10ms",
        elapsed.as_millis()
    );
}
