"""Tests for QueryCache and AdaptiveThreshold."""

from __future__ import annotations

from claude_engram.cache import DEFAULT_SIMILARITY_THRESHOLD, AdaptiveThreshold, QueryCache


class TestAdaptiveThreshold:
    """Tests for AdaptiveThreshold EMA-based threshold adjustment."""

    def test_initial_value(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        assert at.threshold == 0.45

    def test_custom_initial_value(self) -> None:
        at = AdaptiveThreshold(initial=0.75)
        assert at.threshold == 0.75

    def test_no_adjustment_during_warmup(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        # Record 19 misses (all during warmup)
        for _ in range(19):
            at.record(hit=False)
        assert at.threshold == 0.45  # No change during warmup
        assert at.total_queries == 19

    def test_warmup_period_is_20(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        assert at.WARMUP_QUERIES == 20

    def test_lowers_threshold_on_all_misses_after_warmup(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        # Get through warmup with all misses
        for _ in range(20):
            at.record(hit=False)
        # EMA should be near 0 (all misses) -> below HEALTHY_HIT_MIN -> lower threshold
        assert at.threshold < 0.45

    def test_raises_threshold_on_all_hits_after_warmup(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        # EMA alpha is ~0.02 (window=100), need many hits to get
        # EMA above HEALTHY_HIT_MAX (0.60) and trigger upward adjustment
        for _ in range(300):
            at.record(hit=True)
        assert at.threshold > 0.45

    def test_hard_min_floor(self) -> None:
        at = AdaptiveThreshold(initial=0.31)
        # Many misses to push threshold down
        for _ in range(500):
            at.record(hit=False)
        assert at.threshold >= at.HARD_MIN
        assert at.threshold >= 0.30

    def test_hard_max_ceiling(self) -> None:
        at = AdaptiveThreshold(initial=0.69)
        # Many hits to push threshold up
        for _ in range(500):
            at.record(hit=True)
        assert at.threshold <= at.HARD_MAX
        assert at.threshold <= 0.70

    def test_hit_rate_starts_at_zero(self) -> None:
        at = AdaptiveThreshold()
        assert at.hit_rate == 0.0

    def test_total_queries_tracks_count(self) -> None:
        at = AdaptiveThreshold()
        at.record(hit=True)
        at.record(hit=False)
        at.record(hit=True)
        assert at.total_queries == 3

    def test_reset_clears_state(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        for _ in range(50):
            at.record(hit=True)
        at.reset()
        assert at.threshold == 0.45
        assert at.total_queries == 0
        assert at.hit_rate == 0.0

    def test_reset_with_new_initial(self) -> None:
        at = AdaptiveThreshold(initial=0.45)
        at.reset(initial=0.75)
        assert at.threshold == 0.75

    def test_adjustment_step_is_0_01(self) -> None:
        at = AdaptiveThreshold()
        assert at.ADJUSTMENT_STEP == 0.01

    def test_stable_in_healthy_range(self) -> None:
        """When hit rate stays within [HEALTHY_HIT_MIN, HEALTHY_HIT_MAX], threshold is stable."""
        at = AdaptiveThreshold(initial=0.45)
        # ~30% hit rate: every 3rd call is a hit (0.33 — within 0.15..0.60)
        for i in range(200):
            at.record(hit=(i % 3 == 0))
        threshold_after_warmup = at.threshold
        # Continue with same pattern — threshold should not drift
        for i in range(100):
            at.record(hit=(i % 3 == 0))
        assert at.threshold == threshold_after_warmup


class TestQueryCache:
    """Tests for QueryCache two-tier cache."""

    def test_exact_match_put_then_get(self) -> None:
        cache = QueryCache()
        cache.put("test query", {"results": [{"content": "hello"}]})
        result = cache.get_exact("test query")
        assert result is not None
        assert result["results"][0]["content"] == "hello"

    def test_exact_miss_returns_none(self) -> None:
        cache = QueryCache()
        result = cache.get_exact("nonexistent query")
        assert result is None

    def test_different_queries_different_keys(self) -> None:
        cache = QueryCache()
        cache.put("query A", {"data": "A"})
        cache.put("query B", {"data": "B"})
        assert cache.get_exact("query A")["data"] == "A"
        assert cache.get_exact("query B")["data"] == "B"

    def test_stats_hits_exact(self) -> None:
        cache = QueryCache()
        cache.put("q", {"result": True})
        cache.get_exact("q")
        cache.get_exact("q")
        assert cache.stats["hits_exact"] == 2

    def test_stats_misses_tracked(self) -> None:
        cache = QueryCache()
        cache.record_miss()
        cache.record_miss()
        assert cache.stats["misses"] == 2

    def test_stats_semantic_hits_tracked(self) -> None:
        cache = QueryCache()
        cache.record_semantic_hit()
        assert cache.stats["hits_semantic"] == 1

    def test_clear_resets_everything(self) -> None:
        cache = QueryCache()
        cache.put("q", {"data": True})
        cache.record_miss()
        cache.record_semantic_hit()
        cache.clear()

        assert cache.get_exact("q") is None
        assert cache.stats["hits_exact"] == 0
        assert cache.stats["hits_semantic"] == 0
        assert cache.stats["misses"] == 0
        # Verify AdaptiveThreshold was also reset
        assert cache.similarity_threshold == DEFAULT_SIMILARITY_THRESHOLD
        assert cache.stats["ema_hit_rate"] == 0.0
        assert cache.stats["total_semantic_queries"] == 0

    def test_similarity_threshold_property(self) -> None:
        cache = QueryCache(similarity_threshold=0.75)
        assert cache.similarity_threshold == 0.75

    def test_default_similarity_threshold(self) -> None:
        cache = QueryCache()
        assert cache.similarity_threshold == 0.45

    def test_stats_include_threshold_info(self) -> None:
        cache = QueryCache()
        stats = cache.stats
        assert "similarity_threshold" in stats
        assert "ema_hit_rate" in stats
        assert "total_semantic_queries" in stats

    def test_get_exact_does_not_count_miss_in_stats(self) -> None:
        # get_exact returning None should NOT increment misses
        # (misses are tracked separately via record_miss)
        cache = QueryCache()
        cache.get_exact("not found")
        assert cache.stats["misses"] == 0

    def test_maxsize_eviction(self) -> None:
        cache = QueryCache(maxsize=2, ttl=300)
        cache.put("q1", {"data": 1})
        cache.put("q2", {"data": 2})
        cache.put("q3", {"data": 3})  # evicts oldest
        # At least one of the original entries should be evicted
        assert cache.get_exact("q1") is None, "q1 should be evicted (oldest)"
        assert cache.get_exact("q2") is not None, "q2 should remain"
        assert cache.get_exact("q3") is not None, "q3 should remain"


class TestDefaultSimilarityThreshold:
    """Tests for DEFAULT_SIMILARITY_THRESHOLD constant."""

    def test_default_is_0_45(self) -> None:
        assert DEFAULT_SIMILARITY_THRESHOLD == 0.45

    def test_default_matches_query_cache(self) -> None:
        cache = QueryCache()
        assert cache.similarity_threshold == DEFAULT_SIMILARITY_THRESHOLD

    def test_default_within_miniml_range(self) -> None:
        # MiniLM-L6-v2: related content ≈ 0.4-0.6
        assert 0.30 <= DEFAULT_SIMILARITY_THRESHOLD <= 0.60
