"""Tests for the cache module."""

from __future__ import annotations

import threading
import time
from datetime import datetime, timedelta

import pytest

from oclawma.cache import (
    CacheConfig,
    CacheEntry,
    CacheError,
    CacheKey,
    CacheStats,
    ResultCache,
)


class TestCacheKey:
    """Test CacheKey generation."""

    def test_from_job_basic(self):
        """Test basic key generation."""
        payload = {"task": "test", "data": "value"}
        key = CacheKey.from_job("test_job", payload)

        assert key.job_type == "test_job"
        assert len(key.key_hash) == 32  # SHA256 truncated to 32 chars

    def test_from_job_consistency(self):
        """Test that identical payloads produce the same key."""
        payload1 = {"b": 2, "a": 1}  # Different order
        payload2 = {"a": 1, "b": 2}

        key1 = CacheKey.from_job("test", payload1)
        key2 = CacheKey.from_job("test", payload2)

        assert key1.key_hash == key2.key_hash

    def test_from_job_different_payloads(self):
        """Test that different payloads produce different keys."""
        key1 = CacheKey.from_job("test", {"a": 1})
        key2 = CacheKey.from_job("test", {"a": 2})

        assert key1.key_hash != key2.key_hash

    def test_from_job_different_types(self):
        """Test that different job types produce different keys."""
        payload = {"task": "test"}
        key1 = CacheKey.from_job("type_a", payload)
        key2 = CacheKey.from_job("type_b", payload)

        # Different job types should produce different hashes (job_type is included in hash)
        assert key1.key_hash != key2.key_hash
        assert key1.job_type != key2.job_type
        assert str(key1) != str(key2)

    def test_from_job_with_key_fields(self):
        """Test key generation with specific fields."""
        payload = {"task": "test", "ignore": "this", "keep": "that"}
        key = CacheKey.from_job("test", payload, key_fields={"task", "keep"})

        # Should ignore the "ignore" field
        key_full = CacheKey.from_job("test", payload)
        assert key.key_hash != key_full.key_hash

    def test_from_job_empty_payload(self):
        """Test key generation with empty payload."""
        key = CacheKey.from_job("test", {})

        assert key.job_type == "test"
        assert len(key.key_hash) == 32

    def test_str_representation(self):
        """Test string representation."""
        key = CacheKey.from_job("my_job", {"x": 1})

        assert str(key) == f"my_job:{key.key_hash}"


class TestCacheConfig:
    """Test CacheConfig dataclass."""

    def test_default_values(self):
        """Test default configuration values."""
        config = CacheConfig()

        assert config.default_ttl == 3600
        assert config.max_size == 10000
        assert config.key_fields is None
        assert config.db_path is None

    def test_custom_values(self):
        """Test custom configuration values."""
        config = CacheConfig(
            default_ttl=7200,
            max_size=5000,
            key_fields={"field1", "field2"},
            db_path="/tmp/cache.db",
        )

        assert config.default_ttl == 7200
        assert config.max_size == 5000
        assert config.key_fields == {"field1", "field2"}
        assert config.db_path == "/tmp/cache.db"


class TestCacheEntry:
    """Test CacheEntry dataclass."""

    def test_is_expired_no_expiration(self):
        """Test entry without expiration never expires."""
        key = CacheKey.from_job("test", {})
        entry = CacheEntry(key=key, result="data")

        assert entry.expires_at is None
        assert entry.is_expired() is False

    def test_is_expired_future(self):
        """Test entry with future expiration is not expired."""
        key = CacheKey.from_job("test", {})
        entry = CacheEntry(
            key=key,
            result="data",
            expires_at=datetime.utcnow() + timedelta(hours=1),
        )

        assert entry.is_expired() is False

    def test_is_expired_past(self):
        """Test entry with past expiration is expired."""
        key = CacheKey.from_job("test", {})
        entry = CacheEntry(
            key=key,
            result="data",
            expires_at=datetime.utcnow() - timedelta(hours=1),
        )

        assert entry.is_expired() is True

    def test_touch(self):
        """Test touch updates access statistics."""
        key = CacheKey.from_job("test", {})
        entry = CacheEntry(key=key, result="data")

        assert entry.access_count == 0
        assert entry.last_accessed is None

        entry.touch()

        assert entry.access_count == 1
        assert entry.last_accessed is not None


