"""
Comprehensive tests for search_cache module.
Tests caching, TTL, LRU eviction, stampede protection, and persistence.
"""

import pytest
import tempfile
import time
import threading
from unittest.mock import patch
from pathlib import Path


class TestSearchCacheInit:
    """Tests for SearchCache initialization."""

    def test_init_with_defaults(self):
        """Test initialization with default parameters."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            assert cache.max_memory_items == 1000
            assert cache.default_ttl == 3600

    def test_init_with_custom_params(self):
        """Test initialization with custom parameters."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(
                cache_dir=tmpdir,
                max_memory_items=500,
                default_ttl=7200,
            )

            assert cache.max_memory_items == 500
            assert cache.default_ttl == 7200

    def test_creates_cache_directory(self):
        """Test that cache directory is created."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache_dir = Path(tmpdir) / "new_cache"
            SearchCache(cache_dir=str(cache_dir))

            assert cache_dir.exists()

    def test_creates_database_file(self):
        """Test that database file is created."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            assert cache.db_path.exists()

    def test_initializes_empty_memory_cache(self):
        """Test that memory cache starts empty."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            assert cache._memory_cache == {}
            assert cache._access_times == {}


class TestSearchCacheNormalization:
    """Tests for query normalization."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_normalizes_to_lowercase(self, cache):
        """Test that queries are lowercased."""
        result = cache._normalize_query("HELLO WORLD")

        assert result == "hello world"

    def test_removes_extra_whitespace(self, cache):
        """Test that extra whitespace is removed."""
        result = cache._normalize_query("hello   world")

        assert result == "hello world"

    def test_strips_leading_trailing_whitespace(self, cache):
        """Test that leading/trailing whitespace is stripped."""
        result = cache._normalize_query("  hello world  ")

        assert result == "hello world"

    def test_removes_quotes(self, cache):
        """Test that quotes are removed."""
        result = cache._normalize_query("\"hello\" 'world'")

        assert result == "hello world"


class TestSearchCacheHashing:
    """Tests for query hashing."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_same_query_same_hash(self, cache):
        """Test that same query produces same hash."""
        hash1 = cache._get_query_hash("test query")
        hash2 = cache._get_query_hash("test query")

        assert hash1 == hash2

    def test_different_queries_different_hashes(self, cache):
        """Test that different queries produce different hashes."""
        hash1 = cache._get_query_hash("query one")
        hash2 = cache._get_query_hash("query two")

        assert hash1 != hash2

    def test_normalized_queries_same_hash(self, cache):
        """Test that normalized equivalent queries have same hash."""
        hash1 = cache._get_query_hash("HELLO  WORLD")
        hash2 = cache._get_query_hash("hello world")

        assert hash1 == hash2

    def test_different_engines_different_hashes(self, cache):
        """Test that different search engines produce different hashes."""
        hash1 = cache._get_query_hash("test", search_engine="google")
        hash2 = cache._get_query_hash("test", search_engine="bing")

        assert hash1 != hash2


class TestSearchCachePutGet:
    """Tests for put and get operations."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_put_stores_results(self, cache):
        """Test that put stores results."""
        results = [{"title": "Test", "url": "https://test.com"}]

        success = cache.put("test query", results)

        assert success is True

    def test_get_retrieves_stored_results(self, cache):
        """Test that get retrieves stored results."""
        results = [{"title": "Test", "url": "https://test.com"}]
        cache.put("test query", results)

        retrieved = cache.get("test query")

        assert retrieved == results

    def test_get_returns_none_for_missing(self, cache):
        """Test that get returns None for missing queries."""
        result = cache.get("nonexistent query")

        assert result is None

    def test_put_rejects_empty_results(self, cache):
        """Test that empty results are not cached."""
        success = cache.put("test query", [])

        assert success is False

    def test_put_updates_existing_entry(self, cache):
        """Test that put updates existing entry."""
        results1 = [{"title": "First"}]
        results2 = [{"title": "Second"}]

        cache.put("test query", results1)
        cache.put("test query", results2)

        retrieved = cache.get("test query")
        assert retrieved == results2

    def test_get_uses_memory_cache_first(self, cache):
        """Test that get checks memory cache first."""
        results = [{"title": "Test"}]
        cache.put("test query", results)

        # Second get should use memory cache
        with patch.object(cache, "Session") as mock_session:
            retrieved = cache.get("test query")

            # Should not access database
            mock_session.assert_not_called()
            assert retrieved == results

    def test_custom_ttl(self, cache):
        """Test that custom TTL is respected."""
        results = [{"title": "Test"}]

        # Use short TTL of 2 seconds
        cache.put("test query", results, ttl=2)

        # Should be available immediately
        retrieved = cache.get("test query")
        assert retrieved is not None, (
            "Cache should be available immediately after put"
        )

        # Wait for expiration
        time.sleep(2.5)  # allow: unmarked-sleep

        # Clear memory cache to force DB check
        cache._memory_cache.clear()

        # Should be expired
        assert cache.get("test query") is None


