//! Collection CRUD operations and rebuild logic on `TidalDb`.

use std::sync::Arc;

use super::TidalDb;
use crate::{
    entities::{
        Collection, CollectionId, CollectionIndex, CollectionInfo, Visibility,
        deserialize_collection, serialize_collection,
    },
    schema::{EntityId, TidalError, Timestamp},
    session::{SavedSearch, SavedSearchInfo, deserialize_saved_search, serialize_saved_search},
    storage::{StorageEngine, Tag, encode_key, entity_tag_prefix},
};

impl TidalDb {
    /// Create a named collection for an owner user.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if no storage is available.
    pub fn create_collection(
        &self,
        owner: EntityId,
        name: &str,
        visibility: Visibility,
    ) -> crate::Result<CollectionId> {
        self.require_writeable("create_collection")?;
        let id = self.collection_index.next_collection_id();
        let collection = Collection {
            id,
            owner_id: owner.as_u64(),
            name: name.to_string(),
            visibility,
            created_at_ns: Timestamp::now().as_nanos(),
            item_ids: vec![],
        };

        // Persist to storage.
        self.persist_collection(&collection)?;

        // Update in-memory index.
        self.collection_index.create(collection);

        Ok(id)
    }

    /// Add an item to a collection. Idempotent.
    ///
    /// # Errors
    ///
    /// - `TidalError::InvalidInput` if `item_id` exceeds the `u32` item-universe
    ///   limit (collection membership is tracked in a `u32`-keyed `RoaringBitmap`).
    /// - `TidalError::Internal` if the collection does not exist.
    /// - `TidalError::Storage` on persistence failure.
    pub fn add_to_collection(
        &self,
        collection_id: CollectionId,
        item_id: EntityId,
    ) -> crate::Result<()> {
        self.require_writeable("add_to_collection")?;
        let item_id_u32 = narrow_collection_item_id(item_id, "add_to_collection")?;

        // Durability-before-mutation: build the would-be-updated collection from a
        // READ of the current meta, persist it, and only then mutate the in-memory
        // index. The prior order (mutate the bitmap, then re-persist) left memory
        // ahead of disk on a persist failure — the item showed in queries but was
        // gone after restart. `add_item` is idempotent, so applying it here and
        // again in-memory below yields the same result.
        let mut collection = self
            .collection_index
            .get_meta(collection_id)
            .ok_or_else(|| {
                TidalError::internal(
                    "add_to_collection",
                    format!("collection {} not found", collection_id.as_u64()),
                )
            })?;
        if !collection.item_ids.contains(&item_id_u32) {
            collection.item_ids.push(item_id_u32);
        }
        self.persist_collection(&collection)?;

        // Persist succeeded — now make the change visible in the in-memory index.
        self.collection_index.add_item(collection_id, item_id_u32);

        #[cfg(any(test, feature = "test-utils"))]
        crate::testing::crash_injector::check_crash_point(
            crate::testing::CrashPoint::CollectionIndexUpdate,
        );

        Ok(())
    }

    /// Remove an item from a collection. No-op if item not in collection.
    ///
    /// # Errors
    ///
    /// - `TidalError::InvalidInput` if `item_id` exceeds the `u32` item-universe limit.
    /// - `TidalError::Storage` on persistence failure.
    pub fn remove_from_collection(
        &self,
        collection_id: CollectionId,
        item_id: EntityId,
    ) -> crate::Result<()> {
        self.require_writeable("remove_from_collection")?;
        let item_id_u32 = narrow_collection_item_id(item_id, "remove_from_collection")?;

        // Durability-before-mutation: persist the post-removal collection first,
        // then drop the item from the in-memory index. A no-op if the collection
        // does not exist (matching the documented no-op-on-absent contract).
        if let Some(mut collection) = self.collection_index.get_meta(collection_id) {
            collection.item_ids.retain(|&x| x != item_id_u32);
            self.persist_collection(&collection)?;
            self.collection_index
                .remove_item(collection_id, item_id_u32);
        }

        Ok(())
    }