class TestResultCacheBasic:
    """Test basic ResultCache operations."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_init_default_config(self):
        """Test initialization with default config."""
        cache = ResultCache()
        assert cache.config.default_ttl == 3600
        cache.close()

    def test_init_custom_config(self):
        """Test initialization with custom config."""
        config = CacheConfig(default_ttl=7200, max_size=500)
        cache = ResultCache(config)
        assert cache.config.default_ttl == 7200
        assert cache.config.max_size == 500
        cache.close()

    def test_set_and_get(self, cache):
        """Test basic set and get operations."""
        key = CacheKey.from_job("test", {"x": 1})
        result = {"data": "value", "number": 42}

        cache.set(key, result)
        retrieved = cache.get(key)

        assert retrieved == result

    def test_get_missing_key(self, cache):
        """Test get returns None for missing key."""
        key = CacheKey.from_job("test", {"x": 1})

        result = cache.get(key)

        assert result is None

    def test_get_updates_stats(self, cache):
        """Test get updates hit/miss statistics."""
        key1 = CacheKey.from_job("test", {"x": 1})
        key2 = CacheKey.from_job("test", {"x": 2})

        cache.set(key1, "value1")

        # Hit
        cache.get(key1)
        # Miss
        cache.get(key2)

        stats = cache.get_stats()
        assert stats.hits == 1
        assert stats.misses == 1

    def test_set_overwrites_existing(self, cache):
        """Test set overwrites existing entry."""
        key = CacheKey.from_job("test", {"x": 1})

        cache.set(key, "first")
        cache.set(key, "second")

        assert cache.get(key) == "second"

    def test_delete(self, cache):
        """Test delete operation."""
        key = CacheKey.from_job("test", {"x": 1})

        cache.set(key, "value")
        assert cache.get(key) == "value"

        deleted = cache.invalidate(key)
        assert deleted is True
        assert cache.get(key) is None

    def test_delete_missing(self, cache):
        """Test delete returns False for missing key."""
        key = CacheKey.from_job("test", {"x": 1})

        deleted = cache.invalidate(key)
        assert deleted is False

    def test_clear(self, cache):
        """Test clear operation."""
        for i in range(5):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value{i}")

        count = cache.clear()

        assert count == 5
        assert len(cache) == 0

    def test_clear_empty(self, cache):
        """Test clear on empty cache."""
        count = cache.clear()
        assert count == 0

    def test_len(self, cache):
        """Test __len__ method."""
        assert len(cache) == 0

        for i in range(3):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value{i}")

        assert len(cache) == 3

    def test_contains_implicit(self, cache):
        """Test implicit membership via get."""
        key = CacheKey.from_job("test", {"x": 1})

        assert cache.get(key) is None

        cache.set(key, "value")
        assert cache.get(key) is not None


class TestResultCacheExpiration:
    """Test cache expiration behavior."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_expired_entry_not_returned(self, cache):
        """Test expired entries are not returned."""
        key = CacheKey.from_job("test", {"x": 1})

        # Set with very short TTL
        cache.set(key, "value", ttl=0)
        time.sleep(0.1)  # Wait for expiration

        result = cache.get(key)
        assert result is None

    def test_expired_entry_deleted(self, cache):
        """Test expired entries are deleted on access."""
        key = CacheKey.from_job("test", {"x": 1})

        cache.set(key, "value", ttl=0)
        time.sleep(0.1)

        cache.get(key)  # This should delete the expired entry

        assert len(cache) == 0

    def test_cleanup_expired(self, cache):
        """Test cleanup_expired removes expired entries."""
        # Add some entries with short TTL
        for i in range(3):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value{i}", ttl=0)

        time.sleep(0.1)

        # Add entry with long TTL
        key = CacheKey.from_job("test", {"x": 99})
        cache.set(key, "value99", ttl=3600)

        removed = cache.cleanup_expired()

        assert removed == 3
        assert len(cache) == 1

    def test_custom_ttl_per_entry(self, cache):
        """Test setting custom TTL per entry."""
        key1 = CacheKey.from_job("test", {"x": 1})
        key2 = CacheKey.from_job("test", {"x": 2})

        cache.set(key1, "short", ttl=0)
        cache.set(key2, "long", ttl=3600)

        time.sleep(0.1)

        assert cache.get(key1) is None  # Expired
        assert cache.get(key2) == "long"  # Not expired


