"""
Deep coverage tests for LibraryRAGService.

Targets ~146 missing statements not covered by existing test files:
- get_current_index_info (collection_id present/absent, no RAG index, embedding_model_type None)
- remove_document_from_rag (success, not-in-collection error, exception path)
- index_documents_batch (not found, already indexed skip, no text, batch exception)
- get_rag_stats (default collection id, with collection, chunk_sample present/absent)
- index_local_file (file not found, not a file, unsupported type, empty content,
  txt success, html success, pdf success, exception path, no rag_index_record)
- index_user_document (no content, success no rag_index_record, force_reindex path, exception)
- remove_collection_from_index (no chunks, has chunks success, faiss delete exception, outer exception)
- close (idempotent second call, clears resources)
- index_document exception path (embedding fails mid-way)
"""

from unittest.mock import MagicMock, patch
from langchain_core.documents import Document as LangchainDocument

# ---------------------------------------------------------------------------
# Module-level patch path prefix
# ---------------------------------------------------------------------------
_MOD = "local_deep_research.research_library.services.library_rag_service"


def _make_service(**overrides):
    """Create a LibraryRAGService with all external deps mocked out."""
    with (
        patch(f"{_MOD}.LocalEmbeddingManager") as _lem,
        patch(f"{_MOD}.get_user_db_session"),
        patch(f"{_MOD}.FileIntegrityManager") as _fim,
        patch(f"{_MOD}.get_text_splitter") as _gts,
    ):
        _lem.return_value.embeddings = MagicMock()
        from local_deep_research.research_library.services.library_rag_service import (
            LibraryRAGService,
        )

        defaults = dict(username="testuser", db_password="pw")
        defaults.update(overrides)
        svc = LibraryRAGService(**defaults)
    return svc


def _make_session_ctx(session):
    """Helper to build a context-manager mock wrapping *session*."""
    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=session)
    ctx.__exit__ = MagicMock(return_value=None)
    return ctx


# =========================================================================
# close()
# =========================================================================
class TestClose:
    def test_close_clears_embedding_manager(self):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.faiss_index = MagicMock()
        svc.close()
        assert svc.embedding_manager is None
        assert svc.faiss_index is None
        assert svc.rag_index_record is None
        assert svc.integrity_manager is None
        assert svc.text_splitter is None

    def test_close_is_idempotent(self):
        svc = _make_service()
        svc.close()
        # Second call must not raise
        svc.close()
        assert svc._closed is True

    def test_close_with_none_resources_does_not_raise(self):
        svc = _make_service()
        svc.embedding_manager = None
        svc.faiss_index = None
        svc.close()  # Should not raise


