"""
Tests for rag_service_factory.get_rag_service().
"""

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

import pytest

from local_deep_research.database.models.library import (
    Collection,
    EmbeddingProvider,
)
from local_deep_research.constants import DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS
from local_deep_research.research_library.services.rag_service_factory import (
    _get_default_text_separators,
    get_rag_service,
)

FACTORY_MODULE = (
    "local_deep_research.research_library.services.rag_service_factory"
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_settings():
    """Mock SettingsManager with default embedding settings."""
    mgr = MagicMock()
    mgr.get_setting.side_effect = lambda key, default=None: {
        "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": None,
        "local_search_distance_metric": "cosine",
        "local_search_index_type": "flat",
    }.get(key, default)
    mgr.get_bool_setting.return_value = True
    return mgr


@pytest.fixture
def patch_settings(mock_settings):
    """Patch get_settings_manager to return our mock."""
    with patch(
        f"{FACTORY_MODULE}.get_settings_manager", return_value=mock_settings
    ):
        yield mock_settings


@pytest.fixture
def mock_db_session(library_session):
    """Yield a context-managed library_session for the factory's DB calls."""

    @contextmanager
    def _ctx(*args, **kwargs):
        yield library_session

    with patch(f"{FACTORY_MODULE}.get_user_db_session", _ctx):
        yield library_session


@pytest.fixture
def mock_rag_cls():
    """Patch LibraryRAGService so we capture constructor args without side-effects."""
    with patch(f"{FACTORY_MODULE}.LibraryRAGService") as cls:
        cls.return_value = MagicMock(name="RAGServiceInstance")
        yield cls


class TestGetDefaultTextSeparators:
    def test_parses_json_string(self):
        settings = MagicMock()
        settings.get_setting.return_value = '["\\n", ". "]'

        separators = _get_default_text_separators(settings)

        assert separators == ["\n", ". "]

    def test_python_repr_corrupt_value_falls_back_to_defaults(self):
        """A Python-repr (single-quoted) corrupt value is not JSON; it is no
        longer ast-recovered and instead falls back to the defaults. Migration
        #4298 heals existing corrupt rows."""
        settings = MagicMock()
        settings.get_setting.return_value = "['\\n\\n', '\\n']"

        separators = _get_default_text_separators(settings)

        assert separators == DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS

    def test_invalid_string_falls_back_to_defaults(self):
        settings = MagicMock()
        settings.get_setting.return_value = "not valid json"

        separators = _get_default_text_separators(settings)

        assert separators == DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS

    def test_non_list_value_falls_back_to_defaults(self):
        settings = MagicMock()
        settings.get_setting.return_value = 42

        separators = _get_default_text_separators(settings)

        assert separators == DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS


# ---------------------------------------------------------------------------
# Tests — no collection_id
# ---------------------------------------------------------------------------


class TestGetRagServiceDefaults:
    def test_returns_service_with_defaults(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """Without collection_id, should create service with default settings."""
        service = get_rag_service("alice")

        mock_rag_cls.assert_called_once()
        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["username"] == "alice"
        assert kwargs["embedding_model"] == "all-MiniLM-L6-v2"
        assert kwargs["embedding_provider"] == "sentence_transformers"
        assert kwargs["chunk_size"] == 1000
        assert kwargs["chunk_overlap"] == 200
        assert kwargs["db_password"] is None
        assert service is mock_rag_cls.return_value

    def test_passes_db_password(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """db_password should be forwarded to LibraryRAGService."""
        get_rag_service("alice", db_password="secret")

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["db_password"] == "secret"

    def test_fallback_when_settings_return_none(
        self, mock_db_session, mock_rag_cls
    ):
        """When all settings return None, factory should use hardcoded defaults."""
        mgr = MagicMock()
        mgr.get_setting.return_value = None
        mgr.get_bool_setting.return_value = None

        with patch(f"{FACTORY_MODULE}.get_settings_manager", return_value=mgr):
            get_rag_service("alice")

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["embedding_model"] == "all-MiniLM-L6-v2"
        assert kwargs["embedding_provider"] == "sentence_transformers"
        assert kwargs["chunk_size"] == 1000
        assert kwargs["chunk_overlap"] == 200
        assert kwargs["splitter_type"] == "recursive"
        assert kwargs["distance_metric"] == "cosine"
        assert kwargs["index_type"] == "flat"
        assert kwargs["text_separators"] == DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS

    def test_text_separators_json_string_parsed(
        self, mock_db_session, mock_rag_cls
    ):
        """JSON-encoded text_separators string should be parsed to a list."""
        mgr = MagicMock()
        mgr.get_setting.side_effect = lambda key, default=None: {
            "local_search_text_separators": '["\\n", ". "]',
        }.get(key, default)
        mgr.get_bool_setting.return_value = True

        with patch(f"{FACTORY_MODULE}.get_settings_manager", return_value=mgr):
            get_rag_service("alice")

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["text_separators"] == ["\n", ". "]

    def test_invalid_text_separators_json_uses_default(
        self, mock_db_session, mock_rag_cls
    ):
        """Invalid JSON for text_separators should fall back to defaults."""
        mgr = MagicMock()
        mgr.get_setting.side_effect = lambda key, default=None: {
            "local_search_text_separators": "not valid json",
        }.get(key, default)
        mgr.get_bool_setting.return_value = True

        with patch(f"{FACTORY_MODULE}.get_settings_manager", return_value=mgr):
            get_rag_service("alice")

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["text_separators"] == DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS

    def test_text_separators_python_repr_falls_back_to_defaults(
        self, mock_db_session, mock_rag_cls
    ):
        """A Python-repr (single-quoted) corrupt value is not valid JSON and
        is no longer ast-recovered; it falls back to the defaults. Migration
        #4298 heals existing corrupt rows."""
        mgr = MagicMock()
        mgr.get_setting.side_effect = lambda key, default=None: {
            "local_search_text_separators": "['\\n\\n', '\\n']",
        }.get(key, default)
        mgr.get_bool_setting.return_value = True

        with patch(f"{FACTORY_MODULE}.get_settings_manager", return_value=mgr):
            get_rag_service("alice")

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["text_separators"] == DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS


# ---------------------------------------------------------------------------
# Regression tests for #3453 — ensure db_session plumbing stays wired
# ---------------------------------------------------------------------------


class TestSettingsManagerReceivesDbSession:
    """Regression tests for #3453.

    The bug was that get_settings_manager was called without db_session,
    and in background threads (no Flask app context) it silently fell back
    to JSON defaults.  These tests pin down the fix so the kwarg cannot be
    dropped again without CI catching it.
    """

    def test_settings_manager_called_with_db_session_kwarg(
        self, mock_db_session, mock_rag_cls
    ):
        """get_settings_manager must receive db_session= to avoid silent
        fallback when invoked from a background indexing thread (#3453)."""
        mgr = MagicMock()
        mgr.get_setting.return_value = None
        mgr.get_bool_setting.return_value = None

        with patch(
            f"{FACTORY_MODULE}.get_settings_manager", return_value=mgr
        ) as mock_gsm:
            get_rag_service("alice")

        mock_gsm.assert_called_once()
        call_kwargs = mock_gsm.call_args.kwargs
        assert "db_session" in call_kwargs, (
            "get_settings_manager must be called with db_session=; "
            "without it, background threads silently fall back to JSON "
            "defaults (see #3453)."
        )
        assert call_kwargs["db_session"] is mock_db_session
        assert call_kwargs["username"] == "alice"

    def test_get_user_db_session_called_with_username_and_password(
        self, mock_rag_cls
    ):
        """The factory must open get_user_db_session with (username,
        db_password) so that encrypted per-user databases are reachable
        from background threads that have no Flask g."""
        mgr = MagicMock()
        mgr.get_setting.return_value = None
        mgr.get_bool_setting.return_value = None

        @contextmanager
        def fake_ctx(*args, **kwargs):
            yield MagicMock(name="db_session")

        ctx_spy = MagicMock(side_effect=fake_ctx)

        with (
            patch(f"{FACTORY_MODULE}.get_user_db_session", ctx_spy),
            patch(f"{FACTORY_MODULE}.get_settings_manager", return_value=mgr),
        ):
            get_rag_service("alice", db_password="secret")

        ctx_spy.assert_called_once_with("alice", "secret")


# ---------------------------------------------------------------------------
# Tests — with collection_id
# ---------------------------------------------------------------------------


class TestGetRagServiceWithCollection:
    def test_uses_stored_collection_settings(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """When collection has stored embedding settings, those should be used."""
        coll = Collection(
            id=str(uuid.uuid4()),
            name="Test Collection",
            is_default=False,
            collection_type="user_collection",
            embedding_model="nomic-embed-text",
            embedding_model_type=EmbeddingProvider.OLLAMA,
            chunk_size=500,
            chunk_overlap=100,
            splitter_type="token",
            distance_metric="l2",
            normalize_vectors=True,
            index_type="hnsw",
        )
        mock_db_session.add(coll)
        mock_db_session.commit()

        get_rag_service("alice", collection_id=coll.id)

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["embedding_model"] == "nomic-embed-text"
        assert kwargs["embedding_provider"] == "ollama"
        assert kwargs["chunk_size"] == 500
        assert kwargs["chunk_overlap"] == 100

    def test_new_collection_uses_defaults(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """Collection without stored embedding_model should get defaults."""
        coll = Collection(
            id=str(uuid.uuid4()),
            name="New Collection",
            is_default=False,
            collection_type="user_collection",
            embedding_model=None,
        )
        mock_db_session.add(coll)
        mock_db_session.commit()

        get_rag_service("alice", collection_id=coll.id)

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["embedding_model"] == "all-MiniLM-L6-v2"

    def test_use_defaults_ignores_stored_settings(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """use_defaults=True should bypass stored collection settings."""
        coll = Collection(
            id=str(uuid.uuid4()),
            name="Collection With Settings",
            is_default=False,
            collection_type="user_collection",
            embedding_model="nomic-embed-text",
            embedding_model_type=EmbeddingProvider.OLLAMA,
        )
        mock_db_session.add(coll)
        mock_db_session.commit()

        get_rag_service("alice", collection_id=coll.id, use_defaults=True)

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["embedding_model"] == "all-MiniLM-L6-v2"

    def test_nonexistent_collection_uses_defaults(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """Unknown collection_id should fall back to defaults."""
        get_rag_service("alice", collection_id="nonexistent-id")

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["embedding_model"] == "all-MiniLM-L6-v2"

    def test_nullable_embedding_model_type_in_log(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """Collection with embedding_model but NULL embedding_model_type should not crash."""
        coll = Collection(
            id=str(uuid.uuid4()),
            name="Partial Settings",
            is_default=False,
            collection_type="user_collection",
            embedding_model="some-model",
            embedding_model_type=None,
        )
        mock_db_session.add(coll)
        mock_db_session.commit()

        # Should not raise AttributeError on .value
        get_rag_service("alice", collection_id=coll.id)

    def test_normalize_vectors_false_propagated(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """normalize_vectors=False should propagate to the service."""
        coll = Collection(
            id=str(uuid.uuid4()),
            name="No Normalize",
            is_default=False,
            collection_type="user_collection",
            embedding_model="test-model",
            embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
            normalize_vectors=False,
        )
        mock_db_session.add(coll)
        mock_db_session.commit()

        get_rag_service("alice", collection_id=coll.id)

        kwargs = mock_rag_cls.call_args.kwargs
        assert kwargs["normalize_vectors"] is False

    def test_normalize_vectors_none_uses_default(
        self, patch_settings, mock_db_session, mock_rag_cls
    ):
        """normalize_vectors=None should fall back to default setting."""
        coll = Collection(
            id=str(uuid.uuid4()),
            name="None Normalize",
            is_default=False,
            collection_type="user_collection",
            embedding_model="test-model",
            embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
            normalize_vectors=None,
        )
        mock_db_session.add(coll)
        mock_db_session.commit()

        get_rag_service("alice", collection_id=coll.id)

        kwargs = mock_rag_cls.call_args.kwargs
        # Default from mock_settings.get_bool_setting is True
        assert kwargs["normalize_vectors"] is True
