"""
Tests for the Result Cache Service.

Tests the Redis-backed result caching with TTL, invalidation, and lookup.
"""

import time
from unittest.mock import Mock, patch

import pytest

from verlex_server.gateway.backend.result_cache import (
    CacheEntry,
    CacheStats,
    CacheStatus,
    ResultCacheService,
    configure_result_cache,
    get_result_cache,
)


class MockRedisClient:
    """Mock Redis client for testing."""

    def __init__(self):
        self._data = {}
        self._expiry = {}
        self._sets = {}

    def setex(self, key, ttl, value):
        self._data[key] = value
        self._expiry[key] = time.time() + ttl
        return True

    def set(self, key, value, keepttl=False):
        self._data[key] = value
        return True

    def get(self, key):
        if key in self._expiry:
            if time.time() > self._expiry[key]:
                del self._data[key]
                del self._expiry[key]
                return None
        return self._data.get(key)

    def delete(self, *keys):
        count = 0
        for key in keys:
            if key in self._data:
                del self._data[key]
                count += 1
            if key in self._expiry:
                del self._expiry[key]
        return count

    def exists(self, key):
        if key in self._expiry:
            if time.time() > self._expiry[key]:
                return 0
        return 1 if key in self._data else 0

    def sadd(self, key, *values):
        if key not in self._sets:
            self._sets[key] = set()
        self._sets[key].update(values)
        return len(values)

    def smembers(self, key):
        return self._sets.get(key, set())

    def expire(self, key, ttl):
        if key in self._data:
            self._expiry[key] = time.time() + ttl
            return True
        return False


class TestCacheEntry:
    """Tests for CacheEntry dataclass."""

    def test_cache_entry_creation(self):
        """Test creating a cache entry."""
        entry = CacheEntry(
            job_id="job-123",
            user_id="user-456",
            result_data=b"test data",
            result_type="value",
        )

        assert entry.job_id == "job-123"
        assert entry.user_id == "user-456"
        assert entry.result_data == b"test data"
        assert entry.result_type == "value"
        assert entry.access_count == 0
        assert not entry.is_expired()

    def test_cache_entry_expiration(self):
        """Test cache entry expiration check."""
        # Not expired
        entry = CacheEntry(
            job_id="job-123",
            user_id="user-456",
            result_data=b"test",
            expires_at=time.time() + 3600,
        )
        assert not entry.is_expired()

        # Expired
        entry_expired = CacheEntry(
            job_id="job-456",
            user_id="user-789",
            result_data=b"test",
            expires_at=time.time() - 1,
        )
        assert entry_expired.is_expired()

        # No expiry
        entry_no_expiry = CacheEntry(
            job_id="job-789",
            user_id="user-000",
            result_data=b"test",
            expires_at=None,
        )
        assert not entry_no_expiry.is_expired()

    def test_cache_entry_serialization(self):
        """Test cache entry to/from dict."""
        entry = CacheEntry(
            job_id="job-123",
            user_id="user-456",
            result_data=b"test data",
            result_type="numpy",
            checksum="abc123",
            size_bytes=100,
            compressed=True,
        )

        data = entry.to_dict()

        assert data["job_id"] == "job-123"
        assert data["user_id"] == "user-456"
        assert data["result_type"] == "numpy"
        assert data["compressed"] is True

        # Reconstruct
        restored = CacheEntry.from_dict(data)

        assert restored.job_id == entry.job_id
        assert restored.user_id == entry.user_id
        assert restored.result_data == entry.result_data
        assert restored.result_type == entry.result_type


class TestCacheStats:
    """Tests for CacheStats."""

    def test_hit_rate_calculation(self):
        """Test hit rate calculation."""
        stats = CacheStats(hits=80, misses=20)
        assert stats.hit_rate() == 0.8

        stats_empty = CacheStats(hits=0, misses=0)
        assert stats_empty.hit_rate() == 0.0

        stats_all_hits = CacheStats(hits=100, misses=0)
        assert stats_all_hits.hit_rate() == 1.0

    def test_stats_to_dict(self):
        """Test stats serialization."""
        stats = CacheStats(
            hits=50,
            misses=50,
            stores=100,
            saved_cost_usd=10.50,
        )

        data = stats.to_dict()

        assert data["hits"] == 50
        assert data["misses"] == 50
        assert data["hit_rate"] == 0.5
        assert data["saved_cost_usd"] == 10.50


