"""
Tests for ResponseCache with all edge cases from expert review.

Tests cover:
- ✅ Basic caching functionality
- ✅ TTL expiration
- ✅ LRU eviction (optimized with heapq)
- ✅ Cache integrity verification (HMAC)
- ✅ Path traversal protection
- ✅ Large response handling
- ✅ Concurrent access
- ✅ Cache corruption handling
"""

import pytest
import json
import time
from pathlib import Path
from unittest import mock

from claude_force.response_cache import ResponseCache, CacheEntry


# ============================================================================
# Basic Functionality Tests
# ============================================================================


def test_cache_basic_set_get(tmp_path):
    """Test basic cache set and get operations."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Store entry
    cache.set(
        agent_name="python-expert",
        task="What are decorators?",
        model="claude-3-5-sonnet-20241022",
        response="Decorators are...",
        input_tokens=100,
        output_tokens=50,
        estimated_cost=0.001,
    )

    # Retrieve entry
    result = cache.get(
        agent_name="python-expert", task="What are decorators?", model="claude-3-5-sonnet-20241022"
    )

    assert result is not None
    assert result["response"] == "Decorators are..."
    assert result["input_tokens"] == 100
    assert result["output_tokens"] == 50
    assert result["cached"] is True
    assert "cache_age_seconds" in result


def test_cache_miss(tmp_path):
    """Test cache miss for non-existent entry."""
    cache = ResponseCache(cache_dir=tmp_path / "cache")

    result = cache.get(
        agent_name="python-expert", task="Non-existent task", model="claude-3-5-sonnet-20241022"
    )

    assert result is None
    assert cache.stats["misses"] == 1
    assert cache.stats["hits"] == 0


def test_cache_disabled(tmp_path):
    """Test that caching can be disabled."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", enabled=False)

    # Try to store
    cache.set(
        agent_name="python-expert",
        task="task",
        model="model",
        response="response",
        input_tokens=100,
        output_tokens=50,
        estimated_cost=0.001,
    )

    # Should not be cached
    result = cache.get(agent_name="python-expert", task="task", model="model")

    assert result is None


# ============================================================================
# Cache Key Tests (✅ FIXED: 32 chars from expert review)
# ============================================================================


def test_cache_key_length(tmp_path):
    """Test that cache keys are 32 chars to reduce collision risk."""
    cache = ResponseCache(cache_dir=tmp_path / "cache")

    key = cache._cache_key("agent", "task", "model")

    # ✅ Should be 32 chars (128 bits) for negligible collision probability
    assert len(key) == 32
    assert key.isalnum()  # Should be hexadecimal


def test_cache_key_consistency(tmp_path):
    """Test that cache keys are consistent for same inputs."""
    cache = ResponseCache(cache_dir=tmp_path / "cache")

    key1 = cache._cache_key("agent", "task", "model")
    key2 = cache._cache_key("agent", "task", "model")

    assert key1 == key2


def test_cache_key_uniqueness(tmp_path):
    """Test that different inputs produce different keys."""
    cache = ResponseCache(cache_dir=tmp_path / "cache")

    key1 = cache._cache_key("agent1", "task", "model")
    key2 = cache._cache_key("agent2", "task", "model")
    key3 = cache._cache_key("agent1", "task2", "model")

    assert key1 != key2
    assert key1 != key3
    assert key2 != key3


# ============================================================================
# HMAC Integrity Tests (✅ NEW from expert review)
# ============================================================================