    /// List all collections owned by a user.
    ///
    /// # Errors
    ///
    /// This method currently does not produce errors but returns `Result` for
    /// API consistency and future storage-backed listing.
    pub fn list_collections(&self, owner: EntityId) -> crate::Result<Vec<CollectionInfo>> {
        Ok(self.collection_index.list_by_owner(owner.as_u64()))
    }

    /// Access the collection index (for use by the executor).
    #[must_use]
    #[allow(clippy::missing_const_for_fn)] // Arc prevents const in practice
    pub fn collection_index(&self) -> &Arc<CollectionIndex> {
        &self.collection_index
    }

    // ── Saved Search CRUD ────────────────────────────────────────────────

    /// Save a search configuration for later re-execution.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if storage is unavailable.
    pub fn save_search(
        &self,
        user: EntityId,
        name: &str,
        query_text: &str,
        _filter_expr: Option<crate::storage::indexes::filter::FilterExpr>,
    ) -> crate::Result<()> {
        self.require_writeable("save_search")?;
        let ss = SavedSearch {
            user_id: user.as_u64(),
            name: name.to_string(),
            query_text: query_text.to_string(),
            filter_expr_json: None, // Filter persistence deferred; query_text is always stored.
            created_at_ns: Timestamp::now().as_nanos(),
        };

        let key = encode_key(
            EntityId::new(user.as_u64()),
            Tag::SavedSearch,
            name.as_bytes(),
        );
        let value = serialize_saved_search(&ss);
        self.persist_to_items(&key, &value, "save_search")
    }

    /// List all saved searches for a user.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if storage is unavailable.
    pub fn list_saved_searches(&self, user: EntityId) -> crate::Result<Vec<SavedSearchInfo>> {
        let prefix = entity_tag_prefix(EntityId::new(user.as_u64()), Tag::SavedSearch);
        let mut results = Vec::new();

        if let Some(storage) = self.storage.as_ref() {
            let iter = storage.items_engine().scan_prefix(&prefix);
            for (_key, value) in iter.flatten() {
                if let Some(ss) = deserialize_saved_search(&value) {
                    results.push(SavedSearchInfo {
                        name: ss.name,
                        query_text: ss.query_text,
                        created_at_ns: ss.created_at_ns,
                    });
                }
            }
        }

        Ok(results)
    }

    /// Delete a saved search by name.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if storage is unavailable.
    pub fn delete_saved_search(&self, user: EntityId, name: &str) -> crate::Result<()> {
        self.require_writeable("delete_saved_search")?;
        let key = encode_key(
            EntityId::new(user.as_u64()),
            Tag::SavedSearch,
            name.as_bytes(),
        );

        if let Some(storage) = self.storage.as_ref() {
            storage
                .items_engine()
                .delete(&key)
                .map_err(|e| TidalError::internal("delete_saved_search", format!("{e}")))?;
        }

        Ok(())
    }

    /// Re-execute a saved search, optionally filtering by creation time.
    ///
    /// Loads the saved search record, builds a `Search` query from its
    /// `query_text`, and calls `self.search()`. If `since` is provided,
    /// a `CreatedAfter` filter is added.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the saved search is not found or
    /// search execution fails.
    pub fn retrieve_saved_search(
        &self,
        user: EntityId,
        name: &str,
        since: Option<u64>,
    ) -> crate::Result<crate::query::search::SearchResults> {
        let key = encode_key(
            EntityId::new(user.as_u64()),
            Tag::SavedSearch,
            name.as_bytes(),
        );

        let storage = self.storage.as_ref().ok_or_else(|| {
            TidalError::internal(
                "retrieve_saved_search",
                "no storage: open with with_schema()",
            )
        })?;

        let bytes = storage
            .items_engine()
            .get(&key)
            .map_err(|e| TidalError::internal("retrieve_saved_search", format!("read: {e}")))?
            .ok_or_else(|| {
                TidalError::internal(
                    "retrieve_saved_search",
                    format!("saved search '{name}' not found for user {}", user.as_u64()),
                )
            })?;

        let ss = deserialize_saved_search(&bytes).ok_or_else(|| {
            TidalError::internal(
                "retrieve_saved_search",
                format!("corrupted saved search '{name}'"),
            )
        })?;

        let mut builder = crate::query::search::Search::builder()
            .query(&ss.query_text)
            .for_user(user.as_u64());

        if let Some(ts) = since {
            builder = builder.filter(crate::storage::indexes::filter::FilterExpr::CreatedAfter(
                ts,
            ));
        }

        let search = builder.build().map_err(|e| {
            TidalError::internal(
                "retrieve_saved_search",
                format!("saved search build failed: {e}"),
            )
        })?;

        self.search(&search)
    }

