"""
Comprehensive tests for LocalEmbeddingManager.

Covers:
- close() resource cleanup and idempotency
- Context manager (__enter__ / __exit__)
- Lazy initialization via the embeddings property
- _store_chunks_to_db() including deduplication and edge cases
- _delete_chunks_from_db()
- Provider selection logic in _initialize_embeddings()
"""

import hashlib
import threading
from contextlib import contextmanager
from unittest.mock import MagicMock, Mock, patch

import pytest
from langchain_core.documents import Document

from local_deep_research.web_search_engines.engines.local_embedding_manager import (
    LocalEmbeddingManager,
)


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


@pytest.fixture
def manager():
    """Return a vanilla manager with no side-effects."""
    return LocalEmbeddingManager()


@pytest.fixture
def manager_with_user():
    """Return a manager that has a username set (needed for DB operations)."""
    return LocalEmbeddingManager(
        settings_snapshot={"_username": "testuser"},
    )


# ---------------------------------------------------------------------------
# close() – resource cleanup
# ---------------------------------------------------------------------------


class TestClose:
    def test_close_sets_closed_flag(self, manager):
        assert manager._closed is False
        manager.close()
        assert manager._closed is True

    def test_close_clears_embeddings(self, manager):
        manager._embeddings = Mock()
        manager.close()
        assert manager._embeddings is None

    def test_close_clears_vector_stores(self, manager):
        manager.vector_stores["col1"] = Mock()
        manager.close()
        assert manager.vector_stores == {}

    def test_close_is_idempotent(self, manager):
        """Calling close() multiple times must not raise."""
        manager._embeddings = Mock()
        manager.close()
        manager.close()  # second call is a no-op
        assert manager._closed is True

    def test_close_idempotent_does_not_re_clear(self, manager):
        """After a second close(), attributes stay cleared."""
        manager._embeddings = Mock()
        manager.close()
        # Manually set something after first close to prove second is no-op
        manager._embeddings = "sentinel"
        manager.close()
        # _embeddings should still be "sentinel" because close() returned early
        assert manager._embeddings == "sentinel"


# ---------------------------------------------------------------------------
# Context manager
# ---------------------------------------------------------------------------


class TestContextManager:
    def test_enter_returns_self(self, manager):
        result = manager.__enter__()
        assert result is manager

    def test_exit_calls_close(self, manager):
        with patch.object(manager, "close") as mock_close:
            manager.__exit__(None, None, None)
            mock_close.assert_called_once()

    def test_exit_returns_false(self, manager):
        """__exit__ must not suppress exceptions."""
        assert manager.__exit__(None, None, None) is False

    def test_with_statement_closes(self):
        with LocalEmbeddingManager() as mgr:
            assert mgr._closed is False
        assert mgr._closed is True

    def test_with_statement_closes_on_exception(self):
        with pytest.raises(RuntimeError):
            with LocalEmbeddingManager() as mgr:
                raise RuntimeError("boom")
        assert mgr._closed is True


# ---------------------------------------------------------------------------
# Lazy initialization – embeddings property
# ---------------------------------------------------------------------------


