"""
Tests for Track C2 — Redis translation cache.

Covers:
  - make_cache_key: stable across calls, distinct inputs → distinct keys
  - hash_prompt / hash_glossary_terms
  - _LRUBackend: get/set, eviction order, stats
  - _RedisBackend: connection probe, fallback to LRU on error
  - TranslationCache: auto backend selection, user_id namespacing,
    prompt/glossary isolation
  - get_cache: singleton, reset_cache_for_tests
"""
import os
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from services.translation_cache import (
    make_cache_key,
    hash_prompt,
    hash_glossary_terms,
    _LRUBackend,
    _RedisBackend,
    TranslationCache,
    get_cache,
    reset_cache_for_tests,
)


# ============================================================================
# make_cache_key
# ============================================================================

class TestMakeCacheKey:
    def test_stable_across_calls(self):
        k1 = make_cache_key("Hello", "fr", "en", "google")
        k2 = make_cache_key("Hello", "fr", "en", "google")
        assert k1 == k2

    def test_different_target_different_key(self):
        k1 = make_cache_key("Hello", "fr", "en", "google")
        k2 = make_cache_key("Hello", "es", "en", "google")
        assert k1 != k2

    def test_different_provider_different_key(self):
        k1 = make_cache_key("Hello", "fr", "en", "google")
        k2 = make_cache_key("Hello", "fr", "en", "openai")
        assert k1 != k2

    def test_different_text_different_key(self):
        k1 = make_cache_key("Hello", "fr", "en", "google")
        k2 = make_cache_key("Goodbye", "fr", "en", "google")
        assert k1 != k2

    def test_user_namespacing(self):
        k1 = make_cache_key("Hello", "fr", "en", "google", user_id="alice")
        k2 = make_cache_key("Hello", "fr", "en", "google", user_id="bob")
        assert k1 != k2, "Different users should not share cache entries"

    def test_same_user_same_key(self):
        k1 = make_cache_key("Hello", "fr", "en", "google", user_id="alice")
        k2 = make_cache_key("Hello", "fr", "en", "google", user_id="alice")
        assert k1 == k2

    def test_anon_default(self):
        k1 = make_cache_key("Hello", "fr", "en", "google")
        k2 = make_cache_key("Hello", "fr", "en", "google", user_id=None)
        assert k1 == k2, "user_id=None should equal the default anonymous user"

    def test_prompt_isolation(self):
        k1 = make_cache_key("Hello", "fr", "en", "google", custom_prompt_hash="abc")
        k2 = make_cache_key("Hello", "fr", "en", "google", custom_prompt_hash="xyz")
        assert k1 != k2, "Different prompts should not share cache entries"

    def test_glossary_isolation(self):
        k1 = make_cache_key("Hello", "fr", "en", "google", glossary_hash="g1")
        k2 = make_cache_key("Hello", "fr", "en", "google", glossary_hash="g2")
        assert k1 != k2

    def test_returns_hex_string(self):
        k = make_cache_key("Hello", "fr", "en", "google")
        assert isinstance(k, str)
        assert len(k) == 64  # sha256 hex digest length
        assert all(c in "0123456789abcdef" for c in k)


# ============================================================================
# Hash helpers
# ============================================================================

class TestHashHelpers:
    def test_hash_prompt_none(self):
        assert hash_prompt(None) is None

    def test_hash_prompt_empty(self):
        assert hash_prompt("") is None

    def test_hash_prompt_stable(self):
        h1 = hash_prompt("Translate this")
        h2 = hash_prompt("Translate this")
        assert h1 == h2

    def test_hash_prompt_different_inputs(self):
        h1 = hash_prompt("A")
        h2 = hash_prompt("B")
        assert h1 != h2

    def test_hash_prompt_short(self):
        h = hash_prompt("test")
        # We truncate to 16 chars in the implementation
        assert len(h) == 16

    def test_hash_glossary_none(self):
        assert hash_glossary_terms(None) is None

    def test_hash_glossary_empty(self):
        assert hash_glossary_terms([]) is None

    def test_hash_glossary_stable(self):
        terms = [{"source": "Hello", "target": "Bonjour"}]
        h1 = hash_glossary_terms(terms)
        h2 = hash_glossary_terms(terms)
        assert h1 == h2

    def test_hash_glossary_order_invariant(self):
        """The order of terms should not change the hash (sorted internally)."""
        a = [
            {"source": "Hello", "target": "Bonjour"},
            {"source": "World", "target": "Monde"},
        ]
        b = [
            {"source": "World", "target": "Monde"},
            {"source": "Hello", "target": "Bonjour"},
        ]
        assert hash_glossary_terms(a) == hash_glossary_terms(b)

    def test_hash_glossary_metadata_invariant(self):
        """The name/id of the glossary should not affect the hash."""
        a = [{"source": "X", "target": "Y", "name": "g1", "id": "g1"}]
        b = [{"source": "X", "target": "Y", "name": "g2", "id": "g2"}]
        assert hash_glossary_terms(a) == hash_glossary_terms(b)


