"""Tests for the Redis caching layer and PatternManager cache integration.

Uses a simple in-memory dict-based fake to test cache logic without
requiring a running Redis server.  Also tests NullCache and the
PatternCache high-level API.
"""
import json
import pytest

from greymatter_core.cache import (
    KEY_PREFIX,
    DEFAULT_TTL,
    SESSION_LOAD_TTL,
    SEARCH_TTL,
    COUNT_TTL,
    NullCache,
    PatternCache,
    RedisCache,
    _make_key,
    _hash_params,
)
from greymatter_core.db import SQLiteDatabase
from greymatter_core.patterns import PatternManager


# ── Fake in-memory cache backend ─────────────────────────────────────


class FakeCache:
    """Dict-based cache that implements the Cache protocol for testing."""

    def __init__(self):
        self._store: dict[str, str] = {}
        self._ttls: dict[str, int] = {}
        self._hits = 0
        self._misses = 0

    @property
    def enabled(self) -> bool:
        return True

    @property
    def stats(self) -> dict[str, int]:
        return {"hits": self._hits, "misses": self._misses, "enabled": 1}

    def get(self, key: str) -> object:
        raw = self._store.get(key)
        if raw is None:
            self._misses += 1
            return None
        self._hits += 1
        return json.loads(raw)

    def set(self, key: str, value: object, ttl: int = DEFAULT_TTL) -> None:
        self._store[key] = json.dumps(value, default=str)
        self._ttls[key] = ttl

    def delete(self, key: str) -> None:
        self._store.pop(key, None)
        self._ttls.pop(key, None)

    def invalidate_patterns(self) -> None:
        to_delete = [k for k in self._store if k.startswith(KEY_PREFIX)]
        for k in to_delete:
            self._store.pop(k, None)
            self._ttls.pop(k, None)

    def close(self) -> None:
        self._store.clear()


# ── Fixtures ─────────────────────────────────────────────────────────


@pytest.fixture
def fake_cache():
    return FakeCache()


@pytest.fixture
def pattern_cache(fake_cache):
    return PatternCache(fake_cache)


@pytest.fixture
def db():
    return SQLiteDatabase(":memory:")


@pytest.fixture
def patterns_no_cache(db):
    """PatternManager without cache (backward compatibility)."""
    return PatternManager(db)


@pytest.fixture
def patterns_cached(db, pattern_cache):
    """PatternManager with an active cache."""
    return PatternManager(db, cache=pattern_cache)


# ── NullCache tests ──────────────────────────────────────────────────


class TestNullCache:
    def test_get_always_returns_none(self):
        nc = NullCache()
        nc.set("foo", {"bar": 1})
        assert nc.get("foo") is None

    def test_enabled_is_false(self):
        nc = NullCache()
        assert nc.enabled is False

    def test_stats_track_misses(self):
        nc = NullCache()
        nc.get("a")
        nc.get("b")
        assert nc.stats == {"hits": 0, "misses": 2, "enabled": 0}

    def test_operations_are_noop(self):
        nc = NullCache()
        # Should not raise
        nc.set("k", "v")
        nc.delete("k")
        nc.invalidate_patterns()
        nc.close()


# ── Key construction tests ───────────────────────────────────────────


class TestKeyConstruction:
    def test_make_key_prefix(self):
        key = _make_key("id", "pat-abc")
        assert key == "gm:pat:id:pat-abc"

    def test_make_key_session_load(self):
        key = _make_key("session_load", "20")
        assert key == "gm:pat:session_load:20"

    def test_hash_params_deterministic(self):
        h1 = _hash_params(domain="testing", type="process")
        h2 = _hash_params(domain="testing", type="process")
        assert h1 == h2

    def test_hash_params_different_inputs(self):
        h1 = _hash_params(domain="testing")
        h2 = _hash_params(domain="coding")
        assert h1 != h2

    def test_hash_params_order_independent(self):
        h1 = _hash_params(a="1", b="2")
        h2 = _hash_params(b="2", a="1")
        assert h1 == h2


# ── PatternCache tests ───────────────────────────────────────────────