class TestEmbeddingsProperty:
    def test_lazy_init_called_once(self, manager):
        mock_emb = Mock()
        with patch.object(
            manager, "_initialize_embeddings", return_value=mock_emb
        ) as mock_init:
            emb1 = manager.embeddings
            emb2 = manager.embeddings
            mock_init.assert_called_once()
            assert emb1 is emb2 is mock_emb

    def test_already_set_embeddings_not_reinit(self, manager):
        sentinel = object()
        manager._embeddings = sentinel
        with patch.object(manager, "_initialize_embeddings") as mock_init:
            assert manager.embeddings is sentinel
            mock_init.assert_not_called()

    def test_thread_safety_single_init(self):
        """Multiple threads must trigger only one initialization."""
        mgr = LocalEmbeddingManager()
        init_count = 0
        lock = threading.Lock()
        mock_emb = Mock()

        def counting_init():
            nonlocal init_count
            with lock:
                init_count += 1
            import time

            time.sleep(0.05)
            return mock_emb

        mgr._initialize_embeddings = counting_init

        threads = [
            threading.Thread(target=lambda: mgr.embeddings) for _ in range(8)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert init_count == 1


# ---------------------------------------------------------------------------
# Provider selection – _initialize_embeddings
# ---------------------------------------------------------------------------


class TestInitializeEmbeddings:
    @patch(
        "local_deep_research.web_search_engines.engines.local_embedding_manager.get_embeddings",
        create=True,
    )
    def test_sentence_transformers_passes_device(self, _mock_get):
        """For sentence_transformers, device kwarg must be forwarded."""
        with patch("local_deep_research.embeddings.get_embeddings") as mock_get:
            mock_get.return_value = Mock()
            mgr = LocalEmbeddingManager(
                embedding_model_type="sentence_transformers",
                embedding_device="cuda",
            )
            mgr._initialize_embeddings()
            mock_get.assert_called_once()
            _, kwargs = mock_get.call_args
            assert kwargs["device"] == "cuda"
            assert kwargs["provider"] == "sentence_transformers"

    def test_ollama_passes_base_url(self):
        with patch("local_deep_research.embeddings.get_embeddings") as mock_get:
            mock_get.return_value = Mock()
            mgr = LocalEmbeddingManager(
                embedding_model_type="ollama",
                embedding_model="nomic-embed-text",
                ollama_base_url="http://myhost:11434",
            )
            mgr._initialize_embeddings()
            _, kwargs = mock_get.call_args
            assert "base_url" in kwargs
            assert kwargs["provider"] == "ollama"

    def test_ollama_without_base_url_omits_it(self):
        with patch("local_deep_research.embeddings.get_embeddings") as mock_get:
            mock_get.return_value = Mock()
            mgr = LocalEmbeddingManager(
                embedding_model_type="ollama",
                embedding_model="nomic-embed-text",
                ollama_base_url=None,
            )
            mgr._initialize_embeddings()
            _, kwargs = mock_get.call_args
            assert "base_url" not in kwargs

    def test_fallback_only_on_importerror(self):
        """Fallback ONLY on ImportError — other exceptions (RuntimeError,
        ConnectionError, PolicyDeniedError) must propagate so we don't
        silently fetch from huggingface.co when the user has opted into
        local embeddings. The fallback itself routes through the GATED
        get_embeddings(sentence_transformers) path (not a raw
        HuggingFaceEmbeddings construction), so PRIVATE_ONLY /
        embeddings.require_local still blocks an uncached HF download.
        See plan Wave D + the egress-policy review.
        """
        sentinel = Mock()
        calls = []

        def fake_get_embeddings(provider=None, **kwargs):
            calls.append(provider)
            # First call (configured provider) fails with ImportError; the
            # fallback re-invokes for sentence_transformers, which succeeds.
            if len(calls) == 1:
                raise ImportError("missing provider deps")
            return sentinel

        with patch(
            "local_deep_research.embeddings.get_embeddings",
            side_effect=fake_get_embeddings,
        ):
            mgr = LocalEmbeddingManager()
            result = mgr._initialize_embeddings()

        # Routed through the gated provider, not a raw HF build.
        assert calls[-1] == "sentence_transformers"
        assert result is sentinel

    def test_fallback_propagates_policy_denial(self):
        """A PolicyDeniedError from the gated SBERT fallback must propagate —
        the fallback must NOT silently download from huggingface.co under
        PRIVATE_ONLY / embeddings.require_local.
        """
        from local_deep_research.security.egress.policy import (
            Decision,
            PolicyDeniedError,
        )

        def fake_get_embeddings(provider=None, **kwargs):
            if provider == "sentence_transformers":
                raise PolicyDeniedError(
                    Decision(False, "embeddings_model_not_cached"),
                    target="all-MiniLM-L6-v2",
                )
            raise ImportError("missing provider deps")

        with patch(
            "local_deep_research.embeddings.get_embeddings",
            side_effect=fake_get_embeddings,
        ):
            mgr = LocalEmbeddingManager()
            with pytest.raises(PolicyDeniedError):
                mgr._initialize_embeddings()

    def test_runtime_error_propagates(self):
        """RuntimeError must propagate — no silent HF fetch, no fallback."""
        calls = []

        def fake_get_embeddings(provider=None, **kwargs):
            calls.append(provider)
            raise RuntimeError("boom")

        with patch(
            "local_deep_research.embeddings.get_embeddings",
            side_effect=fake_get_embeddings,
        ):
            mgr = LocalEmbeddingManager()
            with pytest.raises(RuntimeError, match="boom"):
                mgr._initialize_embeddings()
            # No fallback attempt: only the original configured-provider call.
            assert len(calls) == 1


# ---------------------------------------------------------------------------
# _store_chunks_to_db
# ---------------------------------------------------------------------------


def _make_doc(text, metadata=None):
    return Document(page_content=text, metadata=metadata or {})


class TestStoreChunksToDb:
    def test_no_username_returns_empty(self, manager):
        """Without a username the method should bail out early."""
        result = manager._store_chunks_to_db(
            [_make_doc("hello")], "collection_abc"
        )
        assert result == []

    def test_stores_new_chunks(self, manager_with_user):
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=fake_session,
        ):
            docs = [_make_doc("chunk one"), _make_doc("chunk two")]
            ids = manager_with_user._store_chunks_to_db(docs, "col1")

        assert len(ids) == 2
        assert mock_session.add.call_count == 2
        mock_session.commit.assert_called_once()

    def test_deduplication_reuses_existing_chunk(self, manager_with_user):
        existing = MagicMock()
        existing.embedding_id = "existing-id-123"
        existing.last_accessed = None

        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = existing

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=fake_session,
        ):
            ids = manager_with_user._store_chunks_to_db(
                [_make_doc("duplicate text")], "col1"
            )

        assert ids == ["existing-id-123"]
        # Should NOT add a new row
        mock_session.add.assert_not_called()
        mock_session.commit.assert_called_once()
        # last_accessed should be updated
        assert existing.last_accessed is not None

    def test_mixed_new_and_existing(self, manager_with_user):
        existing = MagicMock()
        existing.embedding_id = "old-id"

        call_count = 0

        def side_effect(**kwargs):
            nonlocal call_count
            call_count += 1
            mock_filter = MagicMock()
            if call_count == 1:
                mock_filter.first.return_value = existing
            else:
                mock_filter.first.return_value = None
            return mock_filter

        mock_session = MagicMock()
        mock_session.query.return_value.filter_by = side_effect

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=fake_session,
        ):
            docs = [_make_doc("existing text"), _make_doc("new text")]
            ids = manager_with_user._store_chunks_to_db(docs, "col1")

        assert len(ids) == 2
        assert ids[0] == "old-id"
        assert mock_session.add.call_count == 1

    def test_metadata_extraction(self, manager_with_user):
        """Chunk metadata (filename, start_char, end_char) is forwarded."""
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        added_objects = []
        mock_session.add.side_effect = lambda obj: added_objects.append(obj)

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with (
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.DocumentChunk"
            ) as MockChunk,
        ):
            MockChunk.side_effect = lambda **kw: MagicMock(**kw)
            meta = {"filename": "notes.txt", "start_char": 10, "end_char": 50}
            manager_with_user._store_chunks_to_db(
                [_make_doc("text", metadata=meta)],
                "col1",
                source_path="/tmp/notes.txt",
                source_id=42,
                source_type="local_file",
            )
            _, kwargs = MockChunk.call_args
            assert kwargs["document_title"] == "notes.txt"
            assert kwargs["start_char"] == 10
            assert kwargs["end_char"] == 50
            assert kwargs["source_path"] == "/tmp/notes.txt"
            assert kwargs["source_id"] == 42

    def test_title_falls_back_to_title_key(self, manager_with_user):
        """If 'filename' missing, document_title falls back to 'title'."""
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with (
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.DocumentChunk"
            ) as MockChunk,
        ):
            MockChunk.side_effect = lambda **kw: MagicMock(**kw)
            meta = {"title": "My Paper"}
            manager_with_user._store_chunks_to_db(
                [_make_doc("text", metadata=meta)], "col1"
            )
            _, kwargs = MockChunk.call_args
            assert kwargs["document_title"] == "My Paper"

    def test_title_defaults_to_unknown(self, manager_with_user):
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with (
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.DocumentChunk"
            ) as MockChunk,
        ):
            MockChunk.side_effect = lambda **kw: MagicMock(**kw)
            manager_with_user._store_chunks_to_db([_make_doc("text")], "col1")
            _, kwargs = MockChunk.call_args
            assert kwargs["document_title"] == "Unknown"

    def test_db_exception_returns_empty(self, manager_with_user):
        """If the DB session raises, return []."""

        @contextmanager
        def exploding_session(u, p):
            raise RuntimeError("DB down")
            yield  # noqa: unreachable

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=exploding_session,
        ):
            result = manager_with_user._store_chunks_to_db(
                [_make_doc("text")], "col1"
            )
        assert result == []

    def test_empty_chunks_list(self, manager_with_user):
        mock_session = MagicMock()

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=fake_session,
        ):
            ids = manager_with_user._store_chunks_to_db([], "col1")

        assert ids == []
        mock_session.add.assert_not_called()
        mock_session.commit.assert_called_once()

    def test_chunk_hash_uses_sha256(self, manager_with_user):
        """Verify the chunk hash is computed as SHA256 of page_content."""
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        filter_by_calls = []
        original_filter_by = mock_session.query.return_value.filter_by

        def capture_filter_by(**kwargs):
            filter_by_calls.append(kwargs)
            return original_filter_by(**kwargs)

        mock_session.query.return_value.filter_by = capture_filter_by

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        text = "hello world"
        expected_hash = hashlib.sha256(text.encode()).hexdigest()

        with (
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.DocumentChunk"
            ) as MockChunk,
        ):
            MockChunk.side_effect = lambda **kw: MagicMock(**kw)
            manager_with_user._store_chunks_to_db([_make_doc(text)], "col1")

        assert any(
            c.get("chunk_hash") == expected_hash for c in filter_by_calls
        )

    def test_word_count_calculation(self, manager_with_user):
        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with (
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                "local_deep_research.web_search_engines.engines.local_embedding_manager.DocumentChunk"
            ) as MockChunk,
        ):
            MockChunk.side_effect = lambda **kw: MagicMock(**kw)
            manager_with_user._store_chunks_to_db(
                [_make_doc("one two three four")], "col1"
            )
            _, kwargs = MockChunk.call_args
            assert kwargs["word_count"] == 4