class TestResultCacheLRU:
    """Test LRU eviction behavior."""

    @pytest.fixture
    def small_cache(self):
        """Create a small cache for testing eviction."""
        config = CacheConfig(max_size=3)
        with ResultCache(config) as c:
            yield c

    def test_eviction_when_full(self, small_cache):
        """Test eviction when cache reaches max size."""
        # Fill cache to capacity
        for i in range(3):
            key = CacheKey.from_job("test", {"x": i})
            small_cache.set(key, f"value{i}")

        assert len(small_cache) == 3

        # Add one more - should evict oldest
        key = CacheKey.from_job("test", {"x": 3})
        small_cache.set(key, "value3")

        assert len(small_cache) == 3  # Still at max
        # Oldest should be evicted
        oldest_key = CacheKey.from_job("test", {"x": 0})
        assert small_cache.get(oldest_key) is None

    def test_access_updates_lru(self, small_cache):
        """Test that accessing updates LRU order."""
        # Add entries
        for i in range(3):
            key = CacheKey.from_job("test", {"x": i})
            small_cache.set(key, f"value{i}")

        # Access the first one to make it most recently used
        first_key = CacheKey.from_job("test", {"x": 0})
        small_cache.get(first_key)

        # Add new entry - should evict the second one (least recently used)
        new_key = CacheKey.from_job("test", {"x": 3})
        small_cache.set(new_key, "value3")

        assert small_cache.get(first_key) is not None  # Still there
        second_key = CacheKey.from_job("test", {"x": 1})
        assert small_cache.get(second_key) is None  # Evicted


class TestResultCacheGetOrExecute:
    """Test get_or_execute pattern."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_executes_on_cache_miss(self, cache):
        """Test executor is called on cache miss."""
        key = CacheKey.from_job("test", {"x": 1})
        call_count = 0

        def executor():
            nonlocal call_count
            call_count += 1
            return f"result_{call_count}"

        result = cache.get_or_execute(key, executor)

        assert call_count == 1
        assert result == "result_1"

    def test_skips_execution_on_cache_hit(self, cache):
        """Test executor is not called on cache hit."""
        key = CacheKey.from_job("test", {"x": 1})
        call_count = 0

        def executor():
            nonlocal call_count
            call_count += 1
            return f"result_{call_count}"

        # First call - executes
        result1 = cache.get_or_execute(key, executor)
        # Second call - uses cache
        result2 = cache.get_or_execute(key, executor)

        assert call_count == 1  # Only called once
        assert result1 == result2

    def test_caches_result(self, cache):
        """Test result is cached after first execution."""
        key = CacheKey.from_job("test", {"x": 1})

        def executor():
            return {"data": "expensive_computation"}

        cache.get_or_execute(key, executor)

        # Direct get should return cached result
        cached = cache.get(key)
        assert cached == {"data": "expensive_computation"}

    def test_exception_not_cached(self, cache):
        """Test that exceptions are not cached."""
        key = CacheKey.from_job("test", {"x": 1})
        call_count = 0

        def executor():
            nonlocal call_count
            call_count += 1
            raise ValueError("Test error")

        with pytest.raises(ValueError):
            cache.get_or_execute(key, executor)

        # Should not be in cache
        assert cache.get(key) is None

    def test_custom_ttl(self, cache):
        """Test custom TTL in get_or_execute."""
        key = CacheKey.from_job("test", {"x": 1})

        def executor():
            return "result"

        cache.get_or_execute(key, executor, ttl=0)
        time.sleep(0.1)

        # Should be expired
        assert cache.get(key) is None


class TestResultCacheInvalidation:
    """Test cache invalidation."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_invalidate_by_key(self, cache):
        """Test invalidating a specific key."""
        key1 = CacheKey.from_job("test", {"x": 1})
        key2 = CacheKey.from_job("test", {"x": 2})

        cache.set(key1, "value1")
        cache.set(key2, "value2")

        result = cache.invalidate(key1)

        assert result is True
        assert cache.get(key1) is None
        assert cache.get(key2) == "value2"

    def test_invalidate_by_pattern(self, cache):
        """Test invalidating by pattern."""
        # Add entries with different key hashes
        for i in range(5):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value{i}")

        # Add entry with different job type
        other_key = CacheKey.from_job("other", {"x": 1})
        cache.set(other_key, "other_value")

        # Invalidate all "test" entries using job_type method (not pattern)
        count = cache.invalidate_by_job_type("test")

        assert count == 5
        assert len(cache) == 1
        assert cache.get(other_key) == "other_value"

    def test_invalidate_by_job_type(self, cache):
        """Test invalidating all entries of a job type."""
        for i in range(3):
            key = CacheKey.from_job("type_a", {"x": i})
            cache.set(key, f"value_a_{i}")

        for i in range(2):
            key = CacheKey.from_job("type_b", {"x": i})
            cache.set(key, f"value_b_{i}")

        count = cache.invalidate_by_job_type("type_a")

        assert count == 3
        assert len(cache) == 2

    def test_invalidate_nonexistent(self, cache):
        """Test invalidating non-existent key."""
        key = CacheKey.from_job("test", {"x": 999})

        result = cache.invalidate(key)

        assert result is False