    // ── Internal helpers ─────────────────────────────────────────────────

    /// Persist a collection record to fjall.
    ///
    /// All collections are keyed under the reserved [`COLLECTION_NAMESPACE`]
    /// entity ID (not the owner's), with the globally-unique collection ID as
    /// the suffix. This makes every collection share a single 10-byte
    /// `[namespace][0x00][Tag::Collection]` prefix, so [`rebuild_collections`]
    /// can scope its open-time scan to exactly that prefix instead of walking
    /// the entire item keyspace. The owner is recovered from the serialized
    /// `Collection.owner_id` field on rebuild, so `list_by_owner` is unaffected.
    fn persist_collection(&self, collection: &Collection) -> crate::Result<()> {
        let key = encode_key(
            COLLECTION_NAMESPACE,
            Tag::Collection,
            &collection.id.as_u64().to_le_bytes(),
        );
        let value = serialize_collection(collection);
        self.persist_to_items(&key, &value, "collection_persist")
    }
}

/// Narrow an item [`EntityId`] to the `u32` slot used by collection bitmaps,
/// rejecting any id past `u32::MAX` instead of silently aliasing it.
///
/// Collection membership lives in a `u32`-keyed `RoaringBitmap`, so an id above
/// `u32::MAX` would wrap and alias a lower id under a bare `as u32` cast — the
/// item would appear in the wrong collection's membership. Mirrors the audited
/// item-universe guard in `items.rs::write_item_with_metadata`; centralized here
/// so both collection write paths enforce the limit identically.
fn narrow_collection_item_id(item_id: EntityId, op: &'static str) -> crate::Result<u32> {
    u32::try_from(item_id.as_u64()).map_err(|_| {
        TidalError::invalid_input(format!(
            "{op}: item entity id {} exceeds the u32 item-universe limit ({}); \
             allocate dense item IDs within the u32 range",
            item_id.as_u64(),
            u32::MAX
        ))
    })
}

/// Reserved entity ID under which every collection record is keyed.
///
/// Collection IDs are globally unique (one `next_collection_id` counter), so a
/// single namespace cannot collide, and keying all collections here gives them
/// one contiguous, prefix-scannable key range. `EntityId(0)` is never a valid
/// item/user/creator ID (entities are assigned from 1), mirroring the cohort-def
/// sentinel convention in `cohorts.rs`.
const COLLECTION_NAMESPACE: EntityId = EntityId::new(0);