# ---------------------------------------------------------------------------
# _delete_chunks_from_db
# ---------------------------------------------------------------------------


class TestDeleteChunksFromDb:
    def test_no_username_returns_zero(self, manager):
        result = manager._delete_chunks_from_db("col1")
        assert result == 0

    def test_delete_by_collection(self, manager_with_user):
        mock_session = MagicMock()
        mock_query = MagicMock()
        mock_session.query.return_value.filter_by.return_value = mock_query
        mock_query.delete.return_value = 5

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=fake_session,
        ):
            count = manager_with_user._delete_chunks_from_db("col1")

        assert count == 5
        mock_session.commit.assert_called_once()

    def test_delete_with_source_path_filter(self, manager_with_user):
        mock_session = MagicMock()
        mock_q1 = MagicMock()
        mock_q2 = MagicMock()
        mock_session.query.return_value.filter_by.return_value = mock_q1
        mock_q1.filter_by.return_value = mock_q2
        mock_q2.delete.return_value = 2

        @contextmanager
        def fake_session(u, p):
            yield mock_session

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=fake_session,
        ):
            count = manager_with_user._delete_chunks_from_db(
                "col1", source_path="/tmp/file.txt"
            )

        assert count == 2

    def test_delete_db_exception_returns_zero(self, manager_with_user):
        @contextmanager
        def exploding_session(u, p):
            raise RuntimeError("DB down")
            yield

        with patch(
            "local_deep_research.web_search_engines.engines.local_embedding_manager.get_user_db_session",
            side_effect=exploding_session,
        ):
            count = manager_with_user._delete_chunks_from_db("col1")

        assert count == 0


# ---------------------------------------------------------------------------
# Init defaults and settings
# ---------------------------------------------------------------------------


class TestInitialization:
    def test_defaults(self):
        mgr = LocalEmbeddingManager()
        assert mgr.embedding_model == "all-MiniLM-L6-v2"
        assert mgr.embedding_device == "cpu"
        assert mgr.embedding_model_type == "sentence_transformers"
        assert mgr.ollama_base_url is None
        assert mgr.settings_snapshot == {}
        assert mgr.username is None
        assert mgr.db_password is None
        assert mgr._closed is False
        assert mgr.vector_stores == {}

    def test_settings_snapshot_none_defaults_to_empty_dict(self):
        mgr = LocalEmbeddingManager(settings_snapshot=None)
        assert mgr.settings_snapshot == {}
        assert mgr.username is None

    def test_username_extracted_from_settings(self):
        mgr = LocalEmbeddingManager(settings_snapshot={"_username": "alice"})
        assert mgr.username == "alice"