class TestResultCacheService:
    """Tests for ResultCacheService."""

    @pytest.fixture
    def mock_redis(self):
        """Create a mock Redis client."""
        redis = Mock()
        redis.client = MockRedisClient()
        return redis

    @pytest.fixture
    def cache_service(self, mock_redis):
        """Create a cache service with mock Redis."""
        service = ResultCacheService(
            redis_client=mock_redis,
            memory_cache_size_mb=10,
            default_ttl_seconds=3600,
        )
        return service

    @pytest.mark.asyncio
    async def test_store_and_retrieve(self, cache_service):
        """Test storing and retrieving a result."""
        job_id = "job-123"
        user_id = "user-456"
        result_data = b"Hello, World!"

        # Store
        status = await cache_service.store(
            job_id=job_id,
            user_id=user_id,
            result_data=result_data,
            result_type="value",
        )

        assert status == CacheStatus.STORED

        # Retrieve
        data, status = await cache_service.retrieve(job_id)

        assert status == CacheStatus.HIT
        assert data == result_data

    @pytest.mark.asyncio
    async def test_cache_miss(self, cache_service):
        """Test cache miss for non-existent entry."""
        data, status = await cache_service.retrieve("nonexistent-job")

        assert status == CacheStatus.MISS
        assert data is None

    @pytest.mark.asyncio
    async def test_invalidate(self, cache_service):
        """Test invalidating a cache entry."""
        job_id = "job-to-invalidate"

        # Store
        await cache_service.store(
            job_id=job_id,
            user_id="user-123",
            result_data=b"test data",
        )

        # Verify stored
        assert await cache_service.exists(job_id)

        # Invalidate
        result = await cache_service.invalidate(job_id)
        assert result is True

        # Verify gone
        data, status = await cache_service.retrieve(job_id)
        assert status == CacheStatus.MISS

    @pytest.mark.asyncio
    async def test_compression(self, cache_service):
        """Test that large results are compressed."""
        job_id = "job-large"
        # Create data larger than compression threshold
        large_data = b"x" * 2000

        await cache_service.store(
            job_id=job_id,
            user_id="user-123",
            result_data=large_data,
        )

        # Retrieve and verify
        data, status = await cache_service.retrieve(job_id)

        assert status == CacheStatus.HIT
        assert data == large_data

    @pytest.mark.asyncio
    async def test_memory_cache_l1(self, cache_service):
        """Test L1 memory cache."""
        job_id = "job-memory"
        result_data = b"small data"

        await cache_service.store(
            job_id=job_id,
            user_id="user-123",
            result_data=result_data,
        )

        # Should be in L1 cache
        assert job_id in cache_service._memory_cache

        # Retrieve from L1 (should be faster)
        data, status = await cache_service.retrieve(job_id)

        assert status == CacheStatus.HIT
        assert data == result_data

    @pytest.mark.asyncio
    async def test_stats_tracking(self, cache_service):
        """Test that stats are tracked correctly."""
        # Store
        await cache_service.store(
            job_id="job-stats",
            user_id="user-123",
            result_data=b"test",
            compute_seconds=10.0,
            cost_usd=0.50,
        )

        # Hit
        await cache_service.retrieve("job-stats")

        # Miss
        await cache_service.retrieve("nonexistent")

        stats = cache_service.get_stats()

        assert stats.stores == 1
        assert stats.hits == 1
        assert stats.misses == 1
        assert stats.saved_compute_seconds == 10.0
        assert stats.saved_cost_usd == 0.50

    @pytest.mark.asyncio
    async def test_deduplication(self, cache_service):
        """Test result deduplication via computation hash."""
        computation_hash = "hash-abc123"
        result_data = b"computed result"

        # Store with computation hash
        await cache_service.store(
            job_id="job-original",
            user_id="user-123",
            result_data=result_data,
            computation_hash=computation_hash,
        )

        # Retrieve by computation hash
        data, job_id, status = await cache_service.retrieve_by_computation(
            computation_hash
        )

        assert status == CacheStatus.HIT
        assert data == result_data
        assert job_id == "job-original"

    @pytest.mark.asyncio
    async def test_get_metadata(self, cache_service):
        """Test getting cache entry metadata."""
        job_id = "job-meta"

        await cache_service.store(
            job_id=job_id,
            user_id="user-123",
            result_data=b"test data",
            result_type="tensor",
        )

        metadata = await cache_service.get_metadata(job_id)

        assert metadata is not None
        assert metadata["job_id"] == job_id
        assert metadata["result_type"] == "tensor"
        assert "size_bytes" in metadata


class TestGlobalCacheInstance:
    """Tests for global cache instance management."""

    def test_get_result_cache(self):
        """Test getting global cache instance."""
        with patch('verlex_server.gateway.backend.result_cache._result_cache', None):
            with patch('verlex_server.gateway.backend.result_cache.get_redis_client') as mock_get_redis:
                mock_redis = Mock()
                mock_redis.client = MockRedisClient()
                mock_get_redis.return_value = mock_redis

                cache1 = get_result_cache()
                cache2 = get_result_cache()

                # Should be same instance
                assert cache1 is cache2

    def test_configure_result_cache(self):
        """Test configuring global cache instance."""
        with patch('verlex_server.gateway.backend.result_cache._result_cache', None):
            mock_redis = Mock()
            mock_redis.client = MockRedisClient()

            cache = configure_result_cache(
                redis_client=mock_redis,
                memory_cache_size_mb=50,
                default_ttl_seconds=7200,
            )

            assert cache._max_memory_size == 50 * 1024 * 1024
            assert cache._default_ttl == 7200
