"""Tests for cache_service: get, set, invalidate, and cleanup.

Uses mock database sessions to test cache logic in isolation
without requiring a real PostgreSQL connection.
"""

from __future__ import annotations

import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock

import pytest

sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for cache service tests")

from app.services.cache_service import (  # noqa: E402
    cleanup_expired,
    get_cached,
    invalidate,
    set_cached,
)


# ---------------------------------------------------------------------------
# Helpers: mock objects for BlockchainCache entries
# ---------------------------------------------------------------------------


def _make_cache_entry(
    key: str = "test:key",
    value: dict | None = None,
    expired: bool = False,
) -> MagicMock:
    """Create a mock BlockchainCache entry."""
    entry = MagicMock()
    entry.id = uuid.uuid4()
    entry.cache_key = key
    entry.cache_value = value or {"data": 42}
    entry.fetched_at = datetime.now(timezone.utc)

    if expired:
        entry.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
    else:
        entry.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)

    return entry


def _make_async_db(entry: MagicMock | None = None) -> AsyncMock:
    """Create a mock async database session.

    The session's execute() returns a result with scalar_one_or_none()
    that returns the given entry (or None).
    """
    db = AsyncMock()

    result = MagicMock()
    result.scalar_one_or_none.return_value = entry
    db.execute = AsyncMock(return_value=result)
    db.commit = AsyncMock()
    db.add = MagicMock()

    return db


# ---------------------------------------------------------------------------
# Tests: get_cached
# ---------------------------------------------------------------------------


class TestGetCached:
    """Test cache_service.get_cached."""

    @pytest.mark.asyncio
    async def test_returns_none_for_missing_key(self):
        """get_cached returns None when no entry exists for the key."""
        db = _make_async_db(entry=None)
        result = await get_cached("nonexistent:key", db)
        assert result is None

    @pytest.mark.asyncio
    async def test_returns_value_for_valid_entry(self):
        """get_cached returns the cached value when entry exists and is not expired."""
        entry = _make_cache_entry(key="blockchain:wot_size", value={"value": 7224})
        db = _make_async_db(entry=entry)

        result = await get_cached("blockchain:wot_size", db)
        assert result == {"value": 7224}

    @pytest.mark.asyncio
    async def test_returns_none_for_expired_entry(self):
        """get_cached returns None when the entry is expired.

        Note: In the real implementation, the SQL query filters by expires_at.
        Here we test that when the DB returns None (as it would for expired),
        get_cached returns None.
        """
        db = _make_async_db(entry=None)
        result = await get_cached("expired:key", db)
        assert result is None


# ---------------------------------------------------------------------------
# Tests: set_cached + get_cached roundtrip
# ---------------------------------------------------------------------------


class TestSetCached:
    """Test cache_service.set_cached."""

    @pytest.mark.asyncio
    async def test_set_cached_creates_new_entry(self):
        """set_cached creates a new entry when key does not exist."""
        db = _make_async_db(entry=None)

        await set_cached("new:key", {"value": 100}, db, ttl_seconds=3600)

        db.add.assert_called_once()
        db.commit.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_set_cached_updates_existing_entry(self):
        """set_cached updates the value when key already exists."""
        existing = _make_cache_entry(key="existing:key", value={"value": 50})
        db = _make_async_db(entry=existing)

        await set_cached("existing:key", {"value": 200}, db, ttl_seconds=7200)

        # Should update in-place, not add new
        db.add.assert_not_called()
        assert existing.cache_value == {"value": 200}
        db.commit.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_set_cached_roundtrip(self):
        """set_cached followed by get_cached returns the stored value."""
        # First call: set (no existing entry)
        db_set = _make_async_db(entry=None)
        await set_cached("roundtrip:key", {"data": "test"}, db_set, ttl_seconds=3600)
        db_set.add.assert_called_once()

        # Extract the added entry from the mock
        added_entry = db_set.add.call_args[0][0]

        # Second call: get (returns the added entry)
        db_get = _make_async_db(entry=added_entry)
        result = await get_cached("roundtrip:key", db_get)
        assert result == {"data": "test"}


# ---------------------------------------------------------------------------
# Tests: invalidate
# ---------------------------------------------------------------------------


class TestInvalidate:
    """Test cache_service.invalidate."""

    @pytest.mark.asyncio
    async def test_invalidate_removes_entry(self):
        """invalidate executes a delete and commits."""
        db = AsyncMock()
        db.execute = AsyncMock()
        db.commit = AsyncMock()

        await invalidate("some:key", db)

        db.execute.assert_awaited_once()
        db.commit.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_invalidate_then_get_returns_none(self):
        """After invalidation, get_cached returns None."""
        db = _make_async_db(entry=None)
        result = await get_cached("invalidated:key", db)
        assert result is None


# ---------------------------------------------------------------------------
# Tests: cleanup_expired
# ---------------------------------------------------------------------------


class TestCleanupExpired:
    """Test cache_service.cleanup_expired."""

    @pytest.mark.asyncio
    async def test_cleanup_removes_expired_entries(self):
        """cleanup_expired executes a delete for expired entries and returns count."""
        db = AsyncMock()

        # Mock the execute result with rowcount
        exec_result = MagicMock()
        exec_result.rowcount = 3
        db.execute = AsyncMock(return_value=exec_result)
        db.commit = AsyncMock()

        count = await cleanup_expired(db)
        assert count == 3
        db.execute.assert_awaited_once()
        db.commit.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_cleanup_with_no_expired_entries(self):
        """cleanup_expired returns 0 when no expired entries exist."""
        db = AsyncMock()

        exec_result = MagicMock()
        exec_result.rowcount = 0
        db.execute = AsyncMock(return_value=exec_result)
        db.commit = AsyncMock()

        count = await cleanup_expired(db)
        assert count == 0