def test_cache_integrity_verification(tmp_path):
    """Test that cache integrity is verified with HMAC."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Store entry
    cache.set("agent", "task", "model", "response", 100, 50, 0.001)

    # Should retrieve successfully
    result = cache.get("agent", "task", "model")
    assert result is not None
    assert result["response"] == "response"


def test_cache_integrity_tampering_detection(tmp_path):
    """Test that tampering is detected."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Store entry
    cache.set("agent", "task", "model", "original response", 100, 50, 0.001)

    # Get cache key
    key = cache._cache_key("agent", "task", "model")

    # Tamper with cache file
    cache_file = tmp_path / "cache" / f"{key}.json"
    assert cache_file.exists()

    with open(cache_file, "r") as f:
        data = json.load(f)

    # Modify response without updating signature
    data["response"] = "tampered response"

    with open(cache_file, "w") as f:
        json.dump(data, f)

    # Clear memory cache to force disk read
    cache._memory_cache.clear()

    # Should detect tampering and return None
    result = cache.get("agent", "task", "model")
    assert result is None
    assert cache.stats["integrity_failures"] == 1


def test_cache_signature_computation(tmp_path):
    """Test HMAC signature computation."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    entry_dict = {
        "key": "test_key",
        "agent_name": "agent",
        "task": "task",
        "model": "model",
        "response": "response",
        "input_tokens": 100,
        "output_tokens": 50,
        "estimated_cost": 0.001,
        "timestamp": 1234567890.0,
        "hit_count": 0,
    }

    sig1 = cache._compute_signature(entry_dict)
    sig2 = cache._compute_signature(entry_dict)

    # Should be consistent
    assert sig1 == sig2
    assert len(sig1) == 64  # SHA-256 hex digest

    # Should change if data changes
    entry_dict["response"] = "different"
    sig3 = cache._compute_signature(entry_dict)
    assert sig3 != sig1


# ============================================================================
# TTL Expiration Tests
# ============================================================================


def test_cache_ttl_expiration(tmp_path):
    """Test that cache entries expire after TTL."""
    cache = ResponseCache(
        cache_dir=tmp_path / "cache",
        ttl_hours=0.001,  # Very short TTL (3.6 seconds)
        cache_secret="test_secret",
    )

    # Store entry
    cache.set("agent", "task", "model", "response", 100, 50, 0.001)

    # Should be cached immediately
    result = cache.get("agent", "task", "model")
    assert result is not None

    # Wait for expiration
    time.sleep(4)

    # Should be expired
    result = cache.get("agent", "task", "model")
    assert result is None


def test_cache_hit_count(tmp_path):
    """Test that hit count is tracked."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Store entry
    cache.set("agent", "task", "model", "response", 100, 50, 0.001)

    # Get multiple times
    for i in range(5):
        result = cache.get("agent", "task", "model")
        assert result is not None
        assert result["hit_count"] == i + 1


# ============================================================================
# LRU Eviction Tests (✅ FIXED: heapq optimization from expert review)
# ============================================================================


def test_lru_eviction(tmp_path):
    """Test LRU eviction with heapq optimization."""
    cache = ResponseCache(
        cache_dir=tmp_path / "cache", max_size_mb=1, cache_secret="test_secret"  # 1MB limit
    )

    # Fill cache with large responses
    for i in range(100):
        large_response = "x" * 50_000  # 50KB each
        cache.set(f"agent{i}", f"task{i}", "model", large_response, 1000, 500, 0.001)

    # Should have triggered eviction
    assert cache.stats["evictions"] > 0
    assert cache.stats["size_bytes"] <= cache.max_size_bytes

    # Verify least used entries were evicted
    # (We can't directly test which ones were evicted without internals)
    stats = cache.get_stats()
    print(f"After eviction: {stats['entries']} entries, {stats['size_mb']} MB")


