"""
Coverage tests for background indexing in rag_routes.py.

Covers:
- _get_rag_service_for_thread: collection stored settings, string normalize_vectors
- trigger_auto_index: empty list, disabled setting, exception in settings check
- _background_index_worker: collection not found, force_reindex cleanup,
  cancellation mid-loop, no documents, mixed results
- start_background_index: already running (409), success (200)
- get_index_status: no task ("idle")
- cancel_indexing: no task (404), wrong collection (404)
"""

import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch

import pytest
from flask import Flask, jsonify

from local_deep_research.constants import (
    DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON,
)
from local_deep_research.web.auth.routes import auth_bp
from local_deep_research.research_library.routes.rag_routes import rag_bp

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

MODULE = "local_deep_research.research_library.routes.rag_routes"
_FACTORY = "local_deep_research.research_library.services.rag_service_factory"
_DB_CTX = "local_deep_research.database.session_context"
_DB_PASS = "local_deep_research.database.session_passwords"

# ---------------------------------------------------------------------------
# Helpers (copied verbatim from test_rag_routes_deep_coverage.py)
# ---------------------------------------------------------------------------


def _uid():
    """Short unique identifier for test isolation."""
    return uuid.uuid4().hex[:12]


def _create_app():
    """Minimal Flask app with rag blueprint."""
    app = Flask(__name__)
    app.config["SECRET_KEY"] = f"test-{_uid()}"
    app.config["WTF_CSRF_ENABLED"] = False
    app.config["TESTING"] = True
    app.register_blueprint(auth_bp)
    app.register_blueprint(rag_bp)

    @app.errorhandler(500)
    def _handle_500(error):
        return jsonify({"error": "Internal server error"}), 500

    return app


def _mock_db_manager():
    """Mock db_manager so login_required passes."""
    m = Mock()
    m.is_user_connected.return_value = True
    m.connections = {"testuser": True}
    m.has_encryption = False
    return m


def _build_mock_query(all_result=None, first_result=None, count_result=0):
    """Build a chainable mock query."""
    q = Mock()
    q.all.return_value = all_result or []
    q.first.return_value = first_result
    q.count.return_value = count_result
    q.filter_by.return_value = q
    q.filter.return_value = q
    q.order_by.return_value = q
    q.outerjoin.return_value = q
    q.join.return_value = q
    q.options.return_value = q
    q.limit.return_value = q
    q.offset.return_value = q
    q.delete.return_value = 0
    q.update.return_value = 0
    return q


def _make_settings_mock(overrides=None):
    """Create a mock settings manager."""
    mock_sm = Mock()
    defaults = {
        "local_search_embedding_model": "all-MiniLM-L6-v2",
        "local_search_embedding_provider": "sentence_transformers",
        "local_search_chunk_size": 1000,
        "local_search_chunk_overlap": 200,
        "local_search_splitter_type": "recursive",
        "local_search_text_separators": DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON,
        "local_search_distance_metric": "cosine",
        "local_search_normalize_vectors": True,
        "local_search_index_type": "flat",
        "research_library.upload_pdf_storage": "none",
        "research_library.storage_path": "/tmp/test_lib",
        "rag.indexing_batch_size": 15,
        "research_library.auto_index_enabled": True,
    }
    if overrides:
        defaults.update(overrides)
    mock_sm.get_setting.side_effect = lambda k, d=None: defaults.get(k, d)
    mock_sm.get_bool_setting.side_effect = lambda k, d=None: defaults.get(k, d)
    mock_sm.get_all_settings.return_value = {}
    mock_sm.set_setting = Mock()
    mock_sm.get_settings_snapshot.return_value = {}
    return mock_sm


def _make_db_session():
    """Create a standard mock db session."""
    s = Mock()
    s.query = Mock(return_value=_build_mock_query())
    s.commit = Mock()
    s.add = Mock()
    s.flush = Mock()
    s.expire_all = Mock()
    return s


