"""Tests for LRU eviction across all cache tiers (issue #116)."""

from __future__ import annotations

import json
from datetime import datetime  # noqa: F401 -- Pydantic forward ref resolution
from typing import TYPE_CHECKING

import pytest

from llmhosts.cache.exact import ExactHashCache
from llmhosts.cache.models import CacheEntry, CacheStats

if TYPE_CHECKING:
    from pathlib import Path

CacheEntry.model_rebuild()
CacheStats.model_rebuild()


# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------


def _messages(text: str = "Hello") -> list[dict]:
    return [{"role": "user", "content": text}]


def _response(text: str = "Hi there!") -> str:
    return json.dumps({"content": text, "model": "test"})


# -------------------------------------------------------------------
# Tier 0: ExactHashCache eviction
# -------------------------------------------------------------------


@pytest.fixture()
async def small_exact_cache(tmp_path: Path) -> ExactHashCache:
    """ExactHashCache with a max of 5 entries to trigger eviction quickly."""
    c = ExactHashCache(db_path=tmp_path / "exact.db", max_size_mb=100, ttl_seconds=3600, max_entries=5)
    await c.initialize()
    yield c  # type: ignore[misc]
    await c.close()


@pytest.mark.unit
async def test_exact_eviction_by_entry_count(small_exact_cache: ExactHashCache) -> None:
    """When max_entries is exceeded, LRU entries should be evicted."""
    cache = small_exact_cache

    # Insert 8 entries (limit is 5)
    for i in range(8):
        await cache.put("model", _messages(f"prompt-{i}"), _response(f"resp-{i}"))

    stats = await cache.stats()
    # Should have evicted down to at most 5
    assert stats.total_entries <= 5
    assert stats.eviction_count > 0


@pytest.mark.unit
async def test_exact_eviction_preserves_recent(tmp_path: Path) -> None:
    """Most recently accessed entries should survive eviction."""
    # Use max_entries=100 so the batch size (50) doesn't wipe everything
    cache = ExactHashCache(db_path=tmp_path / "lru.db", max_size_mb=100, ttl_seconds=3600, max_entries=100)
    await cache.initialize()
    try:
        # Insert 100 entries to fill up
        for i in range(100):
            await cache.put("model", _messages(f"prompt-{i}"), _response(f"resp-{i}"))

        # Access prompt-99 to make it most recently used
        await cache.get("model", _messages("prompt-99"))

        # Insert 60 more to trigger eviction of ~60 LRU entries
        for i in range(100, 160):
            await cache.put("model", _messages(f"prompt-{i}"), _response(f"resp-{i}"))

        # prompt-99 should survive (recently accessed via get)
        entry = await cache.get("model", _messages("prompt-99"))
        assert entry is not None

        # Early entries without hits should have been evicted
        entry_old = await cache.get("model", _messages("prompt-0"))
        assert entry_old is None
    finally:
        await cache.close()


@pytest.mark.unit
async def test_exact_eviction_by_size(tmp_path: Path) -> None:
    """When max_size_mb is exceeded, LRU entries should be evicted."""
    # Very small size limit: ~500 bytes
    cache = ExactHashCache(db_path=tmp_path / "small.db", max_size_mb=0, ttl_seconds=3600, max_entries=10000)
    # max_size_mb=0 means 0 bytes limit -- any entry will trigger eviction after the first
    await cache.initialize()
    try:
        await cache.put("model", _messages("a"), _response("a" * 100))
        await cache.put("model", _messages("b"), _response("b" * 100))

        stats = await cache.stats()
        assert stats.eviction_count > 0
    finally:
        await cache.close()


# -------------------------------------------------------------------
# Tier 1: NamespaceFilter eviction
# -------------------------------------------------------------------


@pytest.mark.unit
async def test_namespace_eviction_by_count(tmp_path: Path) -> None:
    """When max_namespaces is exceeded, LRU namespace buckets should be evicted."""
    from llmhosts.cache.namespace import NamespaceFilter

    ns = NamespaceFilter(db_path=tmp_path / "ns.db", ttl_seconds=3600, max_namespaces=3)
    await ns.initialize()
    try:
        # Insert entries into 5 different namespaces by using language-specific prompts
        prompts_by_ns = [
            "Write a Python sorting algorithm",
            "Write a JavaScript React component",
            "Write a Rust memory allocator",
            "Write a Go HTTP server",
            "Write a Java Spring Boot app",
        ]
        for prompt in prompts_by_ns:
            await ns.put("model", _messages(prompt), _response(f"resp for {prompt}"))

        stats = await ns.stats()
        # Should have evicted down to at most 3 namespaces
        assert stats.total_namespaces <= 3
        assert stats.eviction_count > 0
    finally:
        await ns.close()


@pytest.mark.unit
async def test_namespace_eviction_counter_increments(tmp_path: Path) -> None:
    """Eviction counter should increment with each eviction cycle."""
    from llmhosts.cache.namespace import NamespaceFilter

    ns = NamespaceFilter(db_path=tmp_path / "ns2.db", ttl_seconds=3600, max_namespaces=2)
    await ns.initialize()
    try:
        # Insert into 4 namespaces
        prompts = [
            "Write Python code",
            "Write JavaScript code",
            "Write Rust code",
            "Write Go code",
        ]
        for prompt in prompts:
            await ns.put("model", _messages(prompt), _response("resp"))

        assert ns._evictions > 0
    finally:
        await ns.close()


# -------------------------------------------------------------------
# Tier 2: VCache eviction
# -------------------------------------------------------------------


