"""
Deep behavioral tests for caching patterns.
Tests LRU cache, TTL cache, cache invalidation,
and cache warming logic.
"""

from datetime import datetime, timezone, timedelta
from collections import OrderedDict
import hashlib


# --- LRU cache patterns ---


class TestLRUCache:
    """Tests for LRU cache patterns."""

    def _create_lru_cache(self, max_size=100):
        """Create an LRU cache."""
        return {"max_size": max_size, "cache": OrderedDict()}

    def _get(self, cache_obj, key):
        """Get value from LRU cache."""
        cache = cache_obj["cache"]
        if key in cache:
            # Move to end (most recently used)
            cache.move_to_end(key)
            return cache[key]
        return None

    def _put(self, cache_obj, key, value):
        """Put value in LRU cache."""
        cache = cache_obj["cache"]
        max_size = cache_obj["max_size"]
        if key in cache:
            cache.move_to_end(key)
        cache[key] = value
        while len(cache) > max_size:
            cache.popitem(last=False)

    def test_put_and_get(self):
        cache_obj = self._create_lru_cache()
        self._put(cache_obj, "key1", "value1")
        assert self._get(cache_obj, "key1") == "value1"

    def test_miss_returns_none(self):
        cache_obj = self._create_lru_cache()
        assert self._get(cache_obj, "missing") is None

    def test_evicts_oldest(self):
        cache_obj = self._create_lru_cache(max_size=2)
        self._put(cache_obj, "a", 1)
        self._put(cache_obj, "b", 2)
        self._put(cache_obj, "c", 3)  # Should evict "a"
        assert self._get(cache_obj, "a") is None
        assert self._get(cache_obj, "b") == 2
        assert self._get(cache_obj, "c") == 3

    def test_access_prevents_eviction(self):
        cache_obj = self._create_lru_cache(max_size=2)
        self._put(cache_obj, "a", 1)
        self._put(cache_obj, "b", 2)
        self._get(cache_obj, "a")  # Access "a"
        self._put(cache_obj, "c", 3)  # Should evict "b"
        assert self._get(cache_obj, "a") == 1
        assert self._get(cache_obj, "b") is None

    def test_update_existing(self):
        cache_obj = self._create_lru_cache()
        self._put(cache_obj, "key", "old")
        self._put(cache_obj, "key", "new")
        assert self._get(cache_obj, "key") == "new"


# --- TTL cache patterns ---


class TestTTLCache:
    """Tests for TTL cache patterns."""

    def _create_ttl_cache(self, ttl_seconds=300):
        """Create a TTL cache."""
        return {"ttl_seconds": ttl_seconds, "cache": {}}

    def _get(self, cache_obj, key):
        """Get value from TTL cache."""
        cache = cache_obj["cache"]
        ttl = cache_obj["ttl_seconds"]
        if key not in cache:
            return None
        entry = cache[key]
        if datetime.now(timezone.utc) - entry["timestamp"] > timedelta(
            seconds=ttl
        ):
            del cache[key]
            return None
        return entry["value"]

    def _put(self, cache_obj, key, value):
        """Put value in TTL cache."""
        cache_obj["cache"][key] = {
            "value": value,
            "timestamp": datetime.now(timezone.utc),
        }

    def test_put_and_get(self):
        cache_obj = self._create_ttl_cache()
        self._put(cache_obj, "key1", "value1")
        assert self._get(cache_obj, "key1") == "value1"

    def test_miss_returns_none(self):
        cache_obj = self._create_ttl_cache()
        assert self._get(cache_obj, "missing") is None

    def test_expired_entry_returns_none(self):
        cache_obj = self._create_ttl_cache(ttl_seconds=1)
        cache_obj["cache"]["key"] = {
            "value": "old",
            "timestamp": datetime.now(timezone.utc) - timedelta(seconds=10),
        }
        assert self._get(cache_obj, "key") is None

    def test_fresh_entry_returned(self):
        cache_obj = self._create_ttl_cache(ttl_seconds=300)
        cache_obj["cache"]["key"] = {
            "value": "fresh",
            "timestamp": datetime.now(timezone.utc) - timedelta(seconds=100),
        }
        assert self._get(cache_obj, "key") == "fresh"


