"""
High-value pure-logic tests for search_cache.py.

Focuses on _normalize_query, _get_query_hash, _evict_lru_memory,
and normalize_entity_query with edge cases not covered by existing tests.
Uses init bypass via patch.object to avoid DB/filesystem side effects.
"""

import hashlib

from unittest.mock import patch

from local_deep_research.utilities.thread_context import clear_search_context


# ---------------------------------------------------------------------------
# Helper: build a SearchCache without __init__ side effects
# ---------------------------------------------------------------------------


def _make_cache(max_memory_items=1000, default_ttl=3600):
    """Create a SearchCache instance with init bypassed."""
    from local_deep_research.utilities.search_cache import SearchCache

    with patch.object(SearchCache, "__init__", lambda self, *a, **kw: None):
        cache = SearchCache()
    cache.max_memory_items = max_memory_items
    cache.default_ttl = default_ttl
    cache._memory_cache = {}
    cache._access_times = {}
    return cache


# ===========================================================================
# _normalize_query
# ===========================================================================


class TestNormalizeQueryHighValue:
    """Edge-case and boundary tests for _normalize_query."""

    def test_only_whitespace_returns_empty(self):
        """A string of only spaces/tabs normalizes to empty string."""
        cache = _make_cache()
        assert cache._normalize_query("   \t  \n  ") == ""

    def test_only_quotes_returns_empty(self):
        """A string containing only quotes normalizes to empty."""
        cache = _make_cache()
        assert cache._normalize_query("\"'\"'") == ""

    def test_mixed_case_with_numbers(self):
        """Numbers are preserved while letters are lowered."""
        cache = _make_cache()
        assert (
            cache._normalize_query("Python 3.12 RELEASE")
            == "python 3.12 release"
        )

    def test_multiple_consecutive_quotes_removed(self):
        """Back-to-back quotes are all stripped."""
        cache = _make_cache()
        assert cache._normalize_query("'''hello'''") == "hello"

    def test_newline_and_carriage_return_collapsed(self):
        """CR and LF are treated as whitespace and collapsed."""
        cache = _make_cache()
        assert cache._normalize_query("hello\r\nworld") == "hello world"

    def test_preserves_hyphens_and_underscores(self):
        """Hyphens and underscores are not stripped."""
        cache = _make_cache()
        result = cache._normalize_query("self-driving_cars")
        assert result == "self-driving_cars"

    def test_unicode_lowercasing(self):
        """Non-ASCII characters are lowercased properly."""
        cache = _make_cache()
        assert cache._normalize_query("STRASSE") == "strasse"
        assert cache._normalize_query("CAFE") == "cafe"

    def test_idempotent(self):
        """Normalizing an already-normalized string returns same result."""
        cache = _make_cache()
        first = cache._normalize_query("  HELLO   'world'  ")
        second = cache._normalize_query(first)
        assert first == second

    def test_single_character(self):
        """Single character query works."""
        cache = _make_cache()
        assert cache._normalize_query("A") == "a"

    def test_query_with_embedded_tabs(self):
        """Tabs between words are collapsed to single space."""
        cache = _make_cache()
        assert cache._normalize_query("a\t\tb\t\tc") == "a b c"


# ===========================================================================
# _get_query_hash
# ===========================================================================