class TestSearchCacheExpiration:
    """Tests for cache expiration."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance with short TTL."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir, default_ttl=1)

    def test_expired_entries_not_returned(self, cache):
        """Test that expired entries are not returned."""
        results = [{"title": "Test"}]
        cache.put("test query", results)

        # Wait for expiration
        time.sleep(1.5)  # allow: unmarked-sleep

        # Clear memory cache to force DB check
        cache._memory_cache.clear()

        result = cache.get("test query")
        assert result is None

    def test_expired_memory_entries_removed(self, cache):
        """Test that expired memory entries are removed."""
        results = [{"title": "Test"}]
        cache.put("test query", results)

        # Wait for expiration
        time.sleep(1.5)  # allow: unmarked-sleep

        # Access should remove expired entry from memory
        cache.get("test query")

        query_hash = cache._get_query_hash("test query")
        assert query_hash not in cache._memory_cache


class TestSearchCacheLRUEviction:
    """Tests for LRU memory eviction."""

    def test_evicts_when_over_limit(self):
        """Test that LRU eviction occurs when over limit."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir, max_memory_items=5)

            # Add more items than limit
            for i in range(10):
                results = [{"title": f"Result {i}"}]
                cache.put(f"query {i}", results)

            # Memory cache should be limited
            assert (
                len(cache._memory_cache) <= 5 + 100
            )  # +100 for efficiency buffer


class TestSearchCacheInvalidate:
    """Tests for cache invalidation."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_invalidate_removes_entry(self, cache):
        """Test that invalidate removes entry."""
        results = [{"title": "Test"}]
        cache.put("test query", results)

        cache.invalidate("test query")

        assert cache.get("test query") is None

    def test_invalidate_removes_from_memory(self, cache):
        """Test that invalidate removes from memory cache."""
        results = [{"title": "Test"}]
        cache.put("test query", results)

        cache.invalidate("test query")

        query_hash = cache._get_query_hash("test query")
        assert query_hash not in cache._memory_cache

    def test_invalidate_returns_true_if_found(self, cache):
        """Test that invalidate returns True if entry found."""
        results = [{"title": "Test"}]
        cache.put("test query", results)

        result = cache.invalidate("test query")

        assert result is True

    def test_invalidate_returns_false_if_not_found(self, cache):
        """Test that invalidate returns False if entry not found."""
        result = cache.invalidate("nonexistent query")

        assert result is False


class TestSearchCacheClearAll:
    """Tests for clear_all operation."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_clear_all_removes_all_entries(self, cache):
        """Test that clear_all removes all entries."""
        cache.put("query 1", [{"title": "1"}])
        cache.put("query 2", [{"title": "2"}])

        cache.clear_all()

        assert cache.get("query 1") is None
        assert cache.get("query 2") is None

    def test_clear_all_empties_memory_cache(self, cache):
        """Test that clear_all empties memory cache."""
        cache.put("test query", [{"title": "Test"}])

        cache.clear_all()

        assert cache._memory_cache == {}
        assert cache._access_times == {}

    def test_clear_all_returns_true(self, cache):
        """Test that clear_all returns True on success."""
        result = cache.clear_all()

        assert result is True