class TestResultCacheStats:
    """Test cache statistics."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_stats_empty(self, cache):
        """Test stats for empty cache."""
        stats = cache.get_stats()

        assert stats.total_entries == 0
        assert stats.expired_entries == 0
        assert stats.hits == 0
        assert stats.misses == 0
        assert stats.hit_rate == 0.0

    def test_stats_with_entries(self, cache):
        """Test stats with entries."""
        key = CacheKey.from_job("test", {"x": 1})
        cache.set(key, "value")

        stats = cache.get_stats()

        assert stats.total_entries == 1
        assert stats.size_bytes > 0

    def test_hit_rate_calculation(self, cache):
        """Test hit rate calculation."""
        key1 = CacheKey.from_job("test", {"x": 1})
        key2 = CacheKey.from_job("test", {"x": 2})

        cache.set(key1, "value")

        # 2 hits
        cache.get(key1)
        cache.get(key1)

        # 1 miss
        cache.get(key2)

        stats = cache.get_stats()

        assert stats.hits == 2
        assert stats.misses == 1
        assert stats.hit_rate == 2 / 3

    def test_stats_after_clear(self, cache):
        """Test stats are reset after clear."""
        key = CacheKey.from_job("test", {"x": 1})
        cache.set(key, "value")
        cache.get(key)

        cache.clear()

        stats = cache.get_stats()
        assert stats.hits == 0
        assert stats.misses == 0


class TestResultCachePersistence:
    """Test cache persistence to disk."""

    def test_persistence(self, tmp_path):
        """Test entries persist across cache instances."""
        db_path = tmp_path / "cache.db"
        key = CacheKey.from_job("test", {"x": 1})

        # Create cache and add entry
        config = CacheConfig(db_path=db_path)
        with ResultCache(config) as cache:
            cache.set(key, "persistent_value")

        # Create new instance with same DB
        with ResultCache(config) as cache2:
            result = cache2.get(key)

        assert result == "persistent_value"

    def test_stats_persisted(self, tmp_path):
        """Test that hits/misses are not persisted (in-memory only)."""
        db_path = tmp_path / "cache.db"
        key = CacheKey.from_job("test", {"x": 1})

        config = CacheConfig(db_path=db_path)
        with ResultCache(config) as cache:
            cache.set(key, "value")
            cache.get(key)  # hit
            cache.get(key)  # hit

        # New instance - stats should be reset
        with ResultCache(config) as cache2:
            stats = cache2.get_stats()
            assert stats.hits == 0  # Not persisted
            assert stats.total_entries == 1  # But entries are


class TestResultCacheConcurrency:
    """Test thread safety."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_concurrent_set(self, cache):
        """Test concurrent set operations."""
        errors = []

        def set_task(n):
            try:
                key = CacheKey.from_job("test", {"x": n})
                cache.set(key, f"value_{n}")
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=set_task, args=(i,)) for i in range(10)]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(cache) == 10

    def test_concurrent_get(self, cache):
        """Test concurrent get operations."""
        # Pre-populate
        for i in range(10):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value_{i}")

        results = []
        errors = []
        lock = threading.Lock()

        def get_task(n):
            try:
                key = CacheKey.from_job("test", {"x": n % 10})
                result = cache.get(key)
                with lock:
                    results.append(result)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=get_task, args=(i,)) for i in range(20)]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(results) == 20

    def test_concurrent_get_or_execute(self, cache):
        """Test concurrent get_or_execute operations."""
        call_count = 0
        lock = threading.Lock()

        def executor():
            nonlocal call_count
            with lock:
                call_count += 1
            return "computed_value"

        results = []
        errors = []
        result_lock = threading.Lock()

        def task(n):
            try:
                key = CacheKey.from_job("test", {"x": 1})  # Same key
                result = cache.get_or_execute(key, executor)
                with result_lock:
                    results.append(result)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=task, args=(i,)) for i in range(10)]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(results) == 10
        assert all(r == "computed_value" for r in results)
        # Executor should have been called only once
        assert call_count == 1