class TestGetQueryHashHighValue:
    """Edge-case and determinism tests for _get_query_hash."""

    def setup_method(self):
        """Clear any leaked search context before each test."""
        clear_search_context()

    def test_hash_is_sha256_hex_string(self):
        """Hash output is a 64-char lowercase hex string (SHA-256)."""
        cache = _make_cache()
        result = cache._get_query_hash("test")
        assert len(result) == 64
        assert all(c in "0123456789abcdef" for c in result)

    def test_hash_matches_manual_sha256(self):
        """Hash matches manually computed SHA-256 of 'username:engine:normalized_query'."""
        cache = _make_cache()
        result = cache._get_query_hash("Hello World", search_engine="google")
        # No search context set, so username is empty string
        expected_input = ":google:hello world"
        expected = hashlib.sha256(expected_input.encode()).hexdigest()
        assert result == expected

    def test_default_engine_is_default(self):
        """Default search_engine parameter is 'default'."""
        cache = _make_cache()
        result = cache._get_query_hash("test")
        expected_input = ":default:test"
        expected = hashlib.sha256(expected_input.encode()).hexdigest()
        assert result == expected

    def test_empty_query_hashes_consistently(self):
        """Empty string query produces a valid, consistent hash."""
        cache = _make_cache()
        h1 = cache._get_query_hash("")
        h2 = cache._get_query_hash("")
        assert h1 == h2
        assert len(h1) == 64

    def test_same_query_different_engines_differ(self):
        """Same normalized query with different engines yields different hashes."""
        cache = _make_cache()
        hashes = {
            cache._get_query_hash("test", engine)
            for engine in ["google", "bing", "duckduckgo", "default"]
        }
        assert len(hashes) == 4

    def test_normalization_applied_before_hashing(self):
        """Queries that differ only by case/whitespace/quotes hash equally."""
        cache = _make_cache()
        variants = [
            "Hello World",
            "  hello   world  ",
            "'hello' \"world\"",
            "HELLO WORLD",
        ]
        hashes = {cache._get_query_hash(v) for v in variants}
        assert len(hashes) == 1

    def test_different_users_produce_different_hashes(self):
        """Different usernames in search context yield different cache keys."""
        from local_deep_research.utilities.thread_context import (
            set_search_context,
            clear_search_context,
        )

        cache = _make_cache()

        set_search_context({"username": "alice"})
        hash_alice = cache._get_query_hash("test query", "google")
        clear_search_context()

        set_search_context({"username": "bob"})
        hash_bob = cache._get_query_hash("test query", "google")
        clear_search_context()

        assert hash_alice != hash_bob

    def test_no_context_matches_empty_username(self):
        """No search context produces same hash as empty-string username."""
        from local_deep_research.utilities.thread_context import (
            set_search_context,
            clear_search_context,
        )

        cache = _make_cache()

        # No context set
        clear_search_context()
        hash_no_ctx = cache._get_query_hash("test query")

        # Empty username
        set_search_context({"username": ""})
        hash_empty = cache._get_query_hash("test query")
        clear_search_context()

        assert hash_no_ctx == hash_empty

    def test_same_user_same_query_produces_same_hash(self):
        """Same user + same query always produces the same hash."""
        from local_deep_research.utilities.thread_context import (
            set_search_context,
            clear_search_context,
        )

        cache = _make_cache()
        set_search_context({"username": "alice"})
        h1 = cache._get_query_hash("test query", "google")
        h2 = cache._get_query_hash("test query", "google")
        clear_search_context()

        assert h1 == h2


# ===========================================================================
# _evict_lru_memory
# ===========================================================================