class TestSearchCacheStats:
    """Tests for cache statistics."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_stats_returns_dict(self, cache):
        """Test that stats returns a dictionary."""
        stats = cache.get_stats()

        assert isinstance(stats, dict)

    def test_stats_includes_required_keys(self, cache):
        """Test that stats includes required keys."""
        stats = cache.get_stats()

        assert "total_valid_entries" in stats
        assert "memory_cache_size" in stats

    def test_stats_reflects_cache_state(self, cache):
        """Test that stats reflect actual cache state."""
        cache.put("test query", [{"title": "Test"}])

        stats = cache.get_stats()

        assert stats["total_valid_entries"] >= 1
        assert stats["memory_cache_size"] >= 1


class TestSearchCacheGetOrFetch:
    """Tests for get_or_fetch with stampede protection."""

    @pytest.fixture
    def cache(self):
        """Create a SearchCache instance."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            yield SearchCache(cache_dir=tmpdir)

    def test_returns_cached_if_available(self, cache):
        """Test that cached results are returned without fetching."""
        results = [{"title": "Cached"}]
        cache.put("test query", results)

        fetch_called = []

        def fetch_func():
            fetch_called.append(True)
            return [{"title": "Fetched"}]

        result = cache.get_or_fetch("test query", fetch_func)

        assert result == results
        assert len(fetch_called) == 0

    def test_calls_fetch_func_on_miss(self, cache):
        """Test that fetch_func is called on cache miss."""
        expected = [{"title": "Fetched"}]

        def fetch_func():
            return expected

        result = cache.get_or_fetch("test query", fetch_func)

        assert result == expected

    def test_caches_fetched_results(self, cache):
        """Test that fetched results are cached."""
        expected = [{"title": "Fetched"}]

        def fetch_func():
            return expected

        cache.get_or_fetch("test query", fetch_func)

        # Second call should use cache
        cached = cache.get("test query")
        assert cached == expected

    def test_handles_fetch_exception(self, cache):
        """Test that fetch exceptions are handled gracefully."""

        def fetch_func():
            raise Exception("Fetch failed")

        result = cache.get_or_fetch("test query", fetch_func)

        assert result is None

    def test_stampede_protection_single_fetch(self, cache):
        """Test that only one thread fetches during stampede."""
        fetch_count = []

        def slow_fetch():
            fetch_count.append(1)
            time.sleep(0.2)  # allow: unmarked-sleep
            return [{"title": "Result"}]

        threads = []
        results = []

        def worker():
            result = cache.get_or_fetch("same query", slow_fetch)
            results.append(result)

        # Start multiple threads simultaneously
        for _ in range(5):
            t = threading.Thread(target=worker)
            threads.append(t)
            t.start()

        # Wait for all threads
        for t in threads:
            t.join(timeout=5)

        # All threads should get results
        assert len(results) == 5
        # But fetch should only be called once (or very few times due to race)
        assert len(fetch_count) <= 2


class TestGlobalCacheInstance:
    """Tests for global cache singleton."""

    def test_get_search_cache_returns_instance(self):
        """Test that get_search_cache returns a SearchCache instance."""
        from local_deep_research.utilities.search_cache import (
            get_search_cache,
            SearchCache,
        )

        cache = get_search_cache()

        assert isinstance(cache, SearchCache)

    def test_get_search_cache_returns_same_instance(self):
        """Test that get_search_cache returns same instance."""
        from local_deep_research.utilities.search_cache import (
            get_search_cache,
        )

        cache1 = get_search_cache()
        cache2 = get_search_cache()

        assert cache1 is cache2