@contextmanager
def _auth_client(
    app, mock_db_session=None, settings_overrides=None, extra_patches=None
):
    """Context manager providing an authenticated test client with mocking."""
    mock_db = _mock_db_manager()
    db_session = mock_db_session or _make_db_session()
    mock_sm = _make_settings_mock(settings_overrides)

    @contextmanager
    def fake_get_user_db_session(*a, **kw):
        yield db_session

    patches = [
        patch("local_deep_research.web.auth.decorators.db_manager", mock_db),
        patch(
            f"{_DB_CTX}.get_user_db_session",
            side_effect=fake_get_user_db_session,
        ),
        patch(f"{MODULE}.get_settings_manager", return_value=mock_sm),
        patch(
            "local_deep_research.utilities.db_utils.get_settings_manager",
            return_value=mock_sm,
        ),
        patch(f"{MODULE}.limiter", Mock(exempt=lambda f: f)),
        patch(f"{MODULE}.upload_rate_limit_user", lambda f: f),
        patch(f"{MODULE}.upload_rate_limit_ip", lambda f: f),
    ]
    if extra_patches:
        patches.extend(extra_patches)

    started = []
    try:
        for p in patches:
            started.append(p.start())
        with app.test_client() as client:
            with client.session_transaction() as sess:
                sess["username"] = "testuser"
                sess["session_id"] = "test-session-id"
            yield client, {"db_session": db_session, "settings": mock_sm}
    finally:
        for p in patches:
            p.stop()


@pytest.fixture
def app():
    """Minimal Flask app fixture."""
    return _create_app()


# ---------------------------------------------------------------------------
# _get_rag_service_for_thread
# ---------------------------------------------------------------------------


class TestGetRagServiceForThread:
    """Tests for _get_rag_service_for_thread collection-settings paths."""

    def test_rag_service_thread_with_collection_settings(self):
        """Uses stored collection settings when collection.embedding_model is set."""
        from local_deep_research.research_library.routes.rag_routes import (
            _get_rag_service_for_thread,
        )

        mock_sm = _make_settings_mock()

        mock_coll = Mock()
        mock_coll.embedding_model = "custom-model"
        mock_coll.embedding_model_type = Mock(value="ollama")
        mock_coll.chunk_size = 512
        mock_coll.chunk_overlap = 64
        mock_coll.splitter_type = "character"
        mock_coll.text_separators = ["\n\n", "\n"]
        mock_coll.distance_metric = "l2"
        mock_coll.normalize_vectors = False  # bool False
        mock_coll.index_type = "hnsw"

        db_session = _make_db_session()
        q = _build_mock_query(first_result=mock_coll)
        db_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        mock_service = Mock()

        with (
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{_FACTORY}.get_user_db_session", side_effect=fake_session),
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(
                f"{_FACTORY}.LibraryRAGService", return_value=mock_service
            ) as mock_rag_cls,
            patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
            patch(f"{MODULE}.LibraryRAGService", return_value=mock_service),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.LocalEmbeddingManager"
            ) as mock_emb,
        ):
            mock_emb.return_value = Mock()
            _get_rag_service_for_thread("coll-1", "testuser", "pass123")

        call_kwargs = mock_rag_cls.call_args.kwargs
        assert call_kwargs["embedding_model"] == "custom-model"
        assert call_kwargs["embedding_provider"] == "ollama"
        assert call_kwargs["chunk_size"] == 512
        assert call_kwargs["chunk_overlap"] == 64
        assert call_kwargs["splitter_type"] == "character"
        assert call_kwargs["distance_metric"] == "l2"
        assert call_kwargs["normalize_vectors"] is False
        assert call_kwargs["index_type"] == "hnsw"

    def test_rag_service_thread_normalize_vectors_string(self):
        """String 'true'/'false' for normalize_vectors is parsed to bool."""
        from local_deep_research.research_library.routes.rag_routes import (
            _get_rag_service_for_thread,
        )

        mock_sm = _make_settings_mock()

        # Test "true" string → True
        mock_coll = Mock()
        mock_coll.embedding_model = "model-x"
        mock_coll.embedding_model_type = Mock(value="sentence_transformers")
        mock_coll.chunk_size = None
        mock_coll.chunk_overlap = None
        mock_coll.splitter_type = None
        mock_coll.text_separators = None
        mock_coll.distance_metric = None
        mock_coll.normalize_vectors = "false"  # String "false" → bool False
        mock_coll.index_type = None

        db_session = _make_db_session()
        q = _build_mock_query(first_result=mock_coll)
        db_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        mock_service = Mock()

        with (
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{_FACTORY}.get_user_db_session", side_effect=fake_session),
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(
                f"{_FACTORY}.LibraryRAGService", return_value=mock_service
            ) as mock_rag_cls,
            patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
            patch(f"{MODULE}.LibraryRAGService", return_value=mock_service),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.LocalEmbeddingManager"
            ) as mock_emb,
        ):
            mock_emb.return_value = Mock()
            _get_rag_service_for_thread("coll-1", "testuser", "pass123")

        call_kwargs = mock_rag_cls.call_args.kwargs
        # "false" is not in ("true", "1", "yes") → False
        assert call_kwargs["normalize_vectors"] is False

        # Now test "true" → True
        mock_coll.normalize_vectors = "true"

        with (
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{_FACTORY}.get_user_db_session", side_effect=fake_session),
            patch(f"{_FACTORY}.get_settings_manager", return_value=mock_sm),
            patch(
                f"{_FACTORY}.LibraryRAGService", return_value=mock_service
            ) as mock_rag_cls2,
            patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
            patch(f"{MODULE}.LibraryRAGService", return_value=mock_service),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.LocalEmbeddingManager"
            ) as mock_emb2,
        ):
            mock_emb2.return_value = Mock()
            _get_rag_service_for_thread("coll-1", "testuser", "pass123")

        call_kwargs2 = mock_rag_cls2.call_args.kwargs
        assert call_kwargs2["normalize_vectors"] is True


