"""Unit tests for the shared collection-indexing helpers in rag_routes.

These helpers were extracted so the SSE route (index_collection) and the
background worker (_background_index_worker) share identical metadata,
force-reindex cleanup, and document-query logic. The key regression they fix:
the background worker previously did NOT store ``embedding_dimension``, so
collections indexed that way lost it.
"""

from types import SimpleNamespace
from unittest.mock import Mock, patch

from local_deep_research.database.models.library import (
    DocumentCollection,
    EmbeddingProvider,
)
from local_deep_research.research_library.routes.rag_routes import (
    _store_collection_embedding_metadata,
    _reset_collection_for_reindex,
    _query_documents_to_index,
)

MODULE = "local_deep_research.research_library.routes.rag_routes"


def _mock_rag_service(embedding_dimension=768, embed_raises=False):
    """Mock a LibraryRAGService.

    The dimension is derived by ``embedding_manager.embeddings.embed_query``
    (the real path). The mock deliberately has NO ``embedding_manager.provider``
    attribute — the original code probed ``provider.embedding_dimension``, which
    never exists on the real LocalEmbeddingManager, so the dimension was always
    NULL. Reverting to that probe must fail these tests.
    """
    embeddings = Mock()
    if embed_raises:
        embeddings.embed_query.side_effect = RuntimeError("no embedder")
    else:
        embeddings.embed_query.return_value = [0.0] * embedding_dimension
    return SimpleNamespace(
        embedding_manager=SimpleNamespace(embeddings=embeddings),
        embedding_model="nomic-embed-text",
        embedding_provider="ollama",
        chunk_size=512,
        chunk_overlap=50,
        splitter_type="recursive",
        text_separators=["\n\n", "\n"],
        distance_metric="cosine",
        normalize_vectors=1,  # truthy non-bool, helper must coerce to bool
        index_type="flat",
    )


class TestStoreCollectionEmbeddingMetadata:
    def test_stores_embedding_dimension_and_all_fields(self):
        collection = Mock()
        rag = _mock_rag_service(embedding_dimension=768)

        _store_collection_embedding_metadata(collection, rag)

        # The regression this fixes: dimension is persisted, derived from the
        # real embed_query path (not a non-existent provider attribute).
        rag.embedding_manager.embeddings.embed_query.assert_called_once_with(
            "test"
        )
        assert collection.embedding_dimension == 768
        assert collection.embedding_model == "nomic-embed-text"
        assert collection.embedding_model_type == EmbeddingProvider.OLLAMA
        assert collection.chunk_size == 512
        assert collection.chunk_overlap == 50
        assert collection.splitter_type == "recursive"
        assert collection.text_separators == ["\n\n", "\n"]
        assert collection.distance_metric == "cosine"
        assert collection.index_type == "flat"

    def test_normalize_vectors_coerced_to_bool(self):
        collection = Mock()
        rag = _mock_rag_service()
        _store_collection_embedding_metadata(collection, rag)
        assert collection.normalize_vectors is True

    def test_embed_failure_yields_none_dimension(self):
        collection = Mock()
        rag = _mock_rag_service(embed_raises=True)
        _store_collection_embedding_metadata(collection, rag)
        assert collection.embedding_dimension is None
        # Other fields are still stored despite the dimension probe failing.
        assert collection.embedding_model == "nomic-embed-text"

    # Note: "does not commit" needs no test here — the helper takes no session
    # at all, so transaction ownership is enforced by its signature.


class TestQueryDocumentsToIndex:
    def _chainable_session(self, all_result):
        db = Mock()
        chain = db.query.return_value.join.return_value
        chain.options.return_value = chain
        chain.filter.return_value = chain
        chain.all.return_value = all_result
        return db, chain

    def test_incremental_filters_on_unindexed(self):
        db, chain = self._chainable_session(["doc"])
        result = _query_documents_to_index(db, "cid", force_reindex=False)
        assert result == ["doc"]
        # collection_id filter + the load-bearing indexed == False filter.
        calls = chain.filter.call_args_list
        assert len(calls) == 2
        assert (
            calls[0].args[0].compare(DocumentCollection.collection_id == "cid")
        )
        # Assert the SECOND filter is exactly `indexed == False` — not `== True`,
        # not a `not`/`is_(False)` variant. This is the invariant a collapsing
        # call-count check could never catch.
        assert (
            calls[1]
            .args[0]
            .compare(
                DocumentCollection.indexed == False  # noqa: E712
            )
        )

    def test_force_reindex_skips_indexed_filter(self):
        db, chain = self._chainable_session(["a", "b"])
        result = _query_documents_to_index(db, "cid", force_reindex=True)
        assert result == ["a", "b"]
        # Only the collection_id filter — and crucially NO indexed predicate,
        # so a force-reindex returns every document.
        calls = chain.filter.call_args_list
        assert len(calls) == 1
        assert (
            calls[0].args[0].compare(DocumentCollection.collection_id == "cid")
        )
        assert (
            not calls[0]
            .args[0]
            .compare(
                DocumentCollection.indexed == False  # noqa: E712
            )
        )

    def test_does_not_eager_load_text_content(
        self, library_session, mock_document, mock_collection
    ):
        """The indexing query must defer text_content. The loop only reads
        doc.id/filename/title (index_document re-fetches by id), so eagerly
        loading every document's full text body is what exhausts memory on a
        large collection (#4560). Real-DB guard: a revert removing the
        defer() option is output-identical and would otherwise pass."""
        from sqlalchemy import inspect as sa_inspect

        result = _query_documents_to_index(
            library_session, mock_collection.id, force_reindex=True
        )

        assert len(result) == 1
        _link, doc = result[0]
        # text_content is deferred (not loaded); id is available.
        assert "text_content" in sa_inspect(doc).unloaded
        assert doc.id is not None


class TestResetCollectionForReindex:
    def test_clears_chunks_indices_and_marks_unindexed(self):
        db = Mock()
        with patch(
            "local_deep_research.research_library.deletion.utils."
            "cascade_helper.CascadeHelper"
        ) as cascade:
            cascade.delete_collection_chunks.return_value = 12
            cascade.delete_rag_indices_for_collection.return_value = {
                "deleted": 1
            }

            _reset_collection_for_reindex(db, "cid")

            cascade.delete_collection_chunks.assert_called_once_with(
                db, "collection_cid"
            )
            cascade.delete_rag_indices_for_collection.assert_called_once_with(
                db, "collection_cid"
            )
            # Marks documents unindexed
            db.query.return_value.filter_by.return_value.update.assert_called_once()

    def test_does_not_commit(self):
        db = Mock()
        with patch(
            "local_deep_research.research_library.deletion.utils."
            "cascade_helper.CascadeHelper"
        ):
            _reset_collection_for_reindex(db, "cid")
        db.commit.assert_not_called()
