"""Tests for rbsp.core.cache — LRU and segment-aware query caching."""

from __future__ import annotations

import threading
import time

import pytest

from rbsp.core.cache import LRUCache, SegmentCache


# =====================================================================
# LRUCache
# =====================================================================


class TestLRUCacheBasic:
    """Core get/put/invalidate/clear behaviour."""

    def test_put_and_get(self):
        c = LRUCache(max_size=8)
        c.put("a", 1)
        assert c.get("a") == 1

    def test_get_missing_returns_none(self):
        c = LRUCache()
        assert c.get("nonexistent") is None

    def test_put_overwrites(self):
        c = LRUCache()
        c.put("k", "old")
        c.put("k", "new")
        assert c.get("k") == "new"

    def test_invalidate_removes_entry(self):
        c = LRUCache()
        c.put("k", 42)
        c.invalidate("k")
        assert c.get("k") is None

    def test_invalidate_missing_is_noop(self):
        c = LRUCache()
        c.invalidate("nope")  # should not raise

    def test_clear(self):
        c = LRUCache()
        c.put("a", 1)
        c.put("b", 2)
        c.clear()
        assert len(c) == 0
        assert c.get("a") is None
        assert c.get("b") is None


class TestLRUCacheEviction:
    """LRU eviction when max_size is exceeded."""

    def test_evicts_oldest(self):
        c = LRUCache(max_size=2)
        c.put("a", 1)
        c.put("b", 2)
        c.put("c", 3)  # should evict "a"
        assert c.get("a") is None
        assert c.get("b") == 2
        assert c.get("c") == 3

    def test_access_refreshes_lru_order(self):
        c = LRUCache(max_size=2)
        c.put("a", 1)
        c.put("b", 2)
        c.get("a")  # refresh "a" — now "b" is oldest
        c.put("c", 3)  # should evict "b"
        assert c.get("a") == 1
        assert c.get("b") is None
        assert c.get("c") == 3

    def test_update_refreshes_lru_order(self):
        c = LRUCache(max_size=2)
        c.put("a", 1)
        c.put("b", 2)
        c.put("a", 10)  # refresh "a"
        c.put("c", 3)  # evicts "b"
        assert c.get("a") == 10
        assert c.get("b") is None

    def test_eviction_count_in_stats(self):
        c = LRUCache(max_size=1)
        c.put("a", 1)
        c.put("b", 2)
        c.put("c", 3)
        s = c.stats()
        assert s["eviction_count"] == 2


class TestLRUCacheTTL:
    """Time-to-live expiry behaviour."""

    def test_expired_entry_returns_none(self):
        c = LRUCache(ttl=0.05)
        c.put("k", "val")
        time.sleep(0.08)
        assert c.get("k") is None

    def test_non_expired_entry_returns_value(self):
        c = LRUCache(ttl=10.0)
        c.put("k", "val")
        assert c.get("k") == "val"

    def test_contains_respects_ttl(self):
        c = LRUCache(ttl=0.05)
        c.put("k", 1)
        assert "k" in c
        time.sleep(0.08)
        assert "k" not in c


class TestLRUCacheStats:
    """Hit/miss/eviction statistics."""

    def test_hit_and_miss_counts(self):
        c = LRUCache()
        c.put("a", 1)
        c.get("a")  # hit
        c.get("b")  # miss
        s = c.stats()
        assert s["hit_count"] == 1
        assert s["miss_count"] == 1

    def test_hit_rate(self):
        c = LRUCache()
        c.put("a", 1)
        c.get("a")
        c.get("a")
        c.get("miss")
        s = c.stats()
        assert s["hit_rate"] == pytest.approx(2 / 3)

    def test_hit_rate_zero_when_empty(self):
        c = LRUCache()
        s = c.stats()
        assert s["hit_rate"] == 0.0

    def test_clear_resets_stats(self):
        c = LRUCache()
        c.put("a", 1)
        c.get("a")
        c.get("miss")
        c.clear()
        s = c.stats()
        assert s["hit_count"] == 0
        assert s["miss_count"] == 0
        assert s["eviction_count"] == 0

    def test_size_in_stats(self):
        c = LRUCache()
        c.put("a", 1)
        c.put("b", 2)
        assert c.stats()["size"] == 2


class TestLRUCacheDunders:
    """__contains__ and __len__."""

    def test_len(self):
        c = LRUCache()
        assert len(c) == 0
        c.put("a", 1)
        assert len(c) == 1
        c.put("b", 2)
        assert len(c) == 2

    def test_contains(self):
        c = LRUCache()
        c.put("a", 1)
        assert "a" in c
        assert "b" not in c


class TestLRUCacheValidation:
    """Constructor validation."""

    def test_invalid_max_size(self):
        with pytest.raises(ValueError, match="max_size"):
            LRUCache(max_size=0)

    def test_invalid_ttl(self):
        with pytest.raises(ValueError, match="ttl"):
            LRUCache(ttl=0.0)

    def test_negative_ttl(self):
        with pytest.raises(ValueError, match="ttl"):
            LRUCache(ttl=-1.0)