# ============================================================================
# LRU backend
# ============================================================================

class TestLRUBackend:
    def test_get_missing_returns_none(self):
        c = _LRUBackend()
        assert c.get("nope") is None
        assert c.misses == 1

    def test_set_and_get(self):
        c = _LRUBackend()
        c.set("k", "v")
        assert c.get("k") == "v"
        assert c.hits == 1

    def test_overwrite(self):
        c = _LRUBackend()
        c.set("k", "v1")
        c.set("k", "v2")
        assert c.get("k") == "v2"
        assert len(c._cache) == 1

    def test_lru_eviction(self):
        c = _LRUBackend(maxsize=2)
        c.set("a", "1")
        c.set("b", "2")
        c.set("c", "3")  # Should evict "a" (least recently used)
        assert c.get("a") is None
        assert c.get("b") == "2"
        assert c.get("c") == "3"

    def test_get_promotes_to_mru(self):
        c = _LRUBackend(maxsize=2)
        c.set("a", "1")
        c.set("b", "2")
        c.get("a")  # Promote "a"
        c.set("c", "3")  # Should evict "b" (now LRU)
        assert c.get("a") == "1"
        assert c.get("b") is None
        assert c.get("c") == "3"

    def test_clear(self):
        c = _LRUBackend()
        c.set("a", "1")
        c.set("b", "2")
        c.clear()
        assert c.get("a") is None
        # After clear, both counters are reset
        assert c.hits == 0
        # The get after clear is a miss (1)
        assert c.misses == 1

    def test_stats(self):
        c = _LRUBackend(maxsize=10)
        c.set("a", "1")
        c.get("a")  # hit
        c.get("b")  # miss
        s = c.stats()
        assert s["backend"] == "lru"
        assert s["hits"] == 1
        assert s["misses"] == 1
        assert s["size"] == 1
        assert s["maxsize"] == 10

    def test_thread_safety(self):
        c = _LRUBackend(maxsize=100)
        errors = []

        def worker(i):
            try:
                for j in range(100):
                    c.set(f"k_{i}_{j}", f"v_{i}_{j}")
                    c.get(f"k_{i}_{j}")
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert errors == []


# ============================================================================
# Redis backend — fallback behavior
# ============================================================================

class TestRedisBackendFallback:
    """When Redis is unavailable, the backend should fall back to LRU."""

    def test_redis_unavailable_falls_back_to_lru(self):
        lru = _LRUBackend()
        backend = _RedisBackend(
            url="redis://invalid-host:9999/0",
            lru_fallback=lru,
        )
        # First call probes the connection, fails, and falls back
        assert backend.get("test_key") is None  # miss in LRU
        backend.set("test_key", "hello")
        # Second get should hit the LRU
        assert backend.get("test_key") == "hello"
        # Errors should have been counted
        assert backend.errors >= 1
        # The backend should not have a client
        assert backend._client is None

    def test_redis_set_falls_back_to_lru(self):
        lru = _LRUBackend()
        backend = _RedisBackend(
            url="redis://invalid-host:9999/0",
            lru_fallback=lru,
        )
        backend.set("k", "v")
        # The LRU should have it
        assert lru.get("k") == "v"

    def test_stats_includes_lru(self):
        lru = _LRUBackend()
        backend = _RedisBackend(
            url="redis://invalid-host:9999/0",
            lru_fallback=lru,
        )
        backend.set("k", "v")
        stats = backend.stats()
        assert stats["backend"] == "redis"
        assert stats["available"] is False
        assert stats["errors"] >= 1
        assert "lru_size" in stats