# ---------------------------------------------------------------------------
# trigger_auto_index
# ---------------------------------------------------------------------------


class TestTriggerAutoIndex:
    """Tests for trigger_auto_index."""

    def test_trigger_auto_index_empty_list(self):
        """Empty document_ids list causes early return without checking settings."""
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        with patch(f"{_DB_CTX}.get_user_db_session") as mock_session_factory:
            trigger_auto_index([], "coll-1", "testuser", "pass")

        # No DB session should be opened when list is empty
        mock_session_factory.assert_not_called()

    def test_trigger_auto_index_disabled(self):
        """auto_index_enabled=False causes early return without spawning a thread."""
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        mock_sm = Mock()
        mock_sm.get_bool_setting.return_value = False  # Disabled

        db_session = _make_db_session()

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        with (
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
            patch(f"{MODULE}._get_auto_index_executor") as mock_executor_fn,
        ):
            trigger_auto_index(["doc-1", "doc-2"], "coll-1", "testuser", "pass")

        # Executor must not be called when disabled
        mock_executor_fn.assert_not_called()

    def test_trigger_auto_index_setting_check_failure(self):
        """Exception while checking auto_index_enabled is caught and logged."""
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        @contextmanager
        def exploding_session(*a, **kw):
            raise RuntimeError("db exploded")
            yield  # noqa: F704

        with (
            patch(
                f"{_DB_CTX}.get_user_db_session", side_effect=exploding_session
            ),
            patch(f"{MODULE}._get_auto_index_executor") as mock_executor_fn,
        ):
            # Should not raise — exception is caught internally
            trigger_auto_index(["doc-1"], "coll-1", "testuser", "pass")

        # Executor must not be called when settings check fails
        mock_executor_fn.assert_not_called()

    def test_trigger_auto_index_drops_when_queue_saturated(self):
        """When the pending-jobs counter hits the cap, new submissions
        are dropped and the executor is never called."""
        import local_deep_research.research_library.routes.rag_routes as mod
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        mock_sm = Mock()
        mock_sm.get_bool_setting.return_value = True  # enabled
        db_session = _make_db_session()

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        # Saturate the counter to its cap so the next submission is dropped.
        original_pending = mod._pending_auto_index_jobs
        mod._pending_auto_index_jobs = mod._MAX_PENDING_AUTO_INDEX_JOBS

        try:
            with (
                patch(
                    f"{_DB_CTX}.get_user_db_session", side_effect=fake_session
                ),
                patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
                patch(f"{MODULE}._get_auto_index_executor") as mock_executor_fn,
            ):
                trigger_auto_index(["doc-1"], "coll-1", "testuser", "pass")
            mock_executor_fn.assert_not_called()
            # Counter must NOT have been bumped (or leaked) when dropped.
            assert (
                mod._pending_auto_index_jobs == mod._MAX_PENDING_AUTO_INDEX_JOBS
            )
        finally:
            mod._pending_auto_index_jobs = original_pending

    def test_trigger_auto_index_releases_slot_after_worker(self):
        """Wrapped worker releases its queue slot when the underlying
        worker raises — preventing counter leaks that would eventually
        block all auto-indexing."""
        import local_deep_research.research_library.routes.rag_routes as mod
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        mock_sm = Mock()
        mock_sm.get_bool_setting.return_value = True
        db_session = _make_db_session()

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        # Synchronous "executor" that faithfully emulates a real
        # ThreadPoolExecutor: a worker exception is captured (it would live in
        # the returned Future) rather than escaping submit(). This is what the
        # production executor does, so the worker exception must NOT propagate
        # out of trigger_auto_index.
        captured = {}

        class _SyncExecutor:
            def submit(self, fn, *args, **kwargs):
                captured["called"] = True
                try:
                    fn(*args, **kwargs)
                except Exception as exc:  # captured into the (mock) future
                    captured["worker_exc"] = exc

        original_pending = mod._pending_auto_index_jobs
        mod._pending_auto_index_jobs = 0

        try:
            with (
                patch(
                    f"{_DB_CTX}.get_user_db_session", side_effect=fake_session
                ),
                patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
                patch(
                    f"{MODULE}._get_auto_index_executor",
                    return_value=_SyncExecutor(),
                ),
                patch(
                    f"{MODULE}._auto_index_documents_worker",
                    side_effect=RuntimeError("worker boom"),
                ),
            ):
                # Must not raise: a worker failure stays inside the future,
                # exactly as with the real ThreadPoolExecutor.
                trigger_auto_index(["doc-1"], "coll-1", "testuser", "pass")

            assert captured.get("called") is True
            # The worker did raise (captured in the future).
            assert isinstance(captured.get("worker_exc"), RuntimeError)
            # The slot must be released by the wrapper's finally so the counter
            # is back to zero (otherwise auto-indexing would silently
            # block forever once 100 worker exceptions accumulated).
            assert mod._pending_auto_index_jobs == 0
        finally:
            mod._pending_auto_index_jobs = original_pending

    def test_trigger_auto_index_releases_slot_when_submit_fails(self):
        """If executor.submit() raises (e.g., shutting down), the slot is
        released rather than leaked AND the failure is swallowed.

        The upload has already been committed by the caller before
        trigger_auto_index runs, so propagating a submit failure would turn a
        successful upload into a 500 and prompt duplicate retries. The failure
        must instead be logged and auto-indexing simply skipped.
        """
        import local_deep_research.research_library.routes.rag_routes as mod
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        mock_sm = Mock()
        mock_sm.get_bool_setting.return_value = True
        db_session = _make_db_session()

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        broken_executor = Mock()
        broken_executor.submit.side_effect = RuntimeError("executor shut down")

        original_pending = mod._pending_auto_index_jobs
        mod._pending_auto_index_jobs = 0

        try:
            with (
                patch(
                    f"{_DB_CTX}.get_user_db_session", side_effect=fake_session
                ),
                patch(f"{MODULE}.SettingsManager", return_value=mock_sm),
                patch(
                    f"{MODULE}._get_auto_index_executor",
                    return_value=broken_executor,
                ),
            ):
                # Must not raise: the committed upload must not become a 500.
                trigger_auto_index(["doc-1"], "coll-1", "testuser", "pass")
            broken_executor.submit.assert_called_once()
            assert mod._pending_auto_index_jobs == 0
        finally:
            mod._pending_auto_index_jobs = original_pending


