"""
Tests for search cache extended functionality.

Tests cover:
- Stampede protection
- Cache edge cases
"""

import threading
import time


class TestStampedeProtectionExtended:
    """Tests for cache stampede protection."""

    def test_stampede_double_check_locking(self):
        """Double-check locking prevents duplicate fetches."""
        cache = {}
        lock = threading.Lock()
        fetch_count = {"count": 0}

        def get_or_fetch(key):
            if key in cache:
                return cache[key]

            with lock:
                # Double check inside lock
                if key in cache:
                    return cache[key]

                fetch_count["count"] += 1
                cache[key] = f"value_{key}"
                return cache[key]

        # Simulate concurrent access
        threads = [
            threading.Thread(target=get_or_fetch, args=("key1",))
            for _ in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Should only fetch once
        assert fetch_count["count"] == 1

    def test_stampede_event_signaling(self):
        """Event signaling coordinates waiting threads."""
        fetch_events = {}
        cache = {}

        def get_with_event(key):
            if key in cache:
                return cache[key]

            if key not in fetch_events:
                fetch_events[key] = threading.Event()
                # Simulate fetch
                time.sleep(0.01)
                cache[key] = "fetched_value"
                fetch_events[key].set()
            else:
                # Wait for fetch to complete
                fetch_events[key].wait(timeout=1.0)

            return cache.get(key)

        result = get_with_event("test_key")

        assert result == "fetched_value"

    def test_stampede_timeout_30s(self):
        """Timeout after 30 seconds of waiting."""

        event = threading.Event()
        start = time.time()

        # Simulate waiting with short timeout for test
        event.wait(timeout=0.01)
        time.time() - start

        # In real code, would check if elapsed > timeout_seconds
        timed_out = not event.is_set()

        assert timed_out

    def test_stampede_stale_event_detection(self):
        """Stale events are detected and cleaned up."""
        events = {
            "key1": {"event": threading.Event(), "created": time.time() - 60},
            "key2": {"event": threading.Event(), "created": time.time() - 10},
        }
        stale_threshold = 30

        stale_keys = [
            k
            for k, v in events.items()
            if time.time() - v["created"] > stale_threshold
        ]

        assert "key1" in stale_keys
        assert "key2" not in stale_keys

    def test_stampede_cleanup_thread_timing(self):
        """Cleanup thread runs periodically."""
        cleanup_interval = 60
        last_cleanup = time.time() - 70

        should_cleanup = time.time() - last_cleanup > cleanup_interval

        assert should_cleanup

    def test_stampede_cleanup_conflicts(self):
        """Cleanup doesn't conflict with active fetches."""
        active_fetches = {"key1", "key2"}
        stale_keys = {"key1", "key3"}

        # Only clean keys not actively being fetched
        safe_to_clean = stale_keys - active_fetches

        assert "key3" in safe_to_clean
        assert "key1" not in safe_to_clean

    def test_stampede_race_condition_window(self):
        """Race condition window is minimized."""
        cache = {}
        lock = threading.RLock()  # Reentrant lock
        race_detected = {"value": False}

        def safe_update(key, value):
            with lock:
                if key in cache:
                    race_detected["value"] = True
                cache[key] = value

        # Simulate concurrent updates
        t1 = threading.Thread(target=safe_update, args=("key", "value1"))
        t2 = threading.Thread(target=safe_update, args=("key", "value2"))

        t1.start()
        t2.start()
        t1.join()
        t2.join()

        # One value should win
        assert cache["key"] in ["value1", "value2"]

    def test_stampede_concurrent_fetches_same_query(self):
        """Concurrent fetches for same query are coalesced."""
        fetch_results = {}
        fetch_lock = threading.Lock()
        in_progress = {}

        def fetch_coalesced(query):
            with fetch_lock:
                if query in in_progress:
                    # Wait for in-progress fetch
                    return in_progress[query]["result"]

                in_progress[query] = {"result": None}

            # Simulate fetch
            result = f"result_{query}"

            with fetch_lock:
                in_progress[query]["result"] = result
                fetch_results[query] = result

            return result

        result = fetch_coalesced("test_query")

        assert result == "result_test_query"

    def test_stampede_fetch_result_propagation(self):
        """Fetch result is propagated to all waiting threads."""
        result_ready = threading.Event()
        shared_result = {"value": None}
        received_results = []

        def wait_for_result():
            result_ready.wait(timeout=1.0)
            received_results.append(shared_result["value"])

        # Start waiting threads
        threads = [threading.Thread(target=wait_for_result) for _ in range(5)]
        for t in threads:
            t.start()

        # Simulate fetch completion
        time.sleep(0.01)
        shared_result["value"] = "fetched_data"
        result_ready.set()

        for t in threads:
            t.join()

        assert all(r == "fetched_data" for r in received_results)

    def test_stampede_error_in_fetch_func(self):
        """Error in fetch function is handled."""
        error_occurred = {"value": False}

        def fetch_with_error():
            raise ConnectionError("Fetch failed")

        try:
            fetch_with_error()
        except ConnectionError:
            error_occurred["value"] = True

        assert error_occurred["value"]


class TestCacheEdgeCases:
    """Tests for cache edge cases."""

    def test_cache_memory_pressure_eviction(self):
        """Items are evicted under memory pressure."""
        max_items = 100
        cache = {}

        # Fill cache beyond capacity
        for i in range(150):
            cache[f"key_{i}"] = f"value_{i}"
            if len(cache) > max_items:
                # Evict oldest
                oldest_key = next(iter(cache))
                del cache[oldest_key]

        assert len(cache) == max_items

    def test_cache_ttl_boundary_conditions(self):
        """TTL boundary conditions are handled."""
        ttl_seconds = 300
        current_time = time.time()

        entries = [
            {"key": "expired", "created": current_time - 301},
            {"key": "valid", "created": current_time - 299},
            {"key": "exact", "created": current_time - 300},
        ]

        valid_entries = [
            e for e in entries if current_time - e["created"] < ttl_seconds
        ]

        assert len(valid_entries) == 1
        assert valid_entries[0]["key"] == "valid"

    def test_cache_unicode_query_normalization(self):
        """Unicode queries are normalized."""
        queries = [
            "café",
            "cafe\u0301",  # e + combining acute accent
        ]

        import unicodedata

        normalized = [unicodedata.normalize("NFC", q) for q in queries]

        # After normalization, they should be comparable
        assert len(normalized) == 2

    def test_cache_very_long_query(self):
        """Very long queries are handled."""
        max_query_length = 1000
        long_query = "x" * 2000

        if len(long_query) > max_query_length:
            truncated = long_query[:max_query_length]
        else:
            truncated = long_query

        assert len(truncated) == max_query_length

    def test_cache_concurrent_invalidation(self):
        """Concurrent cache invalidation is safe."""
        cache = {"key1": "value1", "key2": "value2", "key3": "value3"}
        lock = threading.Lock()
        invalidated = []

        def invalidate(key):
            with lock:
                if key in cache:
                    del cache[key]
                    invalidated.append(key)

        threads = [
            threading.Thread(target=invalidate, args=(f"key{i}",))
            for i in range(1, 4)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(cache) == 0
        assert len(invalidated) == 3


class TestCacheMetrics:
    """Tests for cache metrics."""

    def test_cache_hit_rate_calculation(self):
        """Cache hit rate is calculated correctly."""
        hits = 80
        misses = 20
        total = hits + misses

        hit_rate = hits / total * 100

        assert hit_rate == 80.0

    def test_cache_miss_rate_calculation(self):
        """Cache miss rate is calculated correctly."""
        hits = 75
        misses = 25
        total = hits + misses

        miss_rate = misses / total * 100

        assert miss_rate == 25.0

    def test_cache_size_tracking(self):
        """Cache size is tracked."""
        cache = {}

        for i in range(10):
            cache[f"key_{i}"] = f"value_{i}"

        size = len(cache)

        assert size == 10

    def test_cache_eviction_count(self):
        """Eviction count is tracked."""
        eviction_count = 0
        max_size = 5
        cache = {}

        for i in range(10):
            if len(cache) >= max_size:
                oldest = next(iter(cache))
                del cache[oldest]
                eviction_count += 1
            cache[f"key_{i}"] = f"value_{i}"

        assert eviction_count == 5

    def test_cache_average_entry_age(self):
        """Average entry age is calculated."""
        current_time = time.time()
        entries = [
            {"created": current_time - 60},
            {"created": current_time - 120},
            {"created": current_time - 180},
        ]

        ages = [current_time - e["created"] for e in entries]
        avg_age = sum(ages) / len(ages)

        assert avg_age == 120.0


class TestCacheKeyGeneration:
    """Tests for cache key generation."""

    def test_cache_key_from_query(self):
        """Cache key is generated from query."""
        query = "test search query"

        import hashlib

        key = hashlib.md5(query.encode()).hexdigest()  # noqa: E501  # DevSkim: ignore DS126858

        assert len(key) == 32

    def test_cache_key_includes_engine(self):
        """Cache key includes search engine."""
        query = "test query"
        engine = "google"

        combined = f"{engine}:{query}"
        import hashlib

        key = hashlib.md5(combined.encode()).hexdigest()  # noqa: E501  # DevSkim: ignore DS126858

        assert len(key) == 32

    def test_cache_key_case_sensitivity(self):
        """Cache keys are case-normalized."""
        query1 = "Test Query"
        query2 = "test query"

        normalized1 = query1.lower()
        normalized2 = query2.lower()

        assert normalized1 == normalized2

    def test_cache_key_whitespace_handling(self):
        """Cache keys normalize whitespace."""
        query = "  test   query  "

        normalized = " ".join(query.split())

        assert normalized == "test query"

    def test_cache_key_special_characters(self):
        """Cache keys handle special characters."""
        query = "test@query#with$special%chars"

        import hashlib

        key = hashlib.md5(query.encode()).hexdigest()  # noqa: E501  # DevSkim: ignore DS126858

        assert len(key) == 32
