# -*- coding: utf-8 -*-
"""Location: ./tests/unit/mcpgateway/cache/test_resource_cache.py
Copyright 2026
SPDX-License-Identifier: Apache-2.0
Authors: Mihai Criveti

Unit tests for ResourceCache.
"""

# Standard
import asyncio
import time

# Third-Party
import pytest

# First-Party
from mcpgateway.cache.resource_cache import CacheEntry, ResourceCache


@pytest.fixture
def cache():
    """Fixture for a ResourceCache with small TTL and size for testing."""
    return ResourceCache(max_size=3, ttl=1)


def test_set_and_get(cache):
    """Test setting and getting a cache value."""
    cache.set("foo", "bar")
    assert cache.get("foo") == "bar"
    assert len(cache) == 1


def test_get_missing(cache):
    """Test getting a missing key returns None."""
    assert cache.get("missing") is None


def test_expiration():
    """Test that cache entry expires after TTL."""
    # Use short TTL for faster test execution
    fast_cache = ResourceCache(max_size=3, ttl=0.1)
    fast_cache.set("foo", "bar")

    # Sleep for 0.15 seconds (50% longer than TTL) to ensure expiration
    time.sleep(0.15)

    # Entry should definitely be expired now
    assert len(fast_cache) == 1
    assert fast_cache.get("foo") is None
    # Entry should be deleted following get operation
    assert len(fast_cache) == 0


def test_lru_eviction(cache):
    """Test LRU eviction when max_size is reached."""
    cache.set("a", 1)
    cache.set("b", 2)
    cache.set("c", 3)
    # Access 'a' to update its position in the ordered cache
    assert len(cache) == cache.max_size
    assert cache.get("a") == 1
    # Add another entry, should evict 'b' (least recently used) and keep cache length
    cache.set("d", 4)
    assert len(cache) == cache.max_size

    assert cache.get("b") is None
    assert cache.get("a") == 1
    assert cache.get("c") == 3
    assert cache.get("d") == 4


def test_delete(cache):
    """Test deleting a cache entry."""
    cache.set("foo", "bar")
    assert len(cache) == 1
    cache.delete("foo")
    assert len(cache) == 0
    assert cache.get("foo") is None


def test_clear(cache):
    """Test clearing the cache."""
    cache.set("foo", "bar")
    cache.set("baz", "qux")
    assert len(cache) == 2
    cache.clear()
    assert cache.get("foo") is None
    assert cache.get("baz") is None
    assert len(cache) == 0


@pytest.mark.asyncio
async def test_initialize_and_shutdown_logs(monkeypatch):
    """Test initialize and shutdown log and cleanup."""
    cache = ResourceCache(max_size=2, ttl=1)
    monkeypatch.setattr("mcpgateway.cache.resource_cache.logger", DummyLogger())
    await cache.initialize()
    cache.set("foo", "bar")
    await cache.shutdown()
    assert cache.get("foo") is None
    assert len(cache) == 0


def test_cleanup_once_removes_expired():
    """Test that _cleanup_once removes expired entries via heap-based cleanup."""
    cache = ResourceCache(max_size=2, ttl=0.1)
    cache.set("foo", "bar")
    cache.set("baz", "qux")

    # Verify entries exist before expiration
    assert len(cache) == 2
    assert cache.get("foo") == "bar"

    # Wait for TTL expiration
    time.sleep(0.15)

    # Entries still in cache (not yet cleaned)
    assert len(cache._cache) == 2

    # Trigger heap-based cleanup
    cache._cleanup_once()

    # Entries should be removed by cleanup
    assert len(cache) == 0
    assert cache.get("foo") is None
    assert cache.get("baz") is None


def test_cleanup_once_ignores_updated_entries():
    """Test that _cleanup_once skips entries that were updated after heap entry was created."""
    cache = ResourceCache(max_size=2, ttl=0.1)
    cache.set("foo", "bar")

    # Wait for original expiry
    time.sleep(0.15)

    # Update the entry with a new value (creates new heap entry with new expiry)
    cache.set("foo", "updated")

    # Cleanup should ignore the stale heap entry since timestamps don't match
    cache._cleanup_once()

    # Entry should still exist (was updated)
    assert cache.get("foo") == "updated"


def test_cleanup_once_ignores_deleted_entries():
    """Test that _cleanup_once handles entries deleted before cleanup runs."""
    cache = ResourceCache(max_size=2, ttl=0.1)
    cache.set("foo", "bar")

    # Delete entry before expiry
    cache.delete("foo")

    # Wait for original expiry time
    time.sleep(0.15)

    # Cleanup should handle missing entry gracefully
    cache._cleanup_once()  # Should not raise

    assert cache.get("foo") is None


def test_heap_compaction_bounds_memory():
    """Test that heap compaction triggers when heap grows too large."""
    cache = ResourceCache(max_size=3, ttl=60)

    # Repeatedly update the same key to create stale heap entries
    for i in range(10):
        cache.set("key1", i)

    # Heap should have 10 entries (one per set call), but cache has 1 entry
    assert len(cache._expiry_heap) == 10
    assert len(cache) == 1

    # Heap exceeds 2 * max_size (6), so compaction triggers
    cache._cleanup_once()

    # After compaction, heap should equal cache size
    assert len(cache._expiry_heap) == 1


def test_heap_compaction_preserves_valid_entries():
    """Test that heap compaction preserves all valid cache entries."""
    cache = ResourceCache(max_size=5, ttl=60)

    # Add entries and create stale heap entries via updates
    cache.set("a", 1)
    cache.set("b", 2)
    cache.set("c", 3)

    # Update same keys multiple times to bloat heap beyond 2 * max_size (10)
    for _ in range(4):
        cache.set("a", 1)
        cache.set("b", 2)
        cache.set("c", 3)

    # Heap has 15 entries (3 initial + 12 updates), exceeds 2 * 5 = 10
    assert len(cache._expiry_heap) == 15
    assert len(cache) == 3

    # Trigger compaction
    cache._cleanup_once()

    # After compaction, heap should only have 3 valid entries
    assert len(cache._expiry_heap) == 3
    assert cache.get("a") == 1
    assert cache.get("b") == 2
    assert cache.get("c") == 3


def test_compact_heap_preserves_entries_added_during_compaction():
    """Ensure _compact_heap keeps newer heap entries via heappush path."""
    cache = ResourceCache(max_size=5, ttl=60)
    now = time.time()
    cache._cache["stable"] = CacheEntry(value=1, expires_at=now + 10)
    cache._expiry_heap = [(now + 10, "stable"), (now + 20, "new-during-compaction")]

    cache._compact_heap()

    assert any(key == "new-during-compaction" for _exp, key in cache._expiry_heap)


@pytest.mark.asyncio
async def test_cleanup_loop_logs_errors_from_cleanup(monkeypatch, caplog):
    """Cover cleanup loop exception logging path."""
    cache = ResourceCache(max_size=3, ttl=1)

    async def _boom(*_args, **_kwargs):
        raise RuntimeError("cleanup-failed")

    async def _stop_once(_seconds: float):
        raise asyncio.CancelledError()

    monkeypatch.setattr("mcpgateway.cache.resource_cache.asyncio.to_thread", _boom)
    monkeypatch.setattr("mcpgateway.cache.resource_cache.asyncio.sleep", _stop_once)

    with pytest.raises(asyncio.CancelledError):
        await cache._cleanup_loop()

    assert "Cache cleanup error: cleanup-failed" in caplog.text


class DummyLogger:
    def info(self, msg):
        pass

    def debug(self, msg):
        pass

    def error(self, msg):
        pass