@pytest.mark.unit
async def test_vcache_eviction_by_bytes(tmp_path: Path) -> None:
    """When max_bytes is exceeded, LRU vCache entries should be evicted."""
    try:
        import numpy as np
    except ImportError:
        pytest.skip("numpy not available")

    from llmhosts.cache.vcache import VCache

    # Very small byte limit to trigger eviction
    vc = VCache(
        db_path=tmp_path / "vc.db",
        embedding_dim=16,
        ttl_seconds=3600,
        max_bytes=500,  # ~500 bytes total, each entry is ~64 bytes embedding + response
    )
    await vc.initialize()
    try:
        for i in range(10):
            emb = np.random.rand(16).astype(np.float32)
            await vc.put(
                embedding=emb,
                model="model",
                messages_hash=f"hash-{i}",
                response_json=_response(f"resp-{i}"),
            )

        stats = await vc.stats()
        assert stats.eviction_count > 0
        # Entries should be bounded
        assert stats.total_entries < 10
    finally:
        await vc.close()


@pytest.mark.unit
async def test_vcache_eviction_cleans_thresholds(tmp_path: Path) -> None:
    """Evicted vCache entries should have their in-memory thresholds cleaned up."""
    try:
        import numpy as np
    except ImportError:
        pytest.skip("numpy not available")

    from llmhosts.cache.vcache import VCache

    vc = VCache(
        db_path=tmp_path / "vc2.db",
        embedding_dim=16,
        ttl_seconds=3600,
        max_bytes=300,
    )
    await vc.initialize()
    try:
        keys = []
        for i in range(10):
            emb = np.random.rand(16).astype(np.float32)
            key = await vc.put(
                embedding=emb,
                model="model",
                messages_hash=f"hash-{i}",
                response_json=_response(f"resp-{i}"),
            )
            keys.append(key)

        # In-memory thresholds should match DB entry count
        stats = await vc.stats()
        assert len(vc._thresholds) == stats.total_entries
    finally:
        await vc.close()


# -------------------------------------------------------------------
# CacheManager wiring
# -------------------------------------------------------------------


@pytest.mark.unit
async def test_cache_manager_passes_limits(tmp_path: Path) -> None:
    """CacheManager should pass eviction limits to tier constructors."""
    from llmhosts.cache.manager import CacheManager

    mgr = CacheManager(
        db_path=tmp_path / "mgr",
        exact_max_entries=42,
        namespace_max_namespaces=7,
        vcache_max_bytes=1024,
    )

    # Verify exact tier got the limit
    assert mgr.exact._max_entries == 42

    # Namespace/vCache may be None if numpy not installed
    if mgr.namespace is not None:
        assert mgr.namespace._max_namespaces == 7
    if mgr.vcache is not None:
        assert mgr.vcache._max_bytes == 1024


# -------------------------------------------------------------------
# Prometheus metric
# -------------------------------------------------------------------


@pytest.mark.unit
def test_prometheus_cache_eviction_metric() -> None:
    """The cache eviction Prometheus counter should exist and be incrementable."""
    try:
        from llmhosts.metrics.prometheus import cache_evictions_total, record_cache_eviction

        # Record some evictions
        record_cache_eviction("exact", 5)
        record_cache_eviction("namespace", 2)
        record_cache_eviction("vcache", 1)

        # Verify the counter labels exist (won't raise)
        assert cache_evictions_total.labels(tier="exact") is not None
        assert cache_evictions_total.labels(tier="namespace") is not None
        assert cache_evictions_total.labels(tier="vcache") is not None
    except ImportError:
        pytest.skip("prometheus_client not available")


# -------------------------------------------------------------------
# Config defaults
# -------------------------------------------------------------------


@pytest.mark.unit
def test_config_has_eviction_fields() -> None:
    """CacheConfig should have the three eviction limit fields with sensible defaults."""
    from llmhosts.config import CacheConfig

    cfg = CacheConfig()
    assert cfg.exact_max_entries == 10_000
    assert cfg.namespace_max_namespaces == 500
    assert cfg.vcache_max_bytes == 256 * 1024 * 1024


# -------------------------------------------------------------------
# Stats models include eviction_count
# -------------------------------------------------------------------


@pytest.mark.unit
def test_cache_stats_has_eviction_count() -> None:
    """CacheStats model should include eviction_count field."""
    from llmhosts.cache.models import CacheStats

    CacheStats.model_rebuild()
    stats = CacheStats(
        total_entries=0,
        total_size_bytes=0,
        hit_count=0,
        miss_count=0,
        hit_rate=0.0,
        eviction_count=42,
        entries_by_model={},
        oldest_entry=None,
        newest_entry=None,
    )
    assert stats.eviction_count == 42


@pytest.mark.unit
def test_namespace_stats_has_eviction_count() -> None:
    """NamespaceStats model should include eviction_count field."""
    from llmhosts.cache.namespace import NamespaceStats

    stats = NamespaceStats(
        total_namespaces=0,
        total_entries=0,
        eviction_count=7,
        entries_by_namespace={},
        hit_rate_by_namespace={},
    )
    assert stats.eviction_count == 7


@pytest.mark.unit
def test_vcache_stats_has_eviction_count() -> None:
    """VCacheStats model should include eviction_count field."""
    from llmhosts.cache.vcache import VCacheStats

    stats = VCacheStats(
        total_entries=0,
        total_queries=0,
        total_hits=0,
        total_misses=0,
        hit_rate=0.0,
        eviction_count=3,
        empirical_fp_rate=0.0,
        delta_target=0.01,
        avg_threshold=0.85,
        min_threshold=0.85,
        max_threshold=0.85,
        threshold_distribution={},
    )
    assert stats.eviction_count == 3
