"""Tests for FileBackend — covers all four production fixes."""

import asyncio
import datetime
import pytest

from cachestack import FileBackend, FileConfig
from cachestack.serializers import PickleSerializer, JsonSerializer


class _SerializableObj:
    """Module-level class so Pickle can resolve it during deserialization."""
    def __init__(self, x):
        self.x = x
    def __eq__(self, other):
        return isinstance(other, _SerializableObj) and self.x == other.x


@pytest.fixture
def file_cache(tmp_path):
    return FileBackend(FileConfig(directory=str(tmp_path / "cache")))


# ─── Basic CRUD ───────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_set_and_get(file_cache):
    await file_cache.set("key1", "value1")
    assert await file_cache.get("key1") == "value1"


@pytest.mark.asyncio
async def test_get_missing_key(file_cache):
    assert await file_cache.get("nonexistent") is None


@pytest.mark.asyncio
async def test_delete(file_cache):
    await file_cache.set("key1", "value1")
    await file_cache.delete("key1")
    assert await file_cache.get("key1") is None


@pytest.mark.asyncio
async def test_exists(file_cache):
    await file_cache.set("key1", "value1")
    assert await file_cache.exists("key1") is True
    assert await file_cache.exists("missing") is False


@pytest.mark.asyncio
async def test_clear(file_cache):
    await file_cache.set("a", 1)
    await file_cache.set("b", 2)
    await file_cache.clear()
    assert await file_cache.get("a") is None
    assert await file_cache.get("b") is None


@pytest.mark.asyncio
async def test_get_many(file_cache):
    await file_cache.set("a", 1)
    await file_cache.set("b", 2)
    result = await file_cache.get_many(["a", "b", "c"])
    assert result == {"a": 1, "b": 2, "c": None}


# ─── TTL ──────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_ttl_expiry(tmp_path):
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    await cache.set("key", "value", ttl=1)
    assert await cache.get("key") == "value"
    await asyncio.sleep(1.1)
    assert await cache.get("key") is None


# ─── FIX 1: Pluggable serializer — any Python object ─────────────────────────

@pytest.mark.asyncio
async def test_pickle_serializer_datetime(tmp_path):
    """Pickle (default) can cache datetime — JSON cannot."""
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    now = datetime.datetime(2026, 1, 1, 12, 0, 0)
    await cache.set("ts", now)
    result = await cache.get("ts")
    assert result == now


@pytest.mark.asyncio
async def test_pickle_serializer_set(tmp_path):
    """Pickle can cache Python sets — JSON cannot."""
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    data = {1, 2, 3}
    await cache.set("myset", data)
    assert await cache.get("myset") == data


@pytest.mark.asyncio
async def test_pickle_serializer_custom_object(tmp_path):
    """Pickle can cache arbitrary Python objects.
    
    Note: The class must be defined at module level (not inside the test
    function) so Pickle can resolve it by fully-qualified name on deserialization.
    """
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    obj = _SerializableObj(42)
    await cache.set("obj", obj)
    result = await cache.get("obj")
    assert result == obj


@pytest.mark.asyncio
async def test_json_serializer(tmp_path):
    """JsonSerializer works for JSON-compatible types."""
    cache = FileBackend(FileConfig(
        directory=str(tmp_path / "cache"),
        serializer=JsonSerializer(),
    ))
    data = {"name": "Sarthak", "scores": [1, 2, 3]}
    await cache.set("data", data)
    assert await cache.get("data") == data


@pytest.mark.asyncio
async def test_serializer_shown_in_stats(tmp_path):
    """Stats should report the serializer name."""
    cache = FileBackend(FileConfig(
        directory=str(tmp_path / "cache"),
        serializer=JsonSerializer(),
    ))
    stats = await cache.stats()
    assert stats["serializer"] == "JsonSerializer"


# ─── FIX 2: Disk bloat — purge_expired and auto_purge_interval ───────────────

@pytest.mark.asyncio
async def test_purge_expired(tmp_path):
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    await cache.set("expires", "soon", ttl=1)
    await cache.set("permanent", "forever")
    await asyncio.sleep(1.1)
    deleted = await cache.purge_expired()
    assert deleted == 1
    assert await cache.get("permanent") == "forever"
    assert await cache.get("expires") is None


@pytest.mark.asyncio
async def test_auto_purge_interval(tmp_path):
    """auto_purge_interval triggers purge passively during set() calls."""
    cache = FileBackend(FileConfig(
        directory=str(tmp_path / "cache"),
        auto_purge_interval=1,
    ))
    await cache.set("exp", "gone", ttl=1)
    await asyncio.sleep(1.1)
    # This set() call should trigger the auto-purge
    await cache.set("trigger", "purge")
    assert await cache.get("exp") is None


# ─── FIX 3: Windows atomic write — concurrent safety ─────────────────────────

@pytest.mark.asyncio
async def test_concurrent_writes_no_corruption(tmp_path):
    """Concurrent writes to the same key should not corrupt data."""
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    await asyncio.gather(*[cache.set("shared_key", f"value_{i}") for i in range(20)])
    result = await cache.get("shared_key")
    assert result is not None
    assert result.startswith("value_")


@pytest.mark.asyncio
async def test_concurrent_mixed_ops(tmp_path):
    """Concurrent reads and writes should not raise errors."""
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache"), silent=True))
    await cache.set("k", "initial")

    async def writer(i):
        await cache.set("k", f"val_{i}")

    async def reader():
        return await cache.get("k")

    results = await asyncio.gather(
        *[writer(i) for i in range(10)],
        *[reader() for _ in range(10)],
        return_exceptions=True,
    )
    errors = [r for r in results if isinstance(r, Exception)]
    assert len(errors) == 0


# ─── FIX 4: Windows path safety — absolute path resolution ───────────────────

@pytest.mark.asyncio
async def test_absolute_path_resolution(tmp_path):
    """Cache directory should always be resolved to an absolute path."""
    cache = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    assert cache._base.is_absolute()


# ─── Persistence and namespace isolation ─────────────────────────────────────

@pytest.mark.asyncio
async def test_persistence_across_instances(tmp_path):
    """Values should persist when a new backend instance points to same dir."""
    cache1 = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    await cache1.set("persist", "hello")

    cache2 = FileBackend(FileConfig(directory=str(tmp_path / "cache")))
    assert await cache2.get("persist") == "hello"


@pytest.mark.asyncio
async def test_namespace_isolation(tmp_path):
    """Two backends sharing a directory but different namespaces don't collide."""
    cache_a = FileBackend(FileConfig(directory=str(tmp_path / "cache"), namespace="app_a"))
    cache_b = FileBackend(FileConfig(directory=str(tmp_path / "cache"), namespace="app_b"))
    await cache_a.set("key", "from_a")
    await cache_b.set("key", "from_b")
    assert await cache_a.get("key") == "from_a"
    assert await cache_b.get("key") == "from_b"


# ─── Stats ────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_stats(file_cache):
    await file_cache.set("k", "v")
    await file_cache.get("k")
    await file_cache.get("missing")
    stats = await file_cache.stats()
    assert stats["backend"] == "file"
    assert stats["hits"] >= 1
    assert stats["misses"] >= 1
    assert stats["files_on_disk"] >= 1
    assert stats["writes"] >= 1
    assert "serializer" in stats