# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for StoreController.

Tests verify the end-to-end flow: L1 write -> listener notification ->
L2 store submission -> completion handling -> L1 read lock release.

Uses a real L1Manager and MockL2Adapter (with debug methods) to exercise
the full integration without mocking internals.
"""

# Standard
import select
import time

# Third Party
import pytest
import torch

# First Party
from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey
from lmcache.v1.distributed.config import L1ManagerConfig, L1MemoryManagerConfig
from lmcache.v1.distributed.l1_manager import L1Manager
from lmcache.v1.distributed.l2_adapters.config import MockL2AdapterConfig
from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import MockL2Adapter
from lmcache.v1.distributed.storage_controllers.store_controller import (
    StoreController,
    StoreListener,
)
from lmcache.v1.distributed.storage_controllers.store_policy import (
    AdapterDescriptor,
    DefaultStorePolicy,
    StorePolicy,
)

# Skip all tests in this module if CUDA is not available
pytestmark = pytest.mark.skipif(
    not torch.cuda.is_available(), reason="CUDA is not available"
)


# =============================================================================
# Helpers
# =============================================================================


def make_object_key(chunk_id: int) -> ObjectKey:
    """Create a test ObjectKey with the given chunk ID."""
    return ObjectKey(
        chunk_hash=ObjectKey.IntHash2Bytes(chunk_id),
        model_name="test_model",
        kv_rank=0,
    )


def make_layout() -> MemoryLayoutDesc:
    """Create a small MemoryLayoutDesc for testing."""
    return MemoryLayoutDesc(
        shapes=[torch.Size([100, 2, 512])],
        dtypes=[torch.bfloat16],
    )


def should_use_lazy_alloc() -> bool:
    """Determine if lazy allocation should be used based on CUDA availability."""
    return torch.cuda.is_available()


def wait_for_condition(
    predicate,
    timeout: float = 5.0,
    poll_interval: float = 0.05,
) -> bool:
    """
    Poll until a predicate returns True or timeout.

    Args:
        predicate: Callable returning bool.
        timeout: Max wait time in seconds.
        poll_interval: Time between polls in seconds.

    Returns:
        True if predicate was satisfied, False on timeout.
    """
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if predicate():
            return True
        time.sleep(poll_interval)
    return False


def write_keys_to_l1(
    l1_manager: L1Manager,
    keys: list[ObjectKey],
    layout: MemoryLayoutDesc,
) -> list[ObjectKey]:
    """
    Write keys to L1 (reserve_write + finish_write).

    Args:
        l1_manager: The L1 manager to write to.
        keys: Object keys to write.
        layout: Memory layout description.

    Returns:
        List of keys that were successfully written.
    """
    results = l1_manager.reserve_write(
        keys=keys,
        is_temporary=[False] * len(keys),
        layout_desc=layout,
        mode="new",
    )
    written = [k for k, (e, m) in results.items() if m is not None]
    if written:
        l1_manager.finish_write(written)
    return written


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def l1_manager():
    """Create an L1Manager with a reasonable memory config."""
    config = L1ManagerConfig(
        memory_config=L1MemoryManagerConfig(
            size_in_bytes=128 * 1024 * 1024,
            use_lazy=should_use_lazy_alloc(),
            init_size_in_bytes=64 * 1024 * 1024,
            align_bytes=0x1000,
        ),
        write_ttl_seconds=600,
        read_ttl_seconds=300,
    )
    mgr = L1Manager(config)
    yield mgr
    mgr.close()


def make_adapter() -> MockL2Adapter:
    """Create a MockL2Adapter with fast bandwidth."""
    config = MockL2AdapterConfig(
        max_size_gb=0.01,
        mock_bandwidth_gb=10.0,
    )
    return MockL2Adapter(config)


def make_descriptor(index: int) -> AdapterDescriptor:
    """Create an AdapterDescriptor for testing."""
    config = MockL2AdapterConfig(max_size_gb=0.01, mock_bandwidth_gb=10.0)
    return AdapterDescriptor(index=index, config=config)


# =============================================================================
# StoreListener Tests
# =============================================================================


class TestStoreListener:
    """Test StoreListener as a standalone component."""

    def test_pop_pending_keys_returns_empty_initially(self):
        """A fresh listener should have no pending keys."""
        listener = StoreListener()
        assert listener.pop_pending_keys() == []
        listener.close()

    def test_on_l1_keys_write_finished_enqueues_keys(self):
        """Keys passed to on_l1_keys_write_finished should be retrievable via pop."""
        listener = StoreListener()
        keys = [make_object_key(i) for i in range(3)]

        listener.on_l1_keys_write_finished(keys)
        popped = listener.pop_pending_keys()

        assert popped == keys
        listener.close()

    def test_pop_pending_keys_clears_queue(self):
        """pop_pending_keys should drain the queue."""
        listener = StoreListener()
        listener.on_l1_keys_write_finished([make_object_key(0)])
        listener.pop_pending_keys()

        assert listener.pop_pending_keys() == []
        listener.close()

    def test_on_l1_keys_write_finished_signals_eventfd(self):
        """The eventfd should become readable after on_l1_keys_write_finished."""
        listener = StoreListener()
        efd = listener.get_event_fd()
        poller = select.poll()
        poller.register(efd, select.POLLIN)

        listener.on_l1_keys_write_finished([make_object_key(0)])

        events = poller.poll(1000)
        assert len(events) > 0
        listener.close()

    def test_multiple_writes_accumulate(self):
        """Multiple on_l1_keys_write_finished calls should accumulate keys."""
        listener = StoreListener()
        listener.on_l1_keys_write_finished([make_object_key(0)])
        listener.on_l1_keys_write_finished([make_object_key(1), make_object_key(2)])

        popped = listener.pop_pending_keys()
        assert len(popped) == 3
        listener.close()


# =============================================================================
# StoreController Integration Tests
# =============================================================================


class TestStoreControllerLifecycle:
    """Test StoreController start/stop behavior."""

    def test_start_stop(self, l1_manager):
        """StoreController should start and stop cleanly."""
        adapter = make_adapter()
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[adapter],
            adapter_descriptors=[make_descriptor(0)],
            policy=DefaultStorePolicy(),
        )
        ctrl.start()
        ctrl.stop()
        adapter.close()

    def test_start_stop_no_adapters(self, l1_manager):
        """Controller should start and stop cleanly with no adapters."""
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[],
            adapter_descriptors=[],
            policy=DefaultStorePolicy(),
        )
        ctrl.start()
        ctrl.stop()


class TestStoreControllerSingleAdapter:
    """Test StoreController with one MockL2Adapter."""

    def test_l1_write_triggers_l2_store(self, l1_manager):
        """Writing to L1 should cause the object to appear in L2."""
        adapter = make_adapter()
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[adapter],
            adapter_descriptors=[make_descriptor(0)],
            policy=DefaultStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Wait for the object to appear in L2
        ok = wait_for_condition(
            lambda: adapter.debug_get_stored_object_count() == 1,
            timeout=5.0,
        )
        assert ok, "Object should be stored in L2 after L1 write"
        assert adapter.debug_has_key(keys[0])

        ctrl.stop()
        adapter.close()

    def test_multiple_keys_stored_to_l2(self, l1_manager):
        """Multiple L1 writes should result in all keys in L2."""
        adapter = make_adapter()
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[adapter],
            adapter_descriptors=[make_descriptor(0)],
            policy=DefaultStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(i) for i in range(5)]
        write_keys_to_l1(l1_manager, keys, layout)

        ok = wait_for_condition(
            lambda: adapter.debug_get_stored_object_count() == 5,
            timeout=5.0,
        )
        assert ok, "All 5 objects should be stored in L2"
        for key in keys:
            assert adapter.debug_has_key(key)

        ctrl.stop()
        adapter.close()

    def test_read_lock_released_after_store(self, l1_manager):
        """After L2 store completes, L1 read locks should be released."""
        adapter = make_adapter()
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[adapter],
            adapter_descriptors=[make_descriptor(0)],
            policy=DefaultStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Wait for L2 store to complete
        ok = wait_for_condition(
            lambda: adapter.debug_has_key(keys[0]),
            timeout=5.0,
        )
        assert ok

        # Verify read lock is released: the key should be updatable
        ok = wait_for_condition(
            lambda: l1_manager.reserve_write(
                keys=keys,
                is_temporary=[False],
                layout_desc=layout,
                mode="update",
            )[keys[0]][1]
            is not None,
            timeout=5.0,
        )
        assert ok, "Key should be updatable after store controller releases read lock"

        ctrl.stop()
        adapter.close()


class TestStoreControllerMultipleAdapters:
    """Test StoreController with multiple L2 adapters."""

    def test_stores_to_all_adapters(self, l1_manager):
        """DefaultStorePolicy sends keys to all adapters."""
        adapters = [make_adapter(), make_adapter()]
        descriptors = [make_descriptor(i) for i in range(2)]

        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=adapters,
            adapter_descriptors=descriptors,
            policy=DefaultStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Wait for both adapters to have the object
        ok = wait_for_condition(
            lambda: all(a.debug_has_key(keys[0]) for a in adapters),
            timeout=5.0,
        )
        assert ok, "Object should be stored in both L2 adapters"

        ctrl.stop()
        for a in adapters:
            a.close()

    def test_read_lock_released_after_all_adapters_complete(self, l1_manager):
        """Read locks should be released after all adapter stores complete."""
        adapters = [make_adapter(), make_adapter()]
        descriptors = [make_descriptor(i) for i in range(2)]

        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=adapters,
            adapter_descriptors=descriptors,
            policy=DefaultStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Wait for stores to both adapters, then check updatability
        ok = wait_for_condition(
            lambda: all(a.debug_has_key(keys[0]) for a in adapters),
            timeout=5.0,
        )
        assert ok

        ok = wait_for_condition(
            lambda: l1_manager.reserve_write(
                keys=keys,
                is_temporary=[False],
                layout_desc=layout,
                mode="update",
            )[keys[0]][1]
            is not None,
            timeout=5.0,
        )
        assert ok, "Key should be updatable after all adapter stores complete"

        ctrl.stop()
        for a in adapters:
            a.close()


class TestStoreControllerNoAdapters:
    """Test StoreController with no L2 adapters."""

    def test_write_with_no_adapters_no_read_lock(self, l1_manager):
        """With no adapters, no read locks should be held after L1 write."""
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[],
            adapter_descriptors=[],
            policy=DefaultStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Give the controller time to process the listener event
        time.sleep(0.3)

        # Key should be updatable (no read locks)
        result = l1_manager.reserve_write(
            keys=keys,
            is_temporary=[False],
            layout_desc=layout,
            mode="update",
        )
        assert result[keys[0]][1] is not None

        ctrl.stop()


class TestStoreControllerCustomPolicy:
    """Test StoreController with custom StorePolicy implementations."""

    def test_policy_that_skips_all_adapters(self, l1_manager):
        """A policy returning empty targets should not trigger L2 stores."""

        class SkipAllPolicy(StorePolicy):
            """Never store to L2."""

            def select_store_targets(self, keys, adapters):
                return {}

            def select_l1_deletions(self, keys):
                return []

        adapter = make_adapter()
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[adapter],
            adapter_descriptors=[make_descriptor(0)],
            policy=SkipAllPolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Give the controller time to process
        time.sleep(0.5)

        assert adapter.debug_get_stored_object_count() == 0, (
            "No objects should be stored when policy skips all adapters"
        )

        ctrl.stop()
        adapter.close()

    def test_policy_that_deletes_from_l1(self, l1_manager):
        """A policy that requests L1 deletion should result in deleted keys."""

        class DeleteAfterStorePolicy(StorePolicy):
            """Delete all keys from L1 after storing to L2."""

            def select_store_targets(self, keys, adapters):
                return {ad.index: list(keys) for ad in adapters}

            def select_l1_deletions(self, keys):
                return list(keys)

        adapter = make_adapter()
        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=[adapter],
            adapter_descriptors=[make_descriptor(0)],
            policy=DeleteAfterStorePolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        # Wait for L2 store and L1 deletion
        ok = wait_for_condition(
            lambda: adapter.debug_has_key(keys[0]),
            timeout=5.0,
        )
        assert ok, "Object should be stored in L2"

        # After deletion, reserve_write with mode="new" should succeed
        ok = wait_for_condition(
            lambda: l1_manager.reserve_write(
                keys=keys,
                is_temporary=[False],
                layout_desc=layout,
                mode="new",
            )[keys[0]][1]
            is not None,
            timeout=5.0,
        )
        assert ok, "Key should be re-creatable after L1 deletion by policy"

        ctrl.stop()
        adapter.close()

    def test_policy_selects_subset_of_adapters(self, l1_manager):
        """A policy that targets only some adapters should skip others."""

        class FirstAdapterOnlyPolicy(StorePolicy):
            """Only store to adapter index 0."""

            def select_store_targets(self, keys, adapters):
                for ad in adapters:
                    if ad.index == 0:
                        return {0: list(keys)}
                return {}

            def select_l1_deletions(self, keys):
                return []

        adapters = [make_adapter(), make_adapter()]
        descriptors = [make_descriptor(i) for i in range(2)]

        ctrl = StoreController(
            l1_manager=l1_manager,
            l2_adapters=adapters,
            adapter_descriptors=descriptors,
            policy=FirstAdapterOnlyPolicy(),
        )
        ctrl.start()

        layout = make_layout()
        keys = [make_object_key(0)]
        write_keys_to_l1(l1_manager, keys, layout)

        ok = wait_for_condition(
            lambda: adapters[0].debug_has_key(keys[0]),
            timeout=5.0,
        )
        assert ok, "Object should be stored in first adapter"

        # Give some time and verify second adapter is empty
        time.sleep(0.5)
        assert adapters[1].debug_get_stored_object_count() == 0, (
            "Second adapter should have no objects"
        )

        ctrl.stop()
        for a in adapters:
            a.close()