class TestTTLCacheCleanup:
    """Tests for TTL cache cleanup patterns."""

    def _cleanup_expired(self, cache_obj):
        """Remove all expired entries."""
        ttl = cache_obj["ttl_seconds"]
        now = datetime.now(timezone.utc)
        expired_keys = [
            key
            for key, entry in cache_obj["cache"].items()
            if now - entry["timestamp"] > timedelta(seconds=ttl)
        ]
        for key in expired_keys:
            del cache_obj["cache"][key]
        return len(expired_keys)

    def test_removes_expired(self):
        cache_obj = {"ttl_seconds": 60, "cache": {}}
        old = datetime.now(timezone.utc) - timedelta(seconds=120)
        fresh = datetime.now(timezone.utc)
        cache_obj["cache"]["old"] = {"value": 1, "timestamp": old}
        cache_obj["cache"]["fresh"] = {"value": 2, "timestamp": fresh}
        removed = self._cleanup_expired(cache_obj)
        assert removed == 1
        assert "old" not in cache_obj["cache"]
        assert "fresh" in cache_obj["cache"]

    def test_returns_count(self):
        cache_obj = {"ttl_seconds": 60, "cache": {}}
        old = datetime.now(timezone.utc) - timedelta(seconds=120)
        for i in range(5):
            cache_obj["cache"][f"key{i}"] = {"value": i, "timestamp": old}
        removed = self._cleanup_expired(cache_obj)
        assert removed == 5


# --- Cache key generation patterns ---


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

    def _generate_key(self, *args, **kwargs):
        """Generate cache key from arguments."""
        parts = [str(a) for a in args]
        parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
        return ":".join(parts)

    def _hash_key(self, key):
        """Hash a cache key."""
        return hashlib.sha256(key.encode()).hexdigest()

    def test_simple_key(self):
        key = self._generate_key("user", "123")
        assert key == "user:123"

    def test_with_kwargs(self):
        key = self._generate_key("search", query="test", page=1)
        assert "query=test" in key
        assert "page=1" in key

    def test_consistent_ordering(self):
        key1 = self._generate_key("search", a=1, b=2)
        key2 = self._generate_key("search", b=2, a=1)
        assert key1 == key2

    def test_hash_produces_fixed_length(self):
        key = self._generate_key("very", "long", "key", "with", "many", "parts")
        hashed = self._hash_key(key)
        assert len(hashed) == 64


class TestCacheKeyNamespacing:
    """Tests for cache key namespacing patterns."""

    def _namespace_key(self, namespace, key):
        """Add namespace prefix to key."""
        return f"{namespace}:{key}"

    def _extract_namespace(self, full_key):
        """Extract namespace from full key."""
        if ":" not in full_key:
            return None
        return full_key.split(":")[0]

    def test_adds_namespace(self):
        key = self._namespace_key("news", "card-123")
        assert key == "news:card-123"

    def test_extracts_namespace(self):
        namespace = self._extract_namespace("news:card-123")
        assert namespace == "news"

    def test_no_namespace(self):
        namespace = self._extract_namespace("simple-key")
        assert namespace is None


# --- Cache invalidation patterns ---


class TestCacheInvalidation:
    """Tests for cache invalidation patterns."""

    def _invalidate(self, cache, key):
        """Invalidate single cache entry."""
        if key in cache:
            del cache[key]
            return True
        return False

    def _invalidate_pattern(self, cache, pattern):
        """Invalidate all keys matching pattern."""
        matching_keys = [k for k in cache.keys() if pattern in k]
        for key in matching_keys:
            del cache[key]
        return len(matching_keys)

    def _invalidate_all(self, cache):
        """Invalidate entire cache."""
        count = len(cache)
        cache.clear()
        return count

    def test_invalidate_removes_entry(self):
        cache = {"key1": "value1", "key2": "value2"}
        self._invalidate(cache, "key1")
        assert "key1" not in cache
        assert "key2" in cache

    def test_invalidate_returns_true_if_found(self):
        cache = {"key": "value"}
        result = self._invalidate(cache, "key")
        assert result is True

    def test_invalidate_returns_false_if_missing(self):
        cache = {}
        result = self._invalidate(cache, "key")
        assert result is False

    def test_invalidate_pattern(self):
        cache = {"user:1:profile": 1, "user:2:profile": 2, "settings": 3}
        removed = self._invalidate_pattern(cache, "user:")
        assert removed == 2
        assert "settings" in cache

    def test_invalidate_all(self):
        cache = {"a": 1, "b": 2, "c": 3}
        removed = self._invalidate_all(cache)
        assert removed == 3
        assert len(cache) == 0