# ============================================================================
# Redis backend — happy path with a mock
# ============================================================================

class TestRedisBackendWithMock:
    """When Redis is reachable, the backend should use it."""

    def _build_backend_with_mock_client(self, client):
        """Build a RedisBackend with a pre-set mock client."""
        lru = _LRUBackend()
        backend = _RedisBackend(
            url="redis://fake:6379/0",
            lru_fallback=lru,
        )
        backend._client = client
        backend._available = True
        return backend, lru

    def test_get_uses_redis(self):
        client = MagicMock()
        client.get.return_value = "cached_value"
        backend, lru = self._build_backend_with_mock_client(client)

        result = backend.get("k")
        assert result == "cached_value"
        client.get.assert_called_once()
        # Should also have been written to LRU
        assert lru.get("k") == "cached_value"

    def test_get_miss_redis(self):
        client = MagicMock()
        client.get.return_value = None
        backend, lru = self._build_backend_with_mock_client(client)

        result = backend.get("k")
        assert result is None
        assert backend.misses == 1

    def test_set_uses_redis(self):
        client = MagicMock()
        backend, lru = self._build_backend_with_mock_client(client)

        backend.set("k", "v", ttl=60)
        # Should have called Redis SET
        client.set.assert_called_once()
        args = client.set.call_args[0]
        assert args[1] == "v"  # value
        # The TTL is passed as a keyword arg (ex=ttl)
        assert client.set.call_args[1]["ex"] == 60

    def test_set_uses_default_ttl(self):
        client = MagicMock()
        backend, lru = self._build_backend_with_mock_client(client)

        backend.set("k", "v")
        # Default TTL = 86400 (24h) is passed as ex=...
        assert client.set.call_args[1]["ex"] == 86400

    def test_redis_error_during_get_resets_client(self):
        client = MagicMock()
        client.get.side_effect = RuntimeError("redis down")
        backend, lru = self._build_backend_with_mock_client(client)

        result = backend.get("k")
        # Falls back to LRU
        assert result is None
        # Client should be reset so we re-probe next time
        assert backend._client is None
        assert backend._available is False
        assert backend.errors == 1

    def test_redis_error_during_set_resets_client(self):
        client = MagicMock()
        client.set.side_effect = RuntimeError("redis down")
        backend, lru = self._build_backend_with_mock_client(client)

        backend.set("k", "v")
        # Should have fallen back to LRU
        assert lru.get("k") == "v"
        assert backend._client is None
        assert backend.errors == 1

    def test_namespace_prefix(self):
        client = MagicMock()
        client.get.return_value = None
        backend, lru = self._build_backend_with_mock_client(client)

        backend.get("mykey")
        called_key = client.get.call_args[0][0]
        assert called_key.startswith("translate_cache:")
        assert called_key.endswith(":mykey")

    def test_custom_namespace(self):
        client = MagicMock()
        client.get.return_value = None
        lru = _LRUBackend()
        backend = _RedisBackend(
            url="redis://fake:6379/0",
            namespace="custom_ns",
            lru_fallback=lru,
        )
        backend._client = client
        backend._available = True

        backend.get("mykey")
        called_key = client.get.call_args[0][0]
        assert called_key.startswith("custom_ns:")


# ============================================================================
# TranslationCache — high-level API
# ============================================================================

