"""
Coverage tests for background / helper functions in rag_routes.py.

Targets lines in:
- _get_rag_service_for_thread  (use_defaults=True, bool normalize_vectors)
- _auto_index_documents_worker (mixed success/skip/failure counting)
- _background_index_worker     (embedding metadata storage, filename fallback,
                                 unknown status branch, mid-loop cancellation)
- _update_task_status          (progress_current-only update, failed status
                                 without completed_at)
- _is_task_cancelled           (None task returns falsy)
- trigger_auto_index           (settings check exception)
"""

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

from local_deep_research.constants import (
    DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS_JSON,
)


# ---------------------------------------------------------------------------
# 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"
_EMB_MGR = (
    "local_deep_research.web_search_engines.engines"
    ".local_embedding_manager.LocalEmbeddingManager"
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


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_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


def _make_settings_mock(overrides=None):
    """Create a mock settings manager."""
    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.auto_index_enabled": True,
    }
    if overrides:
        defaults.update(overrides)
    mock_sm = Mock()
    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_settings_snapshot.return_value = {}
    return mock_sm


def _make_rag_service_mock():
    """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


@contextmanager
def _fake_db_session(db_session):
    """Context manager that yields a mock db session."""
    yield db_session


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


class TestGetRagServiceForThreadBackground:
    """Additional coverage for _get_rag_service_for_thread."""

    def test_use_defaults_true_ignores_collection_settings(self):
        """When use_defaults=True, collection settings are ignored even if present."""
        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 = 999
        mock_coll.chunk_overlap = 50
        mock_coll.splitter_type = "character"
        mock_coll.text_separators = ["\n"]
        mock_coll.distance_metric = "l2"
        mock_coll.normalize_vectors = 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(f"{_EMB_MGR}") as mock_emb,
        ):
            mock_emb.return_value = Mock()
            _get_rag_service_for_thread(
                "coll-1", "testuser", "pass123", use_defaults=True
            )

        # Should use defaults, not the collection's custom-model
        call_kwargs = mock_rag_cls.call_args.kwargs
        assert call_kwargs["embedding_model"] == "all-MiniLM-L6-v2"
        assert call_kwargs["embedding_provider"] == "sentence_transformers"
        assert call_kwargs["chunk_size"] == 1000

    def test_normalize_vectors_as_integer_coerced_to_bool(self):
        """normalize_vectors stored as non-string truthy value is coerced via bool()."""
        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 = "test-model"
        mock_coll.embedding_model_type = Mock(value="ollama")
        mock_coll.chunk_size = 500
        mock_coll.chunk_overlap = 100
        mock_coll.splitter_type = "recursive"
        mock_coll.text_separators = ["\n\n"]
        mock_coll.distance_metric = "cosine"
        mock_coll.normalize_vectors = 1  # integer, not str or None
        mock_coll.index_type = "flat"

        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(f"{_EMB_MGR}") 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["normalize_vectors"] is True


# ---------------------------------------------------------------------------
# _auto_index_documents_worker
# ---------------------------------------------------------------------------


class TestAutoIndexDocumentsWorkerBackground:
    """Additional coverage for _auto_index_documents_worker."""

    def test_mixed_results_counts_successes_only(self):
        """Worker counts successes while handling skips and per-doc failures."""
        from local_deep_research.research_library.routes.rag_routes import (
            _auto_index_documents_worker,
        )

        mock_service = Mock()
        mock_service.__enter__ = Mock(return_value=mock_service)
        mock_service.__exit__ = Mock(return_value=False)
        mock_service.index_document.side_effect = [
            {"status": "success"},
            {"status": "skipped"},
            RuntimeError("doc3 failed"),
            {"status": "success"},
        ]

        with patch(
            f"{MODULE}._get_rag_service_for_thread", return_value=mock_service
        ):
            # Should not raise despite the exception on doc3
            _auto_index_documents_worker(
                ["d1", "d2", "d3", "d4"], "coll-1", "user", "pass"
            )

        # All four documents were attempted
        assert mock_service.index_document.call_count == 4


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


class TestBackgroundIndexWorkerBackground:
    """Additional coverage for _background_index_worker."""

    def test_stores_embedding_metadata_when_collection_has_no_model(self):
        """When collection.embedding_model is None, metadata is stored from rag_service."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

        mock_svc = _make_rag_service_mock()

        mock_coll = Mock()
        mock_coll.embedding_model = None  # triggers metadata storage

        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 = []  # no docs
            return q

        db_session.query = Mock(side_effect=query_side_effect)

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

        statuses = []

        def track_status(username, db_password, task_id, **kwargs):
            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=track_status),
        ):
            _background_index_worker(
                "task-1", "coll-1", "user", "pass", force_reindex=False
            )

        # Embedding metadata should have been stored on the collection
        assert mock_coll.embedding_model == "all-MiniLM-L6-v2"
        assert mock_coll.chunk_size == 1000
        assert mock_coll.chunk_overlap == 200
        db_session.commit.assert_called()

    def test_filename_fallback_to_title(self):
        """When doc.filename is None, title is used for progress messages."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

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

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

        doc = Mock()
        doc.filename = None
        doc.title = "My Document Title"
        doc.id = "doc-1"
        link = 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 = [(link, doc)]
            return q

        db_session.query = Mock(side_effect=query_side_effect)

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

        statuses = []

        def track_status(username, db_password, task_id, **kwargs):
            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=track_status),
            patch(f"{MODULE}._is_task_cancelled", return_value=False),
        ):
            _background_index_worker(
                "task-1", "coll-1", "user", "pass", force_reindex=False
            )

        # Progress message should contain the title (not "Unknown")
        progress_msgs = [s.get("progress_message", "") for s in statuses]
        assert any("My Document Title" in msg for msg in progress_msgs)

    def test_unknown_index_status_counts_as_failed(self):
        """When index_document returns an unrecognized status, it counts as failed."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

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

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

        doc = Mock()
        doc.filename = "test.txt"
        doc.title = None
        doc.id = "doc-1"
        link = 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 = [(link, doc)]
            return q

        db_session.query = Mock(side_effect=query_side_effect)

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

        statuses = []

        def track_status(username, db_password, task_id, **kwargs):
            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=track_status),
            patch(f"{MODULE}._is_task_cancelled", return_value=False),
        ):
            _background_index_worker(
                "task-1", "coll-1", "user", "pass", force_reindex=False
            )

        # Final completed message should show 1 failed, 0 indexed
        final_msg = next(
            (
                s.get("progress_message", "")
                for s in reversed(statuses)
                if s.get("status") == "completed"
            ),
            "",
        )
        assert "1 failed" in final_msg
        assert "0 indexed" in final_msg

    def test_cancellation_after_first_document(self):
        """Worker stops after indexing first document when cancelled before second."""
        from local_deep_research.research_library.routes.rag_routes import (
            _background_index_worker,
        )

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

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

        doc1 = Mock()
        doc1.filename = "a.txt"
        doc1.title = None
        doc1.id = "d1"
        doc2 = Mock()
        doc2.filename = "b.txt"
        doc2.title = None
        doc2.id = "d2"
        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

        statuses = []

        def track_status(username, db_password, task_id, **kwargs):
            statuses.append(kwargs)

        # Cancel after the first document (False on first check, True on second)
        cancel_calls = {"n": 0}

        def cancel_side_effect(*a, **kw):
            cancel_calls["n"] += 1
            return cancel_calls["n"] > 1

        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=track_status),
            patch(
                f"{MODULE}._is_task_cancelled", side_effect=cancel_side_effect
            ),
        ):
            _background_index_worker(
                "task-1", "coll-1", "user", "pass", force_reindex=False
            )

        # First document should have been indexed
        assert mock_svc.index_document.call_count == 1
        # Task should be marked cancelled
        assert any(s.get("status") == "cancelled" for s in statuses)
        cancel_msg = next(
            s.get("progress_message", "")
            for s in statuses
            if s.get("status") == "cancelled"
        )
        assert "1/2" in cancel_msg