# ---------------------------------------------------------------------------
# _background_index_worker
# ---------------------------------------------------------------------------


class TestBackgroundIndexWorker:
    """Direct tests for _background_index_worker."""

    def _make_rag_service_mock(self):
        """Create a mock LibraryRAGService that works as context manager."""
        svc = Mock()
        svc.__enter__ = Mock(return_value=svc)
        svc.__exit__ = Mock(return_value=False)
        svc.embedding_model = "all-MiniLM-L6-v2"
        svc.embedding_provider = "sentence_transformers"
        svc.chunk_size = 1000
        svc.chunk_overlap = 200
        svc.splitter_type = "recursive"
        svc.text_separators = ["\n\n", "\n"]
        svc.distance_metric = "cosine"
        svc.normalize_vectors = True
        svc.index_type = "flat"
        return svc

    def test_background_worker_collection_not_found(self):
        """When collection is not found, task status is set to 'failed'."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

        mock_svc = self._make_rag_service_mock()
        db_session = _make_db_session()
        # Collection query returns None
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        updated_statuses = []

        def fake_update_task_status(username, db_password, task_id, **kwargs):
            updated_statuses.append(kwargs)

        with (
            patch(
                f"{MODULE}._get_rag_service_for_thread", return_value=mock_svc
            ),
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(
                f"{MODULE}._update_task_status",
                side_effect=fake_update_task_status,
            ),
        ):
            _background_index_worker(
                "task-1", "coll-1", "testuser", "pass", force_reindex=False
            )

        assert any(s.get("status") == "failed" for s in updated_statuses)
        assert any(
            "Collection not found" in (s.get("error_message") or "")
            for s in updated_statuses
        )

    def test_background_worker_force_reindex_cleanup(self):
        """force_reindex=True triggers cascade deletion of old chunks."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

        mock_svc = self._make_rag_service_mock()
        mock_svc.index_document.return_value = {"status": "success"}

        mock_coll = Mock()
        mock_coll.embedding_model = None  # Will be set during force reindex

        db_session = _make_db_session()

        # Build a query that returns the collection for the first query,
        # and an empty list for doc_links (no docs to index)
        query_counter = {"n": 0}

        def query_side_effect(*models):
            query_counter["n"] += 1
            q = _build_mock_query()
            if query_counter["n"] == 1:
                # Collection lookup
                q.first.return_value = mock_coll
            else:
                # DocumentCollection + Document join → no docs
                q.all.return_value = []
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        mock_cascade = Mock()
        mock_cascade.delete_collection_chunks.return_value = 5
        mock_cascade.delete_rag_indices_for_collection.return_value = {
            "deleted": 2
        }

        updated_statuses = []

        def fake_update(username, db_password, task_id, **kwargs):
            updated_statuses.append(kwargs)

        with (
            patch(
                f"{MODULE}._get_rag_service_for_thread", return_value=mock_svc
            ),
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{MODULE}._update_task_status", side_effect=fake_update),
            patch(
                "local_deep_research.research_library.deletion.utils.cascade_helper.CascadeHelper",
                mock_cascade,
            ),
        ):
            _background_index_worker(
                "task-1", "coll-1", "testuser", "pass", force_reindex=True
            )

        # CascadeHelper methods were called via the import inside the function,
        # so verify the task eventually completed (or at least didn't fail hard)
        assert any(
            s.get("status") in ("completed", "failed")
            or "No documents" in (s.get("progress_message") or "")
            for s in updated_statuses
        )

    def test_background_worker_cancellation(self):
        """Worker stops mid-loop when _is_task_cancelled returns True."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

        mock_svc = self._make_rag_service_mock()
        mock_svc.index_document.return_value = {"status": "success"}

        mock_coll = Mock()
        mock_coll.embedding_model = "model"

        # Create two doc links so the loop has something to iterate
        doc1 = Mock()
        doc1.filename = "file1.txt"
        doc1.title = "Title 1"
        doc1.id = "doc-1"
        doc2 = Mock()
        doc2.filename = "file2.txt"
        doc2.title = "Title 2"
        doc2.id = "doc-2"

        link1 = Mock()
        link2 = Mock()

        db_session = _make_db_session()
        query_counter = {"n": 0}

        def query_side_effect(*models):
            query_counter["n"] += 1
            q = _build_mock_query()
            if query_counter["n"] == 1:
                q.first.return_value = mock_coll
            else:
                q.all.return_value = [(link1, doc1), (link2, doc2)]
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        updated_statuses = []

        def fake_update(username, db_password, task_id, **kwargs):
            updated_statuses.append(kwargs)

        # _is_task_cancelled returns True on first call (before doc1 is indexed)
        with (
            patch(
                f"{MODULE}._get_rag_service_for_thread", return_value=mock_svc
            ),
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{MODULE}._update_task_status", side_effect=fake_update),
            patch(f"{MODULE}._is_task_cancelled", return_value=True),
        ):
            _background_index_worker(
                "task-1", "coll-1", "testuser", "pass", force_reindex=False
            )

        # Should have been marked as cancelled
        assert any(s.get("status") == "cancelled" for s in updated_statuses)
        # index_document must not have been called (cancelled before first doc)
        mock_svc.index_document.assert_not_called()

    def test_background_worker_no_documents(self):
        """No documents in collection → task marked completed with 0 indexed."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

        mock_svc = self._make_rag_service_mock()

        mock_coll = Mock()
        mock_coll.embedding_model = "model"

        db_session = _make_db_session()
        query_counter = {"n": 0}

        def query_side_effect(*models):
            query_counter["n"] += 1
            q = _build_mock_query()
            if query_counter["n"] == 1:
                q.first.return_value = mock_coll
            else:
                q.all.return_value = []
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        updated_statuses = []

        def fake_update(username, db_password, task_id, **kwargs):
            updated_statuses.append(kwargs)

        with (
            patch(
                f"{MODULE}._get_rag_service_for_thread", return_value=mock_svc
            ),
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{MODULE}._update_task_status", side_effect=fake_update),
        ):
            _background_index_worker(
                "task-1", "coll-1", "testuser", "pass", force_reindex=False
            )

        assert any(s.get("status") == "completed" for s in updated_statuses)
        assert any(
            "No documents to index" in (s.get("progress_message") or "")
            for s in updated_statuses
        )

    def test_background_worker_mixed_results(self):
        """Mixed success/skip/fail results are tallied and reported."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

        mock_svc = self._make_rag_service_mock()

        # Three documents: one success, one skipped, one raises exception
        doc_success = Mock()
        doc_success.filename = "success.txt"
        doc_success.title = None
        doc_success.id = "doc-ok"

        doc_skip = Mock()
        doc_skip.filename = "skip.txt"
        doc_skip.title = None
        doc_skip.id = "doc-skip"

        doc_fail = Mock()
        doc_fail.filename = "fail.txt"
        doc_fail.title = None
        doc_fail.id = "doc-fail"

        link_ok = Mock()
        link_skip = Mock()
        link_fail = Mock()

        call_count = {"n": 0}

        def index_side_effect(document_id, collection_id, force_reindex):
            call_count["n"] += 1
            if document_id == "doc-ok":
                return {"status": "success"}
            if document_id == "doc-skip":
                return {"status": "skipped"}
            raise RuntimeError("indexing exploded")

        mock_svc.index_document.side_effect = index_side_effect

        mock_coll = Mock()
        mock_coll.embedding_model = "model"

        db_session = _make_db_session()
        query_counter = {"n": 0}

        def query_side_effect(*models):
            query_counter["n"] += 1
            q = _build_mock_query()
            if query_counter["n"] == 1:
                q.first.return_value = mock_coll
            else:
                q.all.return_value = [
                    (link_ok, doc_success),
                    (link_skip, doc_skip),
                    (link_fail, doc_fail),
                ]
            return q

        db_session.query = Mock(side_effect=query_side_effect)

        @contextmanager
        def fake_session(*a, **kw):
            yield db_session

        updated_statuses = []

        def fake_update(username, db_password, task_id, **kwargs):
            updated_statuses.append(kwargs)

        with (
            patch(
                f"{MODULE}._get_rag_service_for_thread", return_value=mock_svc
            ),
            patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session),
            patch(f"{MODULE}._update_task_status", side_effect=fake_update),
            patch(f"{MODULE}._is_task_cancelled", return_value=False),
        ):
            _background_index_worker(
                "task-1", "coll-1", "testuser", "pass", force_reindex=False
            )

        # Final status should be completed
        assert any(s.get("status") == "completed" for s in updated_statuses)
        # Final message should reflect the mixed results
        final_msg = next(
            (
                s.get("progress_message")
                for s in reversed(updated_statuses)
                if s.get("status") == "completed"
            ),
            "",
        )
        assert "1 indexed" in final_msg
        assert "1 failed" in final_msg
        assert "1 skipped" in final_msg


# ---------------------------------------------------------------------------
# start_background_index  (HTTP endpoint)
# ---------------------------------------------------------------------------


class TestStartBackgroundIndex:
    """Tests for the start_background_index route."""

    def test_start_background_index_already_running(self, app):
        """Returns 409 when an active indexing task already exists for the collection."""
        existing_task = Mock()
        existing_task.task_id = "task-existing"
        existing_task.status = "processing"
        existing_task.metadata_json = {"collection_id": "coll-1"}

        db_session = _make_db_session()
        q = _build_mock_query(first_result=existing_task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post(
                "/library/api/collections/coll-1/index/start",
                json={"force_reindex": False},
                content_type="application/json",
            )

        assert resp.status_code == 409
        data = resp.get_json()
        assert data["success"] is False
        assert data["task_id"] == "task-existing"

    def test_start_background_index_success(self, app):
        """Returns 200 with task_id when no active task exists."""
        db_session = _make_db_session()

        # No existing task → first() returns None, then add/commit works
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        mock_thread_inst = Mock()

        # Patch the background thread so it doesn't actually run
        with patch(f"{MODULE}.threading.Thread", return_value=mock_thread_inst):
            with _auth_client(
                app,
                mock_db_session=db_session,
                extra_patches=[
                    patch(
                        f"{_DB_PASS}.session_password_store",
                        mock_password_store,
                    ),
                ],
            ) as (client, ctx):
                resp = client.post(
                    "/library/api/collections/coll-1/index/start",
                    json={"force_reindex": False},
                    content_type="application/json",
                )

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["success"] is True
        assert "task_id" in data
        assert data["message"] == "Indexing started in background"
        # Thread was started
        mock_thread_inst.start.assert_called_once()


# ---------------------------------------------------------------------------
# get_index_status  (HTTP endpoint)
# ---------------------------------------------------------------------------


class TestGetIndexStatus:
    """Tests for the get_index_status route."""

    def test_get_index_status_no_task(self, app):
        """Returns 'idle' when no indexing task exists."""
        db_session = _make_db_session()
        # query returns None (no task)
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.get("/library/api/collections/coll-1/index/status")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "idle"


# ---------------------------------------------------------------------------
# cancel_indexing  (HTTP endpoint)
# ---------------------------------------------------------------------------


class TestCancelIndexing:
    """Tests for the cancel_indexing route."""

    def test_cancel_indexing_no_task(self, app):
        """Returns 404 when no active processing task exists."""
        db_session = _make_db_session()
        # No processing task found
        q = _build_mock_query(first_result=None)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            resp = client.post("/library/api/collections/coll-1/index/cancel")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["success"] is False
        assert "No active indexing task" in data["error"]

    def test_cancel_indexing_wrong_collection(self, app):
        """Returns 404 when the active task belongs to a different collection."""
        # Task exists but is for a different collection
        existing_task = Mock()
        existing_task.task_id = "task-other"
        existing_task.status = "processing"
        existing_task.metadata_json = {"collection_id": "coll-OTHER"}

        db_session = _make_db_session()
        q = _build_mock_query(first_result=existing_task)
        db_session.query = Mock(return_value=q)

        mock_password_store = Mock()
        mock_password_store.get_session_password.return_value = None

        with _auth_client(
            app,
            mock_db_session=db_session,
            extra_patches=[
                patch(
                    f"{_DB_PASS}.session_password_store", mock_password_store
                ),
            ],
        ) as (client, ctx):
            # Request cancellation for "coll-1", but task is for "coll-OTHER"
            resp = client.post("/library/api/collections/coll-1/index/cancel")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["success"] is False
        assert "this collection" in data["error"]