/// Rebuild the collection index from durable storage.
///
/// Scans only the `[COLLECTION_NAMESPACE][0x00][Tag::Collection]` prefix —
/// every collection is persisted under that single reserved namespace by
/// [`TidalDb::persist_collection`] — and deserializes each record. The owner is
/// read back from the serialized `Collection.owner_id`, so per-owner lookups
/// rebuild correctly. Sets the `next_id` counter to `max(collection_ids) + 1`.
///
/// This is bounded by the number of collections, not the total item keyspace:
/// the open path no longer pays an O(all-keys) scan to find a handful of
/// collection rows.
pub fn rebuild_collections(index: &CollectionIndex, storage: &dyn StorageEngine) {
    let prefix = entity_tag_prefix(COLLECTION_NAMESPACE, Tag::Collection);

    let mut max_id = 0u64;
    let iter = storage.scan_prefix(&prefix);
    for (key, value) in iter.flatten() {
        // Defensive re-check: a tag-scoped prefix should only yield collection
        // keys, but parse + tag-match guards against any future key-layout drift
        // sharing this namespace.
        if let Some((_eid, tag, _suffix)) = crate::storage::parse_key(&key)
            && tag == Tag::Collection
            && let Some(collection) = deserialize_collection(&value)
        {
            let cid = collection.id.as_u64();
            if cid >= max_id {
                max_id = cid + 1;
            }
            index.create(collection);
        }
    }

    if max_id > 0 {
        index.set_next_id(max_id);
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod rebuild_tests {
    use super::*;
    use crate::storage::InMemoryBackend;

    /// Write a collection under the reserved namespace exactly as
    /// `persist_collection` does, so rebuild's scoped prefix scan finds it.
    fn persist(backend: &InMemoryBackend, c: &Collection) {
        let key = encode_key(
            COLLECTION_NAMESPACE,
            Tag::Collection,
            &c.id.as_u64().to_le_bytes(),
        );
        backend.put(&key, &serialize_collection(c)).unwrap();
    }

    fn collection(id: u64, owner: u64) -> Collection {
        Collection {
            id: CollectionId::new(id),
            owner_id: owner,
            name: format!("c{id}"),
            visibility: Visibility::Private,
            created_at_ns: 1_000,
            item_ids: vec![],
        }
    }

    #[test]
    fn rebuild_recovers_collections_and_owner_from_scoped_prefix() {
        let backend = InMemoryBackend::new();

        // Two collections owned by different users, keyed under the namespace.
        persist(&backend, &collection(1, 100));
        persist(&backend, &collection(2, 200));

        // Decoy rows under OTHER tags / owner IDs must NOT be picked up. These
        // would all have been scanned by the old empty-prefix walk; the scoped
        // prefix excludes them entirely.
        backend
            .put(&encode_key(EntityId::new(100), Tag::Meta, b""), b"meta")
            .unwrap();
        backend
            .put(&encode_key(EntityId::new(200), Tag::Sig, b"x"), b"sig")
            .unwrap();
        backend
            .put(
                &encode_key(EntityId::new(100), Tag::SavedSearch, b"q"),
                b"search",
            )
            .unwrap();

        let index = CollectionIndex::new();
        rebuild_collections(&index, &backend);

        // Owners are recovered from the serialized value, not the key.
        assert_eq!(index.list_by_owner(100).len(), 1, "owner 100 collection");
        assert_eq!(index.list_by_owner(200).len(), 1, "owner 200 collection");
        assert_eq!(index.list_by_owner(999).len(), 0, "no spurious owner");

        // next_id is max(id) + 1 so the next allocation does not collide.
        assert_eq!(index.next_collection_id().as_u64(), 3);
    }

    #[test]
    fn rebuild_on_empty_storage_is_noop() {
        let backend = InMemoryBackend::new();
        // Only non-collection rows present.
        backend
            .put(&encode_key(EntityId::new(1), Tag::Meta, b""), b"meta")
            .unwrap();

        let index = CollectionIndex::new();
        rebuild_collections(&index, &backend);
        assert_eq!(index.list_by_owner(1).len(), 0);
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod collection_ops_tests {
    use crate::{
        TidalDb,
        entities::Visibility,
        schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Window},
    };

    /// Minimal valid schema so ephemeral mode wires in-memory storage (required
    /// for any test that persists a collection row).
    fn minimal_schema() -> crate::schema::Schema {
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: std::time::Duration::from_secs(3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        b.build().expect("schema must be valid")
    }

    fn open_db() -> TidalDb {
        TidalDb::builder()
            .ephemeral()
            .with_schema(minimal_schema())
            .open()
            .unwrap()
    }

    /// W5: item IDs above `u32::MAX` must be rejected, not silently narrowed —
    /// otherwise the id wraps and aliases a lower id in the `u32` membership bitmap.
    #[test]
    fn add_to_collection_rejects_id_above_u32_universe_limit() {
        let db = open_db();
        let cid = db
            .create_collection(EntityId::new(1), "c", Visibility::Private)
            .unwrap();

        // u32::MAX + 1: the smallest id that would alias a lower id once narrowed.
        let oversized = EntityId::new(u64::from(u32::MAX) + 1);
        let err = db.add_to_collection(cid, oversized).unwrap_err();
        assert!(
            err.to_string().contains("u32 item-universe limit"),
            "expected u32 item-universe rejection, got: {err}"
        );

        // The rejected add must leave neither the in-memory index nor disk
        // mutated — rejection happens before any mutation or persist, so memory
        // can never get ahead of disk at the boundary.
        let info = db.list_collections(EntityId::new(1)).unwrap();
        assert_eq!(info.len(), 1);
        assert_eq!(info[0].item_count, 0, "rejected item must not be tracked");

        db.close().unwrap();
    }

    #[test]
    fn remove_from_collection_rejects_id_above_u32_universe_limit() {
        let db = open_db();
        let cid = db
            .create_collection(EntityId::new(1), "c", Visibility::Private)
            .unwrap();

        let oversized = EntityId::new(u64::from(u32::MAX) + 1);
        let err = db.remove_from_collection(cid, oversized).unwrap_err();
        assert!(
            err.to_string().contains("u32 item-universe limit"),
            "expected u32 item-universe rejection, got: {err}"
        );
        db.close().unwrap();
    }

    /// W5 boundary: `u32::MAX` is the largest in-bounds id — narrowing is lossless
    /// and the add must succeed.
    #[test]
    fn add_to_collection_accepts_id_at_u32_universe_boundary() {
        let db = open_db();
        let cid = db
            .create_collection(EntityId::new(1), "c", Visibility::Private)
            .unwrap();

        let boundary = EntityId::new(u64::from(u32::MAX));
        db.add_to_collection(cid, boundary).unwrap();
        let info = db.list_collections(EntityId::new(1)).unwrap();
        assert_eq!(info[0].item_count, 1, "boundary id must be accepted");
        db.close().unwrap();
    }

    /// W6: a successful add must be DURABLE — persist runs before the in-memory
    /// mutation, so the membership survives a close + reopen (the reopen rebuilds
    /// the index purely from the persisted rows). If the add had mutated memory
    /// before persisting, a persist failure would leave memory ahead of disk; this
    /// proves the success-path persist actually reaches storage. Idempotent re-add
    /// does not duplicate the membership on disk.
    #[test]
    fn add_to_collection_persists_before_in_memory_mutation() {
        let dir = tempfile::tempdir().unwrap();
        let cid;
        {
            let db = TidalDb::builder()
                .with_data_dir(dir.path())
                .with_schema(minimal_schema())
                .open()
                .unwrap();
            cid = db
                .create_collection(EntityId::new(7), "c", Visibility::Private)
                .unwrap();
            db.add_to_collection(cid, EntityId::new(42)).unwrap();
            db.add_to_collection(cid, EntityId::new(42)).unwrap(); // idempotent
            db.add_to_collection(cid, EntityId::new(43)).unwrap();
            assert_eq!(
                db.list_collections(EntityId::new(7)).unwrap()[0].item_count,
                2
            );
            db.close().unwrap();
        }

        // Reopen: the index is rebuilt solely from persisted rows.
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(minimal_schema())
            .open()
            .unwrap();
        let info = db.list_collections(EntityId::new(7)).unwrap();
        assert_eq!(info.len(), 1, "collection must survive reopen");
        assert_eq!(
            info[0].item_count, 2,
            "persisted membership must match the pre-close index (no dup on re-add)"
        );
        db.close().unwrap();
    }

    /// W6 (remove path): a successful remove is durable — the item is gone after a
    /// close + reopen.
    #[test]
    fn remove_from_collection_persists_before_in_memory_mutation() {
        let dir = tempfile::tempdir().unwrap();
        let cid;
        {
            let db = TidalDb::builder()
                .with_data_dir(dir.path())
                .with_schema(minimal_schema())
                .open()
                .unwrap();
            cid = db
                .create_collection(EntityId::new(7), "c", Visibility::Private)
                .unwrap();
            db.add_to_collection(cid, EntityId::new(42)).unwrap();
            db.add_to_collection(cid, EntityId::new(43)).unwrap();
            db.remove_from_collection(cid, EntityId::new(42)).unwrap();
            db.close().unwrap();
        }

        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(minimal_schema())
            .open()
            .unwrap();
        assert_eq!(
            db.list_collections(EntityId::new(7)).unwrap()[0].item_count,
            1,
            "removed item must stay removed after reopen"
        );
        db.close().unwrap();
    }
}