# ---------------------------------------------------------------------------
# _update_task_status
# ---------------------------------------------------------------------------


class TestUpdateTaskStatusBackground:
    """Additional coverage for _update_task_status."""

    def test_progress_current_only_without_status_change(self):
        """Updating only progress_current does not set completed_at."""
        from local_deep_research.research_library.routes.rag_routes import (
            _update_task_status,
        )

        mock_task = Mock()
        mock_task.status = "processing"
        mock_task.completed_at = None

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

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

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            _update_task_status("user", "pass", "task-1", progress_current=5)

        assert mock_task.progress_current == 5
        # status and completed_at should not have been touched
        assert mock_task.status == "processing"
        assert mock_task.completed_at is None
        db_session.commit.assert_called_once()

    def test_failed_status_does_not_set_completed_at(self):
        """Setting status to 'failed' does not set completed_at."""
        from local_deep_research.research_library.routes.rag_routes import (
            _update_task_status,
        )

        mock_task = Mock()
        mock_task.status = "processing"
        mock_task.completed_at = None

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

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

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            _update_task_status(
                "user",
                "pass",
                "task-1",
                status="failed",
                error_message="something broke",
            )

        assert mock_task.status == "failed"
        assert mock_task.error_message == "something broke"
        # completed_at should remain None for failed status
        assert mock_task.completed_at is None


# ---------------------------------------------------------------------------
# _is_task_cancelled
# ---------------------------------------------------------------------------


class TestIsTaskCancelledBackground:
    """Additional coverage for _is_task_cancelled."""

    def test_none_task_returns_falsy(self):
        """When no task is found, the result is falsy."""
        from local_deep_research.research_library.routes.rag_routes import (
            _is_task_cancelled,
        )

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

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

        with patch(f"{_DB_CTX}.get_user_db_session", side_effect=fake_session):
            result = _is_task_cancelled("user", "pass", "no-such-task")

        # None and (None and ...) is falsy
        assert not result


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


class TestTriggerAutoIndexBackground:
    """Additional coverage for trigger_auto_index."""

    def test_settings_check_exception_returns_early(self):
        """When settings check raises, function returns without spawning thread."""
        from local_deep_research.research_library.routes.rag_routes import (
            trigger_auto_index,
        )

        with (
            patch(
                f"{_DB_CTX}.get_user_db_session",
                side_effect=RuntimeError("db unavailable"),
            ),
            patch(f"{MODULE}._get_auto_index_executor") as mock_executor,
        ):
            trigger_auto_index(["doc-1"], "coll-1", "user", "pass")

        # Executor should never be called when settings check fails
        mock_executor.assert_not_called()