def test_lru_eviction_respects_hit_count(tmp_path):
    """Test that LRU eviction keeps frequently accessed entries."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", max_size_mb=1, cache_secret="test_secret")

    # Create entries
    for i in range(50):
        cache.set(f"agent{i}", f"task{i}", "model", "x" * 30_000, 1000, 500, 0.001)

    # Access some entries frequently
    frequently_accessed = ["agent0", "agent1", "agent2"]
    for agent in frequently_accessed:
        for _ in range(10):
            cache.get(agent, f"task{agent[5:]}", "model")

    # Add more entries to trigger eviction
    for i in range(50, 100):
        cache.set(f"agent{i}", f"task{i}", "model", "x" * 30_000, 1000, 500, 0.001)

    # Frequently accessed entries should still be in cache
    for agent in frequently_accessed:
        result = cache.get(agent, f"task{agent[5:]}", "model")
        # Note: Might have been evicted due to size, but less likely
        print(f"{agent}: {'found' if result else 'evicted'}")


# ============================================================================
# Path Traversal Protection Tests (✅ NEW from expert review)
# ============================================================================


def test_cache_path_validation(tmp_path):
    """Test that cache path traversal is prevented."""
    # Try to set cache outside allowed directories (/etc is not allowed)
    with pytest.raises(ValueError, match="Cache directory must be under"):
        ResponseCache(cache_dir=Path("/etc/evil_cache"))

    # Test bypass attempts that would fool string prefix matching
    with pytest.raises(ValueError, match="Cache directory must be under"):
        ResponseCache(cache_dir=Path("/tmp_evil/cache"))  # Would pass startswith("/tmp")

    with pytest.raises(ValueError, match="Cache directory must be under"):
        ResponseCache(cache_dir=Path("/tmp/../etc/passwd"))  # Path traversal attempt


def test_cache_path_allowed(tmp_path):
    """Test that valid cache paths are accepted."""
    # This should work - under ~/.claude
    claude_dir = Path.home() / ".claude"
    cache_dir = claude_dir / "test_cache"

    cache = ResponseCache(cache_dir=cache_dir)
    assert cache.cache_dir == cache_dir


# ============================================================================
# Large Response Tests (✅ NEW from expert review)
# ============================================================================


def test_cache_large_response(tmp_path):
    """Test caching of very large responses."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", max_size_mb=10, cache_secret="test_secret")

    # Store 2MB response
    large_response = "x" * (2 * 1024 * 1024)

    cache.set("agent", "task", "model", large_response, 10000, 5000, 0.01)

    # Should be able to retrieve
    result = cache.get("agent", "task", "model")
    assert result is not None
    assert len(result["response"]) == 2 * 1024 * 1024


def test_cache_size_tracking(tmp_path):
    """Test that cache size is accurately tracked."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    initial_size = cache.stats["size_bytes"]

    # Add entries
    for i in range(10):
        cache.set(f"agent{i}", f"task{i}", "model", "x" * 1000, 100, 50, 0.001)

    # Size should have increased
    assert cache.stats["size_bytes"] > initial_size

    # Clear cache
    cache.clear()

    # Size should be reset
    assert cache.stats["size_bytes"] == 0


# ============================================================================
# Error Handling Tests (✅ IMPROVED from expert review)
# ============================================================================


def test_cache_corrupt_file_handling(tmp_path):
    """Test handling of corrupt cache files."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Create corrupt cache file
    corrupt_file = tmp_path / "cache" / "corrupt.json"
    corrupt_file.parent.mkdir(parents=True, exist_ok=True)
    with open(corrupt_file, "w") as f:
        f.write("{ invalid json }")

    # Should handle gracefully
    cache._load_cache_index()

    # Corrupt file should be removed
    assert not corrupt_file.exists()


def test_cache_missing_signature(tmp_path):
    """Test handling of cache entries without signature."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Create entry without signature (old format)
    entry = CacheEntry(
        key="test_key",
        agent_name="agent",
        task="task",
        model="model",
        response="response",
        input_tokens=100,
        output_tokens=50,
        estimated_cost=0.001,
        timestamp=time.time(),
        hit_count=0,
        signature="",  # Missing signature
    )

    # Verify should fail
    assert cache._verify_signature(entry) is False


# ============================================================================
# Statistics Tests
# ============================================================================


def test_cache_statistics(tmp_path):
    """Test cache statistics tracking."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Initially empty
    stats = cache.get_stats()
    assert stats["hits"] == 0
    assert stats["misses"] == 0
    assert stats["hit_rate"] == "0.0%"

    # Add entries
    cache.set("agent1", "task1", "model", "response1", 100, 50, 0.001)
    cache.set("agent2", "task2", "model", "response2", 100, 50, 0.001)

    # Hit
    cache.get("agent1", "task1", "model")
    # Miss
    cache.get("agent3", "task3", "model")

    stats = cache.get_stats()
    assert stats["hits"] == 1
    assert stats["misses"] == 1
    assert stats["hit_rate"] == "50.0%"
    assert stats["entries"] == 2