class TestVersionedCacheInvalidation:
    """Tests for versioned cache invalidation patterns."""

    def _create_versioned_cache(self):
        """Create a versioned cache."""
        return {"version": 1, "cache": {}}

    def _get_with_version(self, cache_obj, key):
        """Get value checking version."""
        version = cache_obj["version"]
        cache = cache_obj["cache"]
        if key not in cache:
            return None
        entry = cache[key]
        if entry.get("version") != version:
            return None
        return entry["value"]

    def _put_with_version(self, cache_obj, key, value):
        """Put value with current version."""
        cache_obj["cache"][key] = {
            "value": value,
            "version": cache_obj["version"],
        }

    def _bump_version(self, cache_obj):
        """Increment version to invalidate all."""
        cache_obj["version"] += 1
        return cache_obj["version"]

    def test_get_with_correct_version(self):
        cache_obj = self._create_versioned_cache()
        self._put_with_version(cache_obj, "key", "value")
        assert self._get_with_version(cache_obj, "key") == "value"

    def test_version_bump_invalidates(self):
        cache_obj = self._create_versioned_cache()
        self._put_with_version(cache_obj, "key", "value")
        self._bump_version(cache_obj)
        assert self._get_with_version(cache_obj, "key") is None


# --- Cache warming patterns ---


class TestCacheWarming:
    """Tests for cache warming patterns."""

    def _warm_cache(self, cache, keys, fetch_func):
        """Warm cache with specified keys."""
        warmed = 0
        for key in keys:
            if key not in cache:
                try:
                    value = fetch_func(key)
                    if value is not None:
                        cache[key] = value
                        warmed += 1
                except Exception:
                    pass
        return warmed

    def _get_cold_keys(self, cache, all_keys):
        """Get keys that are not in cache."""
        return [k for k in all_keys if k not in cache]

    def test_warms_missing_keys(self):
        cache = {"existing": "value"}
        keys = ["existing", "new1", "new2"]
        warmed = self._warm_cache(cache, keys, lambda k: f"value-{k}")
        assert warmed == 2
        assert "new1" in cache

    def test_skips_existing(self):
        cache = {"key": "original"}
        self._warm_cache(cache, ["key"], lambda k: "new")
        assert cache["key"] == "original"

    def test_handles_fetch_error(self):
        cache = {}

        def failing_fetch(key):
            raise Exception("Fetch failed")

        warmed = self._warm_cache(cache, ["key"], failing_fetch)
        assert warmed == 0

    def test_get_cold_keys(self):
        cache = {"a": 1, "b": 2}
        cold = self._get_cold_keys(cache, ["a", "b", "c", "d"])
        assert cold == ["c", "d"]


# --- Cache statistics patterns ---


class TestCacheStatistics:
    """Tests for cache statistics patterns."""

    def _create_cache_with_stats(self):
        """Create cache with statistics tracking."""
        return {
            "cache": {},
            "hits": 0,
            "misses": 0,
        }

    def _get_with_stats(self, cache_obj, key):
        """Get value and track statistics."""
        if key in cache_obj["cache"]:
            cache_obj["hits"] += 1
            return cache_obj["cache"][key]
        cache_obj["misses"] += 1
        return None

    def _get_hit_rate(self, cache_obj):
        """Calculate cache hit rate."""
        total = cache_obj["hits"] + cache_obj["misses"]
        if total == 0:
            return 0.0
        return cache_obj["hits"] / total

    def test_tracks_hits(self):
        cache_obj = self._create_cache_with_stats()
        cache_obj["cache"]["key"] = "value"
        self._get_with_stats(cache_obj, "key")
        assert cache_obj["hits"] == 1

    def test_tracks_misses(self):
        cache_obj = self._create_cache_with_stats()
        self._get_with_stats(cache_obj, "missing")
        assert cache_obj["misses"] == 1

    def test_hit_rate_calculation(self):
        cache_obj = self._create_cache_with_stats()
        cache_obj["hits"] = 75
        cache_obj["misses"] = 25
        assert self._get_hit_rate(cache_obj) == 0.75

    def test_hit_rate_empty(self):
        cache_obj = self._create_cache_with_stats()
        assert self._get_hit_rate(cache_obj) == 0.0


# --- Cache size management patterns ---


class TestCacheSizeManagement:
    """Tests for cache size management patterns."""

    def _estimate_size(self, cache):
        """Estimate cache size in bytes."""
        total = 0
        for key, value in cache.items():
            total += len(str(key)) + len(str(value))
        return total

    def _should_evict(self, cache, max_size_bytes):
        """Check if cache needs eviction."""
        return self._estimate_size(cache) > max_size_bytes

    def test_size_estimation(self):
        cache = {"a": "1234567890"}  # 1 + 10 = 11 bytes
        size = self._estimate_size(cache)
        assert size == 11

    def test_should_evict_when_over_limit(self):
        cache = {"key": "a" * 1000}
        assert self._should_evict(cache, 100) is True

    def test_should_not_evict_under_limit(self):
        cache = {"key": "small"}
        assert self._should_evict(cache, 10000) is False