class TestEvictLruMemoryHighValue:
    """Edge-case and boundary tests for LRU eviction logic."""

    def test_no_eviction_when_empty(self):
        """Empty cache triggers no eviction and no errors."""
        cache = _make_cache(max_memory_items=10)
        cache._evict_lru_memory()
        assert len(cache._memory_cache) == 0

    def test_no_eviction_at_exact_limit(self):
        """Cache at exactly max_memory_items does not evict."""
        cache = _make_cache(max_memory_items=3)
        cache._memory_cache = {"a": 1, "b": 2, "c": 3}
        cache._access_times = {"a": 10, "b": 20, "c": 30}
        cache._evict_lru_memory()
        assert len(cache._memory_cache) == 3

    def test_eviction_removes_extra_100_beyond_needed(self):
        """Eviction removes (count - max + 100) items for efficiency."""
        cache = _make_cache(max_memory_items=50)
        for idx in range(60):
            key = f"k{idx}"
            cache._memory_cache[key] = idx
            cache._access_times[key] = idx
        # items_to_remove = 60 - 50 + 100 = 110, but only 60 items exist
        # so all 60 will be attempted for removal (capped by sorted list)
        cache._evict_lru_memory()
        # All items removed since items_to_remove > total
        assert len(cache._memory_cache) == 0

    def test_eviction_preserves_newest_when_buffer_fits(self):
        """When count - max + 100 < count, newest items survive."""
        cache = _make_cache(max_memory_items=200)
        for idx in range(250):
            key = f"item_{idx}"
            cache._memory_cache[key] = {"val": idx}
            cache._access_times[key] = idx  # lower idx = older
        # items_to_remove = 250 - 200 + 100 = 150
        cache._evict_lru_memory()
        assert len(cache._memory_cache) == 100
        # Only items 150..249 should remain
        for idx in range(150, 250):
            assert f"item_{idx}" in cache._memory_cache

    def test_eviction_keeps_access_times_in_sync(self):
        """After eviction, _memory_cache and _access_times have same keys."""
        cache = _make_cache(max_memory_items=5)
        for idx in range(20):
            key = f"k{idx}"
            cache._memory_cache[key] = idx
            cache._access_times[key] = idx * 10
        cache._evict_lru_memory()
        assert set(cache._memory_cache.keys()) == set(
            cache._access_times.keys()
        )

    def test_eviction_with_equal_access_times(self):
        """Eviction still works when all access times are identical."""
        cache = _make_cache(max_memory_items=2)
        for idx in range(5):
            key = f"k{idx}"
            cache._memory_cache[key] = idx
            cache._access_times[key] = 100  # all same timestamp
        cache._evict_lru_memory()
        # items_to_remove = 5 - 2 + 100 = 103, so all removed
        assert len(cache._memory_cache) == 0

    def test_single_item_over_limit(self):
        """max_memory_items=0 with one item triggers eviction."""
        cache = _make_cache(max_memory_items=0)
        cache._memory_cache = {"only": "item"}
        cache._access_times = {"only": 999}
        cache._evict_lru_memory()
        assert len(cache._memory_cache) == 0

    def test_access_times_missing_key_does_not_crash(self):
        """If a memory_cache key is missing from access_times, no crash."""
        cache = _make_cache(max_memory_items=1)
        cache._memory_cache = {"a": 1, "b": 2, "c": 3}
        # Only some keys in access_times -- eviction sorts on access_times
        cache._access_times = {"a": 10, "c": 30}
        # Should not raise; eviction pops from both dicts safely
        cache._evict_lru_memory()
        # Just verify no crash and some items were removed
        assert len(cache._memory_cache) <= 1 or len(cache._memory_cache) == 3


# ===========================================================================
# normalize_entity_query (standalone function)
# ===========================================================================


class TestNormalizeEntityQueryHighValue:
    """Edge-case tests for the standalone normalize_entity_query function."""

    @staticmethod
    def _get_fn():
        from local_deep_research.utilities.search_cache import (
            normalize_entity_query,
        )

        normalize_entity_query.cache_clear()
        return normalize_entity_query

    def test_entity_with_quotes_preserved(self):
        """Unlike _normalize_query, normalize_entity_query does NOT strip quotes."""
        fn = self._get_fn()
        result = fn('"quoted"', "constraint")
        assert (
            '"' in result
        )  # quotes remain because function only splits/strips

    def test_constraint_with_quotes_preserved(self):
        """Quotes in constraint are preserved."""
        fn = self._get_fn()
        result = fn("entity", "'quoted'")
        assert "'" in result

    def test_very_long_inputs(self):
        """Long entity and constraint strings are handled."""
        fn = self._get_fn()
        entity = "word " * 500
        constraint = "term " * 500
        result = fn(entity.strip(), constraint.strip())
        assert "word" in result
        assert "term" in result

    def test_numeric_entity(self):
        """Purely numeric entity is preserved."""
        fn = self._get_fn()
        result = fn("12345", "count")
        assert result == "12345 count"

    def test_entity_and_constraint_separated_by_single_space(self):
        """Output always has exactly one space between entity and constraint."""
        fn = self._get_fn()
        result = fn("abc", "xyz")
        assert result == "abc xyz"
        # No double spaces
        assert "  " not in result

    def test_mixed_whitespace_in_both(self):
        """Tabs, newlines, and multiple spaces all collapse."""
        fn = self._get_fn()
        result = fn("a\t\tb", "c\n\nd")
        assert result == "a b c d"

    def test_cache_distinguishes_swapped_args(self):
        """(entity, constraint) != (constraint, entity) in cache."""
        fn = self._get_fn()
        r1 = fn("alpha", "beta")
        r2 = fn("beta", "alpha")
        assert r1 != r2
        info = fn.cache_info()
        assert info.misses == 2