class TestPatternCache:
    def test_get_set_pattern(self, pattern_cache, fake_cache):
        assert pattern_cache.get_pattern("pat-1") is None
        pattern_cache.set_pattern("pat-1", {"id": "pat-1", "title": "Test"})
        result = pattern_cache.get_pattern("pat-1")
        assert result == {"id": "pat-1", "title": "Test"}

    def test_get_set_session_load(self, pattern_cache):
        assert pattern_cache.get_session_load(20) is None
        data = [{"id": "pat-1"}, {"id": "pat-2"}]
        pattern_cache.set_session_load(20, data)
        assert pattern_cache.get_session_load(20) == data

    def test_get_set_search(self, pattern_cache):
        assert pattern_cache.get_search(domain="testing") is None
        data = [{"id": "pat-1"}]
        pattern_cache.set_search(data, domain="testing")
        assert pattern_cache.get_search(domain="testing") == data

    def test_get_set_list_summary(self, pattern_cache):
        assert pattern_cache.get_list_summary(25, None) is None
        data = {"total": 1, "returned": 1, "patterns": [{"id": "pat-1"}]}
        pattern_cache.set_list_summary(25, None, data)
        assert pattern_cache.get_list_summary(25, None) == data

    def test_get_set_count(self, pattern_cache):
        assert pattern_cache.get_count(None) is None
        pattern_cache.set_count(None, 42)
        assert pattern_cache.get_count(None) == 42

    def test_invalidate_clears_all(self, pattern_cache, fake_cache):
        pattern_cache.set_pattern("pat-1", {"id": "pat-1"})
        pattern_cache.set_session_load(20, [{"id": "pat-1"}])
        pattern_cache.set_count(None, 5)
        pattern_cache.set_list_summary(25, None, {"total": 5})

        # Verify cache is populated
        assert pattern_cache.get_pattern("pat-1") is not None
        assert pattern_cache.get_session_load(20) is not None
        assert pattern_cache.get_count(None) is not None

        # Invalidate
        pattern_cache.invalidate("pat-1")

        # All gone
        assert pattern_cache.get_pattern("pat-1") is None
        assert pattern_cache.get_session_load(20) is None
        assert pattern_cache.get_count(None) is None

    def test_invalidate_without_pattern_id(self, pattern_cache):
        pattern_cache.set_pattern("pat-1", {"id": "pat-1"})
        pattern_cache.set_count(None, 5)

        pattern_cache.invalidate()  # No specific pattern_id

        assert pattern_cache.get_pattern("pat-1") is None
        assert pattern_cache.get_count(None) is None

    def test_enabled_property(self, pattern_cache):
        assert pattern_cache.enabled is True

    def test_stats(self, pattern_cache):
        pattern_cache.get_pattern("miss")
        pattern_cache.set_pattern("hit", {"id": "hit"})
        pattern_cache.get_pattern("hit")
        stats = pattern_cache.stats
        assert stats["hits"] == 1
        assert stats["misses"] == 1
        assert stats["enabled"] == 1


# ── PatternManager + Cache integration tests ─────────────────────────