class TestLRUCacheThreadSafety:
    """Basic thread-safety smoke test."""

    def test_concurrent_puts(self):
        c = LRUCache(max_size=256)
        errors: list[Exception] = []

        def writer(start: int):
            try:
                for i in range(100):
                    c.put(f"key-{start + i}", i)
            except Exception as exc:
                errors.append(exc)

        threads = [threading.Thread(target=writer, args=(i * 100,)) for i in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors
        assert len(c) <= 256

    def test_concurrent_get_put(self):
        c = LRUCache(max_size=64)
        c.put("shared", 42)
        errors: list[Exception] = []

        def reader():
            try:
                for _ in range(200):
                    c.get("shared")
            except Exception as exc:
                errors.append(exc)

        def writer():
            try:
                for i in range(200):
                    c.put(f"w-{i}", i)
            except Exception as exc:
                errors.append(exc)

        threads = (
            [threading.Thread(target=reader) for _ in range(3)]
            + [threading.Thread(target=writer) for _ in range(2)]
        )
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert not errors


# =====================================================================
# SegmentCache
# =====================================================================


class TestSegmentCacheBasic:
    """Core generation-aware get/put."""

    def test_put_and_get_same_generation(self):
        c = SegmentCache()
        c.put("q", [1, 2, 3], generation=1)
        assert c.get("q", current_generation=1) == [1, 2, 3]

    def test_get_wrong_generation_returns_none(self):
        c = SegmentCache()
        c.put("q", "result", generation=1)
        assert c.get("q", current_generation=2) is None

    def test_stale_entry_is_removed(self):
        c = SegmentCache()
        c.put("q", "result", generation=1)
        c.get("q", current_generation=2)  # stale — removed
        # Even asking with the original generation should miss now.
        assert c.get("q", current_generation=1) is None

    def test_set_generation_implicit(self):
        c = SegmentCache()
        c.set_generation(5)
        c.put("q", "val")  # uses internal generation=5
        assert c.get("q") == "val"  # uses internal generation=5
        c.set_generation(6)
        assert c.get("q") is None  # generation mismatch


class TestSegmentCacheGeneration:
    """Generation advancement and invalidation."""

    def test_advance_invalidates_old_entries(self):
        c = SegmentCache(max_size=16)
        c.set_generation(1)
        c.put("a", "v1")
        c.put("b", "v2")
        c.set_generation(2)
        assert c.get("a") is None
        assert c.get("b") is None

    def test_new_entries_survive_after_generation_bump(self):
        c = SegmentCache()
        c.set_generation(1)
        c.put("old", "x")
        c.set_generation(2)
        c.put("new", "y")
        assert c.get("new") == "y"
        assert c.get("old") is None

    def test_explicit_generation_overrides_internal(self):
        c = SegmentCache()
        c.set_generation(10)
        c.put("q", "val", generation=5)
        assert c.get("q", current_generation=5) == "val"
        assert c.get("q", current_generation=10) is None


class TestSegmentCacheInheritsLRU:
    """SegmentCache should still honour max_size and TTL."""

    def test_eviction(self):
        c = SegmentCache(max_size=2)
        c.set_generation(1)
        c.put("a", 1)
        c.put("b", 2)
        c.put("c", 3)  # evicts "a"
        assert c.get("a") is None
        assert c.get("c") == 3

    def test_ttl_expiry(self):
        c = SegmentCache(ttl=0.05)
        c.set_generation(1)
        c.put("k", "val")
        time.sleep(0.08)
        assert c.get("k") is None

    def test_stats(self):
        c = SegmentCache()
        c.set_generation(1)
        c.put("a", 1)
        c.get("a")  # hit
        c.get("miss")  # miss
        s = c.stats()
        assert s["hit_count"] == 1
        assert s["miss_count"] == 1

    def test_clear(self):
        c = SegmentCache()
        c.set_generation(1)
        c.put("a", 1)
        c.clear()
        assert len(c) == 0


class TestSegmentCacheThreadSafety:
    """Thread-safety for generation-based cache."""

    def test_concurrent_generation_bump(self):
        c = SegmentCache(max_size=128)
        errors: list[Exception] = []

        def writer(gen: int):
            try:
                c.set_generation(gen)
                for i in range(50):
                    c.put(f"k-{gen}-{i}", i, generation=gen)
            except Exception as exc:
                errors.append(exc)

        def reader(gen: int):
            try:
                for i in range(50):
                    c.get(f"k-{gen}-{i}", current_generation=gen)
            except Exception as exc:
                errors.append(exc)

        threads = (
            [threading.Thread(target=writer, args=(g,)) for g in range(4)]
            + [threading.Thread(target=reader, args=(g,)) for g in range(4)]
        )
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert not errors