# =========================================================================
# get_current_index_info
# =========================================================================
class TestGetCurrentIndexInfo:
    @patch(f"{_MOD}.get_user_db_session")
    def test_with_collection_id_found(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_collection = MagicMock()
        mock_rag_index = MagicMock()
        mock_rag_index.embedding_model = "model-x"
        mock_rag_index.embedding_model_type = MagicMock()
        mock_rag_index.embedding_model_type.value = "sentence_transformers"
        mock_rag_index.embedding_dimension = 384
        mock_rag_index.chunk_size = 1000
        mock_rag_index.chunk_overlap = 200
        mock_rag_index.created_at = MagicMock()
        mock_rag_index.created_at.isoformat.return_value = "2024-01-01T00:00:00"
        mock_rag_index.last_updated_at = MagicMock()
        mock_rag_index.last_updated_at.isoformat.return_value = (
            "2024-01-02T00:00:00"
        )

        # query(Collection).filter_by().first() -> collection
        # query(RAGIndex).filter_by().first() -> rag_index
        # query(func.sum(...)).filter_by().scalar() -> 10
        # query(RagDocumentStatus).filter_by().count() -> 2
        def side_effect_query(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "Collection" in model_name:
                q.filter_by.return_value.first.return_value = mock_collection
            elif "RAGIndex" in model_name:
                q.filter_by.return_value.first.return_value = mock_rag_index
            elif "RagDocumentStatus" in model_name:
                q.filter_by.return_value.scalar.return_value = 10
                q.filter_by.return_value.count.return_value = 2
            else:
                # func.sum path
                q.filter_by.return_value.scalar.return_value = 10
                q.filter_by.return_value.count.return_value = 2
            return q

        mock_session.query = MagicMock(side_effect=side_effect_query)

        result = svc.get_current_index_info("coll-123")
        assert result is not None
        assert result["embedding_model"] == "model-x"

    @patch(f"{_MOD}.get_user_db_session")
    def test_returns_none_when_no_rag_index(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        def side_effect_query(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "Collection" in model_name:
                q.filter_by.return_value.first.return_value = MagicMock()
            elif "RAGIndex" in model_name:
                q.filter_by.return_value.first.return_value = None
                q.all.return_value = []
            else:
                q.filter_by.return_value.scalar.return_value = 0
            return q

        mock_session.query = MagicMock(side_effect=side_effect_query)

        result = svc.get_current_index_info("coll-123")
        assert result is None

    @patch(f"{_MOD}.get_user_db_session")
    def test_embedding_model_type_none_returns_none_in_dict(
        self, mock_session_ctx
    ):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_collection = MagicMock()
        mock_rag_index = MagicMock()
        mock_rag_index.embedding_model = "model-x"
        mock_rag_index.embedding_model_type = None  # <-- None
        mock_rag_index.embedding_dimension = 384
        mock_rag_index.chunk_size = 500
        mock_rag_index.chunk_overlap = 50
        mock_rag_index.created_at = MagicMock()
        mock_rag_index.created_at.isoformat.return_value = "2024-01-01T00:00:00"
        mock_rag_index.last_updated_at = MagicMock()
        mock_rag_index.last_updated_at.isoformat.return_value = (
            "2024-01-02T00:00:00"
        )

        def side_effect_query(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "Collection" in model_name:
                q.filter_by.return_value.first.return_value = mock_collection
            elif "RAGIndex" in model_name:
                q.filter_by.return_value.first.return_value = mock_rag_index
            else:
                q.filter_by.return_value.scalar.return_value = 5
                q.filter_by.return_value.count.return_value = 1
            return q

        mock_session.query = MagicMock(side_effect=side_effect_query)

        result = svc.get_current_index_info("coll-999")
        assert result["embedding_model_type"] is None

    @patch(f"{_MOD}.get_user_db_session")
    @patch(
        "local_deep_research.database.library_init.get_default_library_id",
        return_value="lib-001",
    )
    def test_no_collection_id_uses_default_library(
        self, mock_get_lib_id, mock_session_ctx
    ):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_rag_index = MagicMock()
        mock_rag_index.embedding_model = "default-model"
        mock_rag_index.embedding_model_type = None
        mock_rag_index.embedding_dimension = 384
        mock_rag_index.chunk_size = 1000
        mock_rag_index.chunk_overlap = 200
        mock_rag_index.created_at = MagicMock()
        mock_rag_index.created_at.isoformat.return_value = "2024-01-01T00:00:00"
        mock_rag_index.last_updated_at = MagicMock()
        mock_rag_index.last_updated_at.isoformat.return_value = (
            "2024-01-02T00:00:00"
        )

        def side_effect_query(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "RAGIndex" in model_name:
                q.filter_by.return_value.first.return_value = mock_rag_index
            else:
                q.filter_by.return_value.scalar.return_value = 0
                q.filter_by.return_value.count.return_value = 0
            return q

        mock_session.query = MagicMock(side_effect=side_effect_query)

        result = svc.get_current_index_info(collection_id=None)
        # Either None or a dict is acceptable; we care it does not raise
        assert result is None or isinstance(result, dict)


# =========================================================================
# remove_document_from_rag
# =========================================================================
class TestRemoveDocumentFromRag:
    @patch(f"{_MOD}.get_user_db_session")
    def test_returns_error_when_not_in_collection(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        result = svc.remove_document_from_rag("doc-1", "coll-1")
        assert result["status"] == "error"
        assert "not found" in result["error"]

    @patch(f"{_MOD}.get_user_db_session")
    def test_success_path(self, mock_session_ctx):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._delete_chunks_from_db.return_value = 5

        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_doc_collection = MagicMock()
        mock_collection = MagicMock()

        def query_side_effect(model):
            q = MagicMock()
            model_name = getattr(model, "__name__", str(model))
            if "DocumentCollection" in model_name:
                q.filter_by.return_value.first.return_value = (
                    mock_doc_collection
                )
            elif "Collection" in model_name:
                q.filter_by.return_value.first.return_value = mock_collection
            return q

        mock_session.query = MagicMock(side_effect=query_side_effect)

        result = svc.remove_document_from_rag("doc-1", "coll-1")
        assert result["status"] == "success"
        assert result["deleted_count"] == 5
        assert mock_doc_collection.indexed is False
        assert mock_doc_collection.chunk_count == 0

    @patch(f"{_MOD}.get_user_db_session")
    def test_exception_returns_error(self, mock_session_ctx):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._delete_chunks_from_db.side_effect = RuntimeError(
            "db crash"
        )

        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_doc_collection = MagicMock()
        mock_collection = MagicMock()

        def query_side_effect(model):
            q = MagicMock()
            model_name = getattr(model, "__name__", str(model))
            if "DocumentCollection" in model_name:
                q.filter_by.return_value.first.return_value = (
                    mock_doc_collection
                )
            elif "Collection" in model_name:
                q.filter_by.return_value.first.return_value = mock_collection
            return q

        mock_session.query = MagicMock(side_effect=query_side_effect)

        result = svc.remove_document_from_rag("doc-1", "coll-1")
        assert result["status"] == "error"
        assert "RuntimeError" in result["error"]


# =========================================================================
# index_documents_batch
# =========================================================================
class TestIndexDocumentsBatch:
    @patch(f"{_MOD}.get_user_db_session")
    def test_document_not_found_in_lookup(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        # No documents returned
        mock_session.query.return_value.filter.return_value.all.return_value = []

        result = svc.index_documents_batch(
            [("doc-missing", "Missing Title")], "coll-1"
        )
        assert result["doc-missing"]["status"] == "error"
        assert "not found" in result["doc-missing"]["error"]

    @patch(f"{_MOD}.get_user_db_session")
    def test_already_indexed_skip(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_doc = MagicMock()
        mock_doc.id = "doc-1"
        mock_doc.text_content = "some content"

        mock_dc = MagicMock()
        mock_dc.document_id = "doc-1"
        mock_dc.indexed = True
        mock_dc.chunk_count = 7

        mock_session.query.return_value.filter.return_value.all.side_effect = [
            [mock_doc],  # Document query
            [mock_dc],  # DocumentCollection query
        ]

        result = svc.index_documents_batch(
            [("doc-1", "Title")], "coll-1", force_reindex=False
        )
        assert result["doc-1"]["status"] == "skipped"
        assert result["doc-1"]["chunk_count"] == 7

    @patch(f"{_MOD}.get_user_db_session")
    def test_no_text_content_in_batch(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_doc = MagicMock()
        mock_doc.id = "doc-2"
        mock_doc.text_content = None

        mock_dc = MagicMock()
        mock_dc.document_id = "doc-2"
        mock_dc.indexed = False

        mock_session.query.return_value.filter.return_value.all.side_effect = [
            [mock_doc],
            [mock_dc],
        ]

        result = svc.index_documents_batch([("doc-2", "Title")], "coll-1")
        assert result["doc-2"]["status"] == "error"
        assert "no text content" in result["doc-2"]["error"]

    @patch(f"{_MOD}.get_user_db_session")
    def test_index_document_exception_captured(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_doc = MagicMock()
        mock_doc.id = "doc-3"
        mock_doc.text_content = "some real content here"

        # No DocumentCollection => not indexed yet
        mock_session.query.return_value.filter.return_value.all.side_effect = [
            [mock_doc],
            [],  # empty doc_collections
        ]

        svc.index_document = MagicMock(
            side_effect=RuntimeError("unexpected failure")
        )

        result = svc.index_documents_batch([("doc-3", "Title")], "coll-1")
        assert result["doc-3"]["status"] == "error"
        assert "RuntimeError" in result["doc-3"]["error"]


# =========================================================================
# get_rag_stats
# =========================================================================
class TestGetRagStats:
    @patch(f"{_MOD}.get_user_db_session")
    def test_basic_stats_with_collection_id(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_collection = MagicMock()
        mock_chunk_sample = MagicMock()
        mock_chunk_sample.embedding_model = "model-a"
        mock_chunk_sample.embedding_model_type = MagicMock()
        mock_chunk_sample.embedding_model_type.value = "sentence_transformers"
        mock_chunk_sample.embedding_dimension = 384

        def query_side(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "DocumentCollection" in model_name:
                q.filter_by.return_value.count.return_value = 5
            elif "RagDocumentStatus" in model_name:
                q.filter_by.return_value.count.return_value = 3
                q.filter_by.return_value.scalar.return_value = 30
            elif "Collection" in model_name:
                q.filter_by.return_value.first.return_value = mock_collection
            elif "DocumentChunk" in model_name:
                q.filter_by.return_value.first.return_value = mock_chunk_sample
            else:
                q.filter_by.return_value.scalar.return_value = 30
            return q

        mock_session.query = MagicMock(side_effect=query_side)

        result = svc.get_rag_stats("coll-abc")
        assert result["total_documents"] == 5
        assert result["indexed_documents"] == 3
        assert result["unindexed_documents"] == 2
        assert result["embedding_info"]["model"] == "model-a"

    @patch(f"{_MOD}.get_user_db_session")
    def test_stats_without_chunk_sample(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        def query_side(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "DocumentCollection" in model_name:
                q.filter_by.return_value.count.return_value = 0
            elif "RagDocumentStatus" in model_name:
                q.filter_by.return_value.count.return_value = 0
                q.filter_by.return_value.scalar.return_value = None
            elif "Collection" in model_name:
                q.filter_by.return_value.first.return_value = None
            elif "DocumentChunk" in model_name:
                q.filter_by.return_value.first.return_value = None
            else:
                q.filter_by.return_value.scalar.return_value = None
            return q

        mock_session.query = MagicMock(side_effect=query_side)

        result = svc.get_rag_stats("coll-empty")
        assert result["total_chunks"] == 0
        assert result["embedding_info"] == {}

    @patch(f"{_MOD}.get_user_db_session")
    def test_chunk_sample_embedding_model_type_none(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_chunk_sample = MagicMock()
        mock_chunk_sample.embedding_model = "model-b"
        mock_chunk_sample.embedding_model_type = None  # <-- None branch
        mock_chunk_sample.embedding_dimension = 768

        def query_side(model_or_expr):
            q = MagicMock()
            model_name = getattr(model_or_expr, "__name__", str(model_or_expr))
            if "DocumentCollection" in model_name:
                q.filter_by.return_value.count.return_value = 1
            elif "RagDocumentStatus" in model_name:
                q.filter_by.return_value.count.return_value = 1
                q.filter_by.return_value.scalar.return_value = 10
            elif "Collection" in model_name:
                q.filter_by.return_value.first.return_value = MagicMock()
            elif "DocumentChunk" in model_name:
                q.filter_by.return_value.first.return_value = mock_chunk_sample
            else:
                q.filter_by.return_value.scalar.return_value = 10
            return q

        mock_session.query = MagicMock(side_effect=query_side)

        result = svc.get_rag_stats("coll-xyz")
        assert result["embedding_info"]["model_type"] is None


# =========================================================================
# index_local_file
# =========================================================================
class TestIndexLocalFile:
    def test_file_not_found(self, tmp_path):
        svc = _make_service()
        result = svc.index_local_file(str(tmp_path / "no_such_file.txt"))
        assert result["status"] == "error"
        assert "not found" in result["error"]

    def test_not_a_file(self, tmp_path):
        svc = _make_service()
        d = tmp_path / "some_dir"
        d.mkdir()
        result = svc.index_local_file(str(d))
        assert result["status"] == "error"
        assert "Not a file" in result["error"]

    def test_unsupported_file_type(self, tmp_path):
        svc = _make_service()
        f = tmp_path / "data.xyz"
        f.write_text("some content")
        result = svc.index_local_file(str(f))
        assert result["status"] == "skipped"
        assert "Unsupported" in result["error"]

    def test_empty_content_returns_error(self, tmp_path):
        svc = _make_service()
        f = tmp_path / "empty.txt"
        f.write_text("   ")  # whitespace-only → < 10 chars after strip
        result = svc.index_local_file(str(f))
        assert result["status"] == "error"
        assert "no extractable text" in result["error"]

    def test_txt_file_success_no_rag_index_record(self, tmp_path):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.return_value = [
            "id-1",
            "id-2",
        ]

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {}

        svc.faiss_index = mock_faiss
        svc.rag_index_record = None  # no record → save branch skipped

        mock_chunks = [LangchainDocument(page_content="chunk text")]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        f = tmp_path / "doc.txt"
        f.write_text("This is a long enough document text content for testing.")

        result = svc.index_local_file(str(f))
        assert result["status"] == "success"
        assert result["chunk_count"] >= 0

    def test_txt_file_success_with_rag_index_record(self, tmp_path):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.return_value = ["id-1"]
        svc.integrity_manager = MagicMock()

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {}
        svc.faiss_index = mock_faiss

        mock_record = MagicMock()
        index_file = tmp_path / "idx.faiss"
        mock_record.index_path = str(index_file)
        mock_record.id = "rag-rec-1"
        svc.rag_index_record = mock_record

        mock_chunks = [LangchainDocument(page_content="some content text")]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        f = tmp_path / "report.txt"
        f.write_text("This is a long enough document text content for testing.")

        result = svc.index_local_file(str(f))
        assert result["status"] == "success"
        mock_faiss.save_local.assert_called_once()
        svc.integrity_manager.record_file.assert_called_once()

    def test_txt_exception_path(self, tmp_path):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.side_effect = RuntimeError(
            "boom"
        )

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {}
        svc.faiss_index = mock_faiss

        mock_chunks = [
            LangchainDocument(page_content="long enough content here")
        ]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        f = tmp_path / "broken.txt"
        f.write_text("This is a long enough document text content for testing.")

        result = svc.index_local_file(str(f))
        assert result["status"] == "error"
        assert "RuntimeError" in result["error"]

    def test_html_file_success(self, tmp_path):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.return_value = ["id-h1"]
        svc.integrity_manager = MagicMock()

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {}
        svc.faiss_index = mock_faiss
        svc.rag_index_record = None

        mock_chunks = [LangchainDocument(page_content="html content text")]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        f = tmp_path / "page.html"
        f.write_text(
            "<html><body><p>This is a long enough HTML document.</p></body></html>"
        )

        result = svc.index_local_file(str(f))
        assert result["status"] == "success"

    def test_pdf_file_exception_returns_error(self, tmp_path):
        svc = _make_service()
        svc.text_splitter = MagicMock()

        f = tmp_path / "doc.pdf"
        f.write_bytes(b"%PDF invalid pdf content")

        # PyPDF2 will raise on invalid PDF
        result = svc.index_local_file(str(f))
        assert result["status"] == "error"


# =========================================================================
# index_user_document
# =========================================================================
class TestIndexUserDocument:
    def _make_user_doc(self, text_content="some content here for testing"):
        user_doc = MagicMock()
        user_doc.id = "udoc-1"
        user_doc.filename = "test.pdf"
        user_doc.file_type = "pdf"
        user_doc.file_size = 1024
        user_doc.text_content = text_content
        return user_doc

    def test_no_content_returns_error(self):
        svc = _make_service()
        user_doc = self._make_user_doc(text_content=None)
        result = svc.index_user_document(user_doc, "collection_abc")
        assert result["status"] == "error"
        assert "no extractable text" in result["error"]

    def test_short_content_returns_error(self):
        svc = _make_service()
        user_doc = self._make_user_doc(text_content="hi")  # < 10 chars
        result = svc.index_user_document(user_doc, "collection_abc")
        assert result["status"] == "error"

    def test_success_without_rag_index_record(self):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.return_value = ["uid-1"]

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {}
        svc.faiss_index = mock_faiss
        svc.rag_index_record = None  # no record → save is skipped

        mock_chunks = [
            LangchainDocument(page_content="user uploaded content text")
        ]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        user_doc = self._make_user_doc()
        result = svc.index_user_document(user_doc, "collection_abc")
        assert result["status"] == "success"
        assert result["chunk_count"] == 1

    def test_success_with_rag_index_record(self, tmp_path):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.return_value = ["uid-1"]
        svc.integrity_manager = MagicMock()

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {}
        svc.faiss_index = mock_faiss

        mock_record = MagicMock()
        idx_path = tmp_path / "idx.faiss"
        mock_record.index_path = str(idx_path)
        mock_record.id = "rag-1"
        svc.rag_index_record = mock_record

        mock_chunks = [
            LangchainDocument(page_content="user uploaded content text")
        ]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        user_doc = self._make_user_doc()
        result = svc.index_user_document(user_doc, "collection_abc")
        assert result["status"] == "success"
        mock_faiss.save_local.assert_called_once()

    def test_force_reindex_removes_old_chunks(self):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.return_value = ["uid-old"]

        mock_faiss = MagicMock()
        mock_faiss.docstore._dict = {"uid-old": MagicMock()}
        svc.faiss_index = mock_faiss
        svc.rag_index_record = None

        mock_chunks = [
            LangchainDocument(page_content="updated content text here")
        ]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        user_doc = self._make_user_doc()
        result = svc.index_user_document(
            user_doc, "collection_abc", force_reindex=True
        )
        assert result["status"] == "success"
        # Old chunk ids should have been deleted from FAISS
        mock_faiss.delete.assert_called_once()

    def test_exception_returns_error(self):
        svc = _make_service()
        svc.embedding_manager = MagicMock()
        svc.embedding_manager._store_chunks_to_db.side_effect = RuntimeError(
            "db exploded"
        )

        mock_chunks = [
            LangchainDocument(page_content="valid content text here")
        ]
        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.return_value = mock_chunks

        svc.faiss_index = MagicMock()

        user_doc = self._make_user_doc()
        result = svc.index_user_document(user_doc, "collection_abc")
        assert result["status"] == "error"
        assert "RuntimeError" in result["error"]


# =========================================================================
# remove_collection_from_index
# =========================================================================
# The method re-imports get_user_db_session locally, so we must patch the
# source module (session_context) rather than the service module attribute.
_SESSION_CTX_PATH = (
    "local_deep_research.database.session_context.get_user_db_session"
)


class TestRemoveCollectionFromIndex:
    @patch(_SESSION_CTX_PATH)
    def test_no_chunks_returns_success_zero(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)
        mock_session.query.return_value.filter_by.return_value.all.return_value = []

        result = svc.remove_collection_from_index("collection_xyz")
        assert result["status"] == "success"
        assert result["deleted_count"] == 0

    @patch(_SESSION_CTX_PATH)
    def test_with_chunks_deletes_and_saves(self, mock_session_ctx, tmp_path):
        svc = _make_service()
        svc.integrity_manager = MagicMock()

        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_chunk = MagicMock()
        mock_chunk.id = "chunk-1"
        mock_session.query.return_value.filter_by.return_value.all.return_value = [
            mock_chunk
        ]

        mock_faiss = MagicMock()
        svc.faiss_index = mock_faiss

        mock_record = MagicMock()
        idx_path = tmp_path / "idx.faiss"
        mock_record.index_path = str(idx_path)
        mock_record.id = "rag-rec-1"
        svc.rag_index_record = mock_record

        result = svc.remove_collection_from_index("collection_xyz")
        assert result["status"] == "success"
        assert result["deleted_count"] == 1
        mock_faiss.delete.assert_called_once()
        mock_faiss.save_local.assert_called_once()

    @patch(_SESSION_CTX_PATH)
    def test_faiss_delete_exception_still_returns_success(
        self, mock_session_ctx, tmp_path
    ):
        svc = _make_service()
        svc.integrity_manager = MagicMock()

        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_chunk = MagicMock()
        mock_chunk.id = "chunk-1"
        mock_session.query.return_value.filter_by.return_value.all.return_value = [
            mock_chunk
        ]

        mock_faiss = MagicMock()
        mock_faiss.delete.side_effect = RuntimeError("delete failed")
        svc.faiss_index = mock_faiss
        svc.rag_index_record = None

        result = svc.remove_collection_from_index("collection_xyz")
        # Delete failed, but overall status should still be success (warning logged)
        assert result["status"] == "success"
        assert result["deleted_count"] == 1

    @patch(_SESSION_CTX_PATH)
    def test_outer_exception_returns_error(self, mock_session_ctx):
        svc = _make_service()
        mock_session_ctx.side_effect = RuntimeError("session blew up")

        result = svc.remove_collection_from_index("collection_xyz")
        assert result["status"] == "error"
        # The outer except catches the RuntimeError (or any wrapping exception)
        assert "Error" in result["error"]

    @patch(_SESSION_CTX_PATH)
    def test_no_faiss_index_loads_one(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_chunk = MagicMock()
        mock_chunk.id = "chunk-9"
        mock_session.query.return_value.filter_by.return_value.all.return_value = [
            mock_chunk
        ]

        # faiss_index is None → should call load_or_create_faiss_index
        svc.faiss_index = None
        mock_new_faiss = MagicMock()
        svc.load_or_create_faiss_index = MagicMock(return_value=mock_new_faiss)
        svc.rag_index_record = None

        result = svc.remove_collection_from_index("collection_abc123")
        svc.load_or_create_faiss_index.assert_called_once_with("abc123")
        assert result["status"] == "success"


# =========================================================================
# index_document — exception mid-way
# =========================================================================
class TestIndexDocumentExceptionPath:
    @patch(f"{_MOD}.get_user_db_session")
    def test_exception_during_splitting_returns_error(self, mock_session_ctx):
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_document = MagicMock()
        mock_document.text_content = (
            "long enough document content to pass validation"
        )
        mock_document.original_url = "http://example.com"
        mock_document.title = "Test"
        mock_document.filename = None
        mock_document.authors = None
        mock_document.published_date = None
        mock_document.doi = None
        mock_document.arxiv_id = None
        mock_document.pmid = None
        mock_document.pmcid = None
        mock_document.extraction_method = None
        mock_document.word_count = None

        mock_doc_collection = MagicMock()
        mock_doc_collection.indexed = False

        def query_side(model):
            q = MagicMock()
            model_name = getattr(model, "__name__", str(model))
            if model_name == "Document":
                q.filter_by.return_value.first.return_value = mock_document
            elif model_name == "DocumentCollection":
                q.filter_by.return_value.all.return_value = [
                    mock_doc_collection
                ]
            elif model_name == "Collection":
                q.filter_by.return_value.first.return_value = MagicMock()
            return q

        mock_session.query = MagicMock(side_effect=query_side)

        svc.text_splitter = MagicMock()
        svc.text_splitter.split_documents.side_effect = RuntimeError(
            "splitter broke"
        )

        result = svc.index_document("doc-1", "coll-1")
        assert result["status"] == "error"
        assert "RuntimeError" in result["error"]

    @patch(f"{_MOD}.get_user_db_session")
    def test_creates_doc_collection_when_missing(self, mock_session_ctx):
        """When no DocumentCollection exists, a new one is created before proceeding."""
        svc = _make_service()
        mock_session = MagicMock()
        mock_session_ctx.return_value = _make_session_ctx(mock_session)

        mock_document = MagicMock()
        mock_document.text_content = None  # triggers error after dc creation

        def query_side(model):
            q = MagicMock()
            model_name = getattr(model, "__name__", str(model))
            if model_name == "Document":
                q.filter_by.return_value.first.return_value = mock_document
            elif model_name == "DocumentCollection":
                q.filter_by.return_value.all.return_value = []  # empty -> create new
            return q

        mock_session.query = MagicMock(side_effect=query_side)

        result = svc.index_document("doc-new", "coll-1")
        # After creating the DocumentCollection, text_content is None → error
        assert result["status"] == "error"
        assert "no text content" in result["error"]
        mock_session.add.assert_called_once()