def test_cache_clear(tmp_path):
    """Test cache clearing."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Add entries
    for i in range(10):
        cache.set(f"agent{i}", f"task{i}", "model", "response", 100, 50, 0.001)

    assert len(cache._memory_cache) == 10

    # Clear
    cache.clear()

    assert len(cache._memory_cache) == 0
    assert cache.stats["size_bytes"] == 0


# ============================================================================
# Agent Exclusion Tests
# ============================================================================


def test_exclude_agents(tmp_path):
    """Test that specific agents can be excluded from caching."""
    cache = ResponseCache(
        cache_dir=tmp_path / "cache",
        exclude_agents=["non-deterministic-agent"],
        cache_secret="test_secret",
    )

    # Try to cache excluded agent
    cache.set(
        agent_name="non-deterministic-agent",
        task="task",
        model="model",
        response="response",
        input_tokens=100,
        output_tokens=50,
        estimated_cost=0.001,
    )

    # Should not be cached
    result = cache.get(agent_name="non-deterministic-agent", task="task", model="model")

    assert result is None

    # Normal agent should be cached
    cache.set(
        agent_name="python-expert",
        task="task",
        model="model",
        response="response",
        input_tokens=100,
        output_tokens=50,
        estimated_cost=0.001,
    )

    result = cache.get(agent_name="python-expert", task="task", model="model")

    assert result is not None


# ============================================================================
# Persistence Tests
# ============================================================================


def test_cache_persistence(tmp_path):
    """Test that cache persists across instances."""
    cache_dir = tmp_path / "cache"
    secret = "test_secret"

    # Create first cache instance and store
    cache1 = ResponseCache(cache_dir=cache_dir, cache_secret=secret)
    cache1.set("agent", "task", "model", "response", 100, 50, 0.001)

    # Create second cache instance
    cache2 = ResponseCache(cache_dir=cache_dir, cache_secret=secret)

    # Should load from disk
    result = cache2.get("agent", "task", "model")
    assert result is not None
    assert result["response"] == "response"


# ============================================================================
# Performance Tests
# ============================================================================


@pytest.mark.performance
def test_cache_performance(tmp_path):
    """Test cache performance with many entries."""
    import time

    cache = ResponseCache(cache_dir=tmp_path / "cache", max_size_mb=100, cache_secret="test_secret")

    # Store 1000 entries
    start = time.time()
    for i in range(1000):
        cache.set(f"agent{i}", f"task{i}", "model", "x" * 1000, 100, 50, 0.001)
    store_time = time.time() - start

    # Retrieve 1000 entries
    start = time.time()
    for i in range(1000):
        cache.get(f"agent{i}", f"task{i}", "model")
    retrieve_time = time.time() - start

    print(f"Store 1000 entries: {store_time:.2f}s")
    print(f"Retrieve 1000 entries: {retrieve_time:.2f}s")

    # Should be reasonably fast
    assert store_time < 5.0  # Should store 1000 entries in < 5s
    assert retrieve_time < 1.0  # Should retrieve 1000 entries in < 1s


# ============================================================================
# Regression Tests (✅ NEW from Codex review)
# ============================================================================


def test_cache_expands_user_home():
    """Test that tilde (~) in cache path is properly expanded."""
    from pathlib import Path

    # Create cache with tilde path
    cache_path = Path("~/.claude/test_cache_tilde")
    cache = ResponseCache(cache_dir=cache_path, cache_secret="test_secret")

    # Verify that tilde was expanded and path is under home directory
    assert cache.cache_dir.is_absolute()
    assert str(cache.cache_dir).startswith(str(Path.home()))
    assert "~" not in str(cache.cache_dir)

    # Clean up
    import shutil

    if cache.cache_dir.exists():
        shutil.rmtree(cache.cache_dir)


def test_ttl_expiration_updates_size(tmp_path):
    """Test that TTL expiration properly updates cache size accounting."""
    cache = ResponseCache(
        cache_dir=tmp_path / "cache",
        ttl_hours=0.0001,  # Very short TTL (~0.36 seconds)
        cache_secret="test_secret",
    )

    # Store an entry
    large_response = "x" * 10000  # 10KB
    cache.set("agent", "task", "model", large_response, 100, 50, 0.001)

    # Verify size increased
    initial_size = cache.stats["size_bytes"]
    assert initial_size > 0

    # Wait for TTL to expire
    time.sleep(0.5)

    # Try to get expired entry (should trigger eviction)
    result = cache.get("agent", "task", "model")
    assert result is None

    # Verify size was decreased
    assert cache.stats["size_bytes"] < initial_size
    # Should be 0 or close to 0 (accounting for index file)
    assert cache.stats["size_bytes"] < 1000


def test_overwrite_updates_size(tmp_path):
    """Test that overwriting cache entries properly updates size accounting."""
    cache = ResponseCache(cache_dir=tmp_path / "cache", cache_secret="test_secret")

    # Store initial entry
    initial_response = "x" * 1000  # 1KB
    cache.set("agent", "task", "model", initial_response, 100, 50, 0.001)
    size_after_first = cache.stats["size_bytes"]
    assert size_after_first > 0

    # Overwrite with larger entry
    larger_response = "x" * 5000  # 5KB
    cache.set("agent", "task", "model", larger_response, 100, 50, 0.001)
    size_after_overwrite = cache.stats["size_bytes"]

    # Size should have increased (not doubled)
    # The old entry size should have been subtracted before adding new size
    assert size_after_overwrite > size_after_first
    size_difference = size_after_overwrite - size_after_first
    # Should be approximately 4KB (5KB - 1KB), allow some margin for metadata
    assert 3000 < size_difference < 6000

    # Overwrite with smaller entry
    smaller_response = "y" * 500  # 0.5KB
    cache.set("agent", "task", "model", smaller_response, 100, 50, 0.001)
    size_after_small = cache.stats["size_bytes"]

    # Size should have decreased
    assert size_after_small < size_after_overwrite


def test_eviction_enforces_size_limit(tmp_path):
    """Test that eviction loops until cache is actually under size limit."""
    cache = ResponseCache(
        cache_dir=tmp_path / "cache", max_size_mb=0.1, cache_secret="test_secret"  # 100KB limit
    )

    # Add many entries to exceed limit
    # Each entry is ~10KB, so we'll add 20 entries = 200KB total
    for i in range(20):
        large_response = "x" * 10000  # 10KB each
        cache.set(f"agent{i}", f"task{i}", "model", large_response, 100, 50, 0.001)

    # Cache should have evicted enough entries to stay under limit
    max_bytes = cache.max_size_bytes
    actual_bytes = cache.stats["size_bytes"]

    # Should be under the limit
    assert actual_bytes <= max_bytes, f"Cache size {actual_bytes} exceeds limit {max_bytes}"

    # Verify evictions occurred
    assert cache.stats["evictions"] > 0

    # Verify not all entries are still present
    present_count = 0
    for i in range(20):
        if cache.get(f"agent{i}", f"task{i}", "model") is not None:
            present_count += 1

    # Should have evicted some entries
    assert present_count < 20