# --- Write-through cache patterns ---


class TestWriteThroughCache:
    """Tests for write-through cache patterns."""

    def _write_through(self, cache, storage, key, value):
        """Write to both cache and storage."""
        storage[key] = value  # Write to storage first
        cache[key] = value  # Then update cache
        return True

    def _read_through(self, cache, storage, key):
        """Read from cache, fallback to storage."""
        if key in cache:
            return cache[key]
        if key in storage:
            value = storage[key]
            cache[key] = value  # Populate cache
            return value
        return None

    def test_write_updates_both(self):
        cache = {}
        storage = {}
        self._write_through(cache, storage, "key", "value")
        assert cache["key"] == "value"
        assert storage["key"] == "value"

    def test_read_hits_cache(self):
        cache = {"key": "cached"}
        storage = {"key": "stored"}
        value = self._read_through(cache, storage, "key")
        assert value == "cached"

    def test_read_falls_through_to_storage(self):
        cache = {}
        storage = {"key": "stored"}
        value = self._read_through(cache, storage, "key")
        assert value == "stored"
        assert cache["key"] == "stored"  # Now cached


# --- Cache aside patterns ---


class TestCacheAsidePattern:
    """Tests for cache-aside (lazy loading) patterns."""

    def _get_or_load(self, cache, key, load_func):
        """Get from cache or load if missing."""
        if key in cache:
            return cache[key], True  # value, was_cached
        value = load_func(key)
        if value is not None:
            cache[key] = value
        return value, False

    def test_returns_cached(self):
        cache = {"key": "cached"}
        value, was_cached = self._get_or_load(cache, "key", lambda k: "loaded")
        assert value == "cached"
        assert was_cached is True

    def test_loads_on_miss(self):
        cache = {}
        value, was_cached = self._get_or_load(cache, "key", lambda k: "loaded")
        assert value == "loaded"
        assert was_cached is False

    def test_caches_loaded_value(self):
        cache = {}
        self._get_or_load(cache, "key", lambda k: "loaded")
        assert cache["key"] == "loaded"


# --- Memoization patterns ---


class TestMemoization:
    """Tests for function memoization patterns."""

    def _memoize(self, func, cache, key):
        """Memoize function result."""
        if key in cache:
            return cache[key]
        result = func()
        cache[key] = result
        return result

    def test_caches_result(self):
        cache = {}
        call_count = [0]

        def expensive():
            call_count[0] += 1
            return "result"

        self._memoize(expensive, cache, "key")
        self._memoize(expensive, cache, "key")
        assert call_count[0] == 1

    def test_returns_cached_result(self):
        cache = {"key": "cached"}
        result = self._memoize(lambda: "fresh", cache, "key")
        assert result == "cached"


# --- Multi-level cache patterns ---


class TestMultiLevelCache:
    """Tests for multi-level cache patterns."""

    def _get_multi_level(self, l1_cache, l2_cache, key):
        """Get from L1, then L2."""
        if key in l1_cache:
            return l1_cache[key], "L1"
        if key in l2_cache:
            # Promote to L1
            l1_cache[key] = l2_cache[key]
            return l2_cache[key], "L2"
        return None, None

    def _put_multi_level(self, l1_cache, l2_cache, key, value):
        """Put to both L1 and L2."""
        l1_cache[key] = value
        l2_cache[key] = value

    def test_hits_l1_first(self):
        l1 = {"key": "L1-value"}
        l2 = {"key": "L2-value"}
        value, level = self._get_multi_level(l1, l2, "key")
        assert value == "L1-value"
        assert level == "L1"

    def test_falls_to_l2(self):
        l1 = {}
        l2 = {"key": "L2-value"}
        value, level = self._get_multi_level(l1, l2, "key")
        assert value == "L2-value"
        assert level == "L2"

    def test_promotes_to_l1(self):
        l1 = {}
        l2 = {"key": "value"}
        self._get_multi_level(l1, l2, "key")
        assert l1["key"] == "value"

    def test_miss_returns_none(self):
        l1 = {}
        l2 = {}
        value, level = self._get_multi_level(l1, l2, "key")
        assert value is None
        assert level is None