class TestResultCacheListEntries:
    """Test listing cache entries."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_list_all(self, cache):
        """Test listing all entries."""
        for i in range(5):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value{i}")

        entries = cache.list_entries()

        assert len(entries) == 5
        assert all(isinstance(e, CacheEntry) for e in entries)

    def test_list_by_job_type(self, cache):
        """Test filtering by job type."""
        for i in range(3):
            key = CacheKey.from_job("type_a", {"x": i})
            cache.set(key, f"value_a_{i}")

        for i in range(2):
            key = CacheKey.from_job("type_b", {"x": i})
            cache.set(key, f"value_b_{i}")

        entries = cache.list_entries(job_type="type_a")

        assert len(entries) == 3
        assert all(e.key.job_type == "type_a" for e in entries)

    def test_list_with_limit(self, cache):
        """Test listing with limit."""
        for i in range(10):
            key = CacheKey.from_job("test", {"x": i})
            cache.set(key, f"value{i}")

        entries = cache.list_entries(limit=3)

        assert len(entries) == 3

    def test_list_excludes_expired(self, cache):
        """Test that expired entries are excluded by default."""
        # Add expired entry
        key1 = CacheKey.from_job("test", {"x": 1})
        cache.set(key1, "expired", ttl=0)
        time.sleep(0.1)

        # Add valid entry
        key2 = CacheKey.from_job("test", {"x": 2})
        cache.set(key2, "valid", ttl=3600)

        entries = cache.list_entries()

        assert len(entries) == 1
        assert entries[0].result == "valid"

    def test_list_include_expired(self, cache):
        """Test including expired entries."""
        key = CacheKey.from_job("test", {"x": 1})
        cache.set(key, "expired", ttl=0)
        time.sleep(0.1)

        entries = cache.list_entries(include_expired=True)

        assert len(entries) == 1


class TestResultCacheEntryInfo:
    """Test getting entry info."""

    @pytest.fixture
    def cache(self):
        """Create an in-memory cache for testing."""
        with ResultCache(CacheConfig()) as c:
            yield c

    def test_get_entry_info(self, cache):
        """Test getting detailed entry info."""
        key = CacheKey.from_job("test", {"x": 1})
        cache.set(key, "value")
        cache.get(key)  # Access to update stats

        info = cache.get_entry_info(key)

        assert info is not None
        assert info.key == key
        assert info.result == "value"
        assert info.access_count == 1
        assert info.last_accessed is not None

    def test_get_entry_info_missing(self, cache):
        """Test getting info for missing entry."""
        key = CacheKey.from_job("test", {"x": 999})

        info = cache.get_entry_info(key)

        assert info is None


class TestResultCacheErrorHandling:
    """Test error handling."""

    def test_non_serializable_result(self):
        """Test error on non-serializable result."""
        cache = ResultCache()
        key = CacheKey.from_job("test", {"x": 1})

        with pytest.raises(CacheError):
            cache.set(key, lambda x: x)  # Functions can't be serialized

        cache.close()

    def test_context_manager(self):
        """Test context manager properly closes connection."""
        with ResultCache() as cache:
            key = CacheKey.from_job("test", {"x": 1})
            cache.set(key, "value")

        # After exiting context, cache should be closed
        # (no explicit test, but coverage ensures close() was called)


class TestCacheStatsModel:
    """Test CacheStats model."""

    def test_default_creation(self):
        """Test default creation."""
        stats = CacheStats()

        assert stats.total_entries == 0
        assert stats.expired_entries == 0
        assert stats.hits == 0
        assert stats.misses == 0
        assert stats.size_bytes == 0

    def test_hit_rate_zero(self):
        """Test hit rate when no operations."""
        stats = CacheStats()
        assert stats.hit_rate == 0.0

    def test_hit_rate_perfect(self):
        """Test perfect hit rate."""
        stats = CacheStats(hits=10, misses=0)
        assert stats.hit_rate == 1.0

    def test_hit_rate_mixed(self):
        """Test mixed hit rate."""
        stats = CacheStats(hits=3, misses=1)
        assert stats.hit_rate == 0.75