class TestPatternManagerCached:
    """Test that PatternManager correctly uses the cache layer."""

    def test_get_caches_result(self, patterns_cached, fake_cache):
        """First get() should miss cache and populate it; second should hit."""
        p = patterns_cached.create(
            type="process", domain="testing", title="TDD", observation="Write tests first"
        )
        pid = p["id"]

        # After create(), the cache was invalidated, then get() was called
        # internally by create() which populated the cache.
        # A second get should hit the cache.
        result = patterns_cached.get(pid)
        assert result is not None
        assert result["title"] == "TDD"
        assert fake_cache._hits >= 1

    def test_get_nonexistent_not_cached(self, patterns_cached, fake_cache):
        """Nonexistent patterns should not be cached."""
        result = patterns_cached.get("pat-nonexistent")
        assert result is None
        # Should not have stored anything for this key
        key = _make_key("id", "pat-nonexistent")
        assert key not in fake_cache._store

    def test_create_invalidates_cache(self, patterns_cached, pattern_cache, fake_cache):
        """Creating a pattern should invalidate aggregate caches."""
        # Pre-populate some cache entries
        pattern_cache.set_count(None, 0)
        pattern_cache.set_session_load(20, [])
        assert pattern_cache.get_count(None) == 0

        # Create a pattern — should invalidate
        patterns_cached.create(
            type="process", domain="testing", title="A", observation="B"
        )

        # Aggregate caches should be cleared
        assert pattern_cache.get_count(None) is None
        assert pattern_cache.get_session_load(20) is None

    def test_add_evidence_invalidates_cache(self, patterns_cached, pattern_cache, fake_cache):
        """Adding evidence should invalidate the specific pattern and aggregates."""
        p = patterns_cached.create(
            type="process", domain="testing", title="A", observation="A"
        )
        pid = p["id"]

        # Populate count cache
        pattern_cache.set_count(None, 1)

        patterns_cached.add_evidence(pid, session_id="s-1")

        # Cache should be invalidated
        assert pattern_cache.get_count(None) is None
        assert pattern_cache.get_pattern(pid) is None

    def test_delete_invalidates_cache(self, patterns_cached, pattern_cache):
        """Deleting a pattern should invalidate its cache entry and aggregates."""
        p = patterns_cached.create(
            type="process", domain="testing", title="A", observation="A"
        )
        pid = p["id"]

        # Ensure cached
        patterns_cached.get(pid)
        pattern_cache.set_count(None, 1)

        patterns_cached.delete(pid)

        assert pattern_cache.get_pattern(pid) is None
        assert pattern_cache.get_count(None) is None

    def test_session_load_caches_result(self, patterns_cached, pattern_cache, db):
        """get_for_session_load should be cached on second call."""
        # Create a medium-confidence pattern
        p = patterns_cached.create(
            type="process", domain="testing", title="A", observation="A"
        )
        for i in range(3):
            patterns_cached.add_evidence(p["id"], session_id=f"s-{i}")

        # First call — cache miss, populates cache
        result1 = patterns_cached.get_for_session_load(20)
        assert len(result1) == 1

        # Second call — should hit cache
        result2 = patterns_cached.get_for_session_load(20)
        assert len(result2) == 1
        assert result2[0]["id"] == result1[0]["id"]

    def test_list_summary_caches_result(self, patterns_cached, pattern_cache):
        """list_summary should cache and return cached results."""
        patterns_cached.create(
            type="process", domain="testing", title="A", observation="A"
        )

        result1 = patterns_cached.list_summary()
        assert result1["total"] == 1

        result2 = patterns_cached.list_summary()
        assert result2["total"] == 1

    def test_count_caches_result(self, patterns_cached, pattern_cache, fake_cache):
        """count should cache the result."""
        patterns_cached.create(
            type="process", domain="testing", title="A", observation="A"
        )

        c1 = patterns_cached.count()
        assert c1 == 1

        c2 = patterns_cached.count()
        assert c2 == 1
        # Should have hit cache on second call
        assert fake_cache._hits >= 1


# ── Backward compatibility tests ─────────────────────────────────────


class TestPatternManagerNoCache:
    """Verify PatternManager works identically without a cache."""

    def test_create_and_get(self, patterns_no_cache):
        p = patterns_no_cache.create(
            type="process", domain="testing", title="TDD", observation="Test first"
        )
        assert p["id"].startswith("pat-")
        fetched = patterns_no_cache.get(p["id"])
        assert fetched["title"] == "TDD"

    def test_session_load(self, patterns_no_cache):
        p = patterns_no_cache.create(
            type="process", domain="testing", title="A", observation="A"
        )
        for i in range(3):
            patterns_no_cache.add_evidence(p["id"], session_id=f"s-{i}")
        results = patterns_no_cache.get_for_session_load()
        assert len(results) == 1

    def test_delete(self, patterns_no_cache):
        p = patterns_no_cache.create(
            type="process", domain="testing", title="A", observation="A"
        )
        assert patterns_no_cache.delete(p["id"]) is True
        assert patterns_no_cache.get(p["id"]) is None

    def test_count(self, patterns_no_cache):
        patterns_no_cache.create(
            type="process", domain="testing", title="A", observation="A"
        )
        assert patterns_no_cache.count() == 1
        assert patterns_no_cache.count(confidence="low") == 1
        assert patterns_no_cache.count(confidence="high") == 0


# ── RedisCache.connect fallback tests ────────────────────────────────


class TestRedisCacheConnect:
    def test_empty_url_returns_null_cache(self):
        result = RedisCache.connect("")
        assert isinstance(result, NullCache)

    def test_invalid_url_returns_null_cache(self):
        result = RedisCache.connect("redis://nonexistent-host:9999/0")
        assert isinstance(result, NullCache)

    def test_none_url_returns_null_cache(self):
        # Passing empty string explicitly
        result = RedisCache.connect("")
        assert isinstance(result, NullCache)
        assert result.enabled is False