class TestNormalizeEntityQuery:
    """Tests for normalize_entity_query function."""

    def test_normalizes_entity_and_constraint(self):
        """Test that entity and constraint are normalized."""
        from local_deep_research.utilities.search_cache import (
            normalize_entity_query,
        )

        result = normalize_entity_query("ENTITY NAME", "CONSTRAINT VALUE")

        assert result == "entity name constraint value"

    def test_removes_extra_whitespace(self):
        """Test that extra whitespace is removed."""
        from local_deep_research.utilities.search_cache import (
            normalize_entity_query,
        )

        result = normalize_entity_query("entity  name", "constraint  value")

        assert result == "entity name constraint value"

    def test_strips_values(self):
        """Test that values are stripped."""
        from local_deep_research.utilities.search_cache import (
            normalize_entity_query,
        )

        result = normalize_entity_query("  entity  ", "  constraint  ")

        assert result == "entity constraint"

    def test_caches_results(self):
        """Test that results are cached via lru_cache."""
        from local_deep_research.utilities.search_cache import (
            normalize_entity_query,
        )

        # Clear cache
        normalize_entity_query.cache_clear()

        # First call
        normalize_entity_query("test", "value")

        # Check cache info
        cache_info = normalize_entity_query.cache_info()
        assert cache_info.misses == 1

        # Second call with same args
        normalize_entity_query("test", "value")

        cache_info = normalize_entity_query.cache_info()
        assert cache_info.hits == 1


class TestSearchCacheResourceCleanup:
    """Tests for engine disposal and resource management."""

    def test_dispose_disposes_engine(self):
        """Test that dispose() calls engine.dispose()."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            # Mock the engine
            mock_engine = cache.engine
            original_dispose = mock_engine.dispose

            dispose_called = []

            def track_dispose():
                dispose_called.append(True)
                original_dispose()

            mock_engine.dispose = track_dispose

            cache.dispose()

            assert len(dispose_called) == 1

    def test_dispose_sets_engine_to_none(self):
        """Test that dispose() sets engine to None."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            # Verify engine exists
            assert cache.engine is not None

            cache.dispose()

            # Engine should be None after dispose
            assert cache.engine is None

    def test_dispose_handles_no_engine(self):
        """Test that dispose() handles missing engine gracefully."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            # Dispose twice - should not raise
            cache.dispose()
            cache.dispose()

            assert cache.engine is None

    def test_dispose_handles_exception(self):
        """Test that dispose() handles engine.dispose() exceptions."""
        from local_deep_research.utilities.search_cache import SearchCache
        from unittest.mock import Mock

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            # Mock engine to raise on dispose
            mock_engine = Mock()
            mock_engine.dispose.side_effect = Exception("Dispose failed")
            cache.engine = mock_engine

            # Should not raise, just log the exception
            cache.dispose()

            # Engine should be set to None even after exception
            assert cache.engine is None

    def test_del_calls_dispose(self):
        """Test that __del__ calls dispose()."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            with patch.object(cache, "dispose") as mock_dispose:
                cache.__del__()
                mock_dispose.assert_called_once()


class TestSearchCacheEdgeCases:
    """Tests for edge cases and error handling."""

    def test_handles_special_characters_in_query(self):
        """Test handling of special characters in queries."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            results = [{"title": "Test"}]
            query = "test & query with <special> 'chars'"

            cache.put(query, results)
            retrieved = cache.get(query)

            assert retrieved == results

    def test_handles_unicode_queries(self):
        """Test handling of unicode queries."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            results = [{"title": "Test"}]
            query = "café naïve 日本語"

            cache.put(query, results)
            retrieved = cache.get(query)

            assert retrieved == results

    def test_handles_very_long_queries(self):
        """Test handling of very long queries."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            results = [{"title": "Test"}]
            query = "x" * 10000

            cache.put(query, results)
            retrieved = cache.get(query)

            assert retrieved == results

    def test_handles_empty_query(self):
        """Test handling of empty query."""
        from local_deep_research.utilities.search_cache import SearchCache

        with tempfile.TemporaryDirectory() as tmpdir:
            cache = SearchCache(cache_dir=tmpdir)

            results = [{"title": "Test"}]

            cache.put("", results)
            retrieved = cache.get("")

            assert retrieved == results