class TestTranslationCache:
    def test_default_backend_is_lru(self, monkeypatch):
        """No REDIS_CACHE_ENABLED → LRU backend."""
        monkeypatch.delenv("REDIS_CACHE_ENABLED", raising=False)
        monkeypatch.delenv("REDIS_URL", raising=False)
        cache = TranslationCache(backend="auto")
        assert cache.backend_name == "lru"

    def test_explicit_lru_backend(self, monkeypatch):
        monkeypatch.setenv("REDIS_CACHE_ENABLED", "true")
        cache = TranslationCache(backend="lru")
        assert cache.backend_name == "lru"

    def test_get_and_set(self):
        cache = TranslationCache(backend="lru")
        cache.set("Hello", "fr", "en", "google", "Bonjour")
        assert cache.get("Hello", "fr", "en", "google") == "Bonjour"

    def test_get_miss(self):
        cache = TranslationCache(backend="lru")
        assert cache.get("Nope", "fr", "en", "google") is None

    def test_user_isolation(self):
        cache = TranslationCache(backend="lru")
        cache.set("Hello", "fr", "en", "google", "Bonjour", user_id="alice")
        cache.set("Hello", "fr", "en", "google", "Hola", user_id="bob")
        assert cache.get("Hello", "fr", "en", "google", user_id="alice") == "Bonjour"
        assert cache.get("Hello", "fr", "en", "google", user_id="bob") == "Hola"

    def test_prompt_isolation(self):
        cache = TranslationCache(backend="lru")
        cache.set("Hello", "fr", "en", "google", "V1",
                  custom_prompt_hash="p1")
        cache.set("Hello", "fr", "en", "google", "V2",
                  custom_prompt_hash="p2")
        assert cache.get("Hello", "fr", "en", "google", custom_prompt_hash="p1") == "V1"
        assert cache.get("Hello", "fr", "en", "google", custom_prompt_hash="p2") == "V2"

    def test_glossary_isolation(self):
        cache = TranslationCache(backend="lru")
        cache.set("Hello", "fr", "en", "google", "V1",
                  glossary_hash="g1")
        cache.set("Hello", "fr", "en", "google", "V2",
                  glossary_hash="g2")
        assert cache.get("Hello", "fr", "en", "google", glossary_hash="g1") == "V1"
        assert cache.get("Hello", "fr", "en", "google", glossary_hash="g2") == "V2"

    def test_clear(self):
        cache = TranslationCache(backend="lru")
        cache.set("k", "fr", "en", "google", "v")
        cache.clear()
        assert cache.get("k", "fr", "en", "google") is None

    def test_stats_returns_dict(self):
        cache = TranslationCache(backend="lru")
        cache.set("k", "fr", "en", "google", "v")
        cache.get("k", "fr", "en", "google")
        s = cache.stats()
        assert isinstance(s, dict)
        assert s["backend"] == "lru"


# ============================================================================
# Singleton accessor
# ============================================================================

class TestGetCache:
    def test_returns_singleton(self, monkeypatch):
        reset_cache_for_tests()
        c1 = get_cache()
        c2 = get_cache()
        assert c1 is c2

    def test_reset_for_tests(self, monkeypatch):
        reset_cache_for_tests()
        c1 = get_cache()
        reset_cache_for_tests()
        c2 = get_cache()
        assert c1 is not c2

    def test_singleton_thread_safe(self, monkeypatch):
        reset_cache_for_tests()
        caches = []
        errors = []

        def worker():
            try:
                caches.append(get_cache())
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=worker) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert errors == []
        assert all(c is caches[0] for c in caches)


# ============================================================================
# End-to-end: integrate with hash_prompt / hash_glossary
# ============================================================================

class TestE2EHashIntegration:
    def test_real_prompt_and_glossary(self):
        """A realistic scenario: same text, different prompt/glossary
        should give different cache keys and thus different entries."""
        cache = TranslationCache(backend="lru")

        prompt_a = "Translate this as a formal document."
        prompt_b = "Translate this as a casual chat message."
        glossary_a = [{"source": "API", "target": "API"}]
        glossary_b = [{"source": "API", "target": "interface"}]

        cache.set(
            "Hello", "fr", "en", "google",
            "Bonjour (formel)",
            custom_prompt_hash=hash_prompt(prompt_a),
            glossary_hash=hash_glossary_terms(glossary_a),
        )
        cache.set(
            "Hello", "fr", "en", "google",
            "Salut (décontracté)",
            custom_prompt_hash=hash_prompt(prompt_b),
            glossary_hash=hash_glossary_terms(glossary_b),
        )

        # Reading with the same prompt/glossary should give the right entry
        assert cache.get(
            "Hello", "fr", "en", "google",
            custom_prompt_hash=hash_prompt(prompt_a),
            glossary_hash=hash_glossary_terms(glossary_a),
        ) == "Bonjour (formel)"
        assert cache.get(
            "Hello", "fr", "en", "google",
            custom_prompt_hash=hash_prompt(prompt_b),
            glossary_hash=hash_glossary_terms(glossary_b),
        ) == "Salut (décontracté)"
