# SPDX-License-Identifier: Apache-2.0
# Standard
from typing import Generator
import multiprocessing as mp
import os
import time

# Third Party
import pytest
import torch
import zmq

# First Party
from lmcache.v1.distributed.config import (
    EvictionConfig,
    L1ManagerConfig,
    L1MemoryManagerConfig,
    StorageManagerConfig,
)
from lmcache.v1.mp_observability.config import DEFAULT_PROMETHEUS_CONFIG
from lmcache.v1.multiprocess.custom_types import (
    CudaIPCWrapper,
    IPCCacheEngineKey,
    KVCache,
)
from lmcache.v1.multiprocess.mq import MessageQueueClient
from lmcache.v1.multiprocess.protocol import (
    RequestType,
    get_response_class,
)
from lmcache.v1.multiprocess.server import run_cache_server

# Configuration constants
SERVER_HOST = "localhost"
SERVER_PORT = 5599
SERVER_URL = f"tcp://{SERVER_HOST}:{SERVER_PORT}"
CHUNK_SIZE = 256
CPU_BUFFER_SIZE = 5.0
DEFAULT_TIMEOUT = 5.0


def _has_working_new_shared_cuda() -> bool:
    if not torch.cuda.is_available():
        print("CUDA is not available, skipping tests that require new_shared_cuda")
        return False
    try:
        # Minimal sanity check — adapt to your real API
        buf = torch.empty(1024, device="cuda")
        shared = buf.untyped_storage()._share_cuda_()  # or your exact call
        return shared is not None
    except Exception:
        return False


if not _has_working_new_shared_cuda():
    pytest.skip(
        "new_shared_cuda is not available or not working on this system",
        allow_module_level=True,
    )


def initialize_kv_cache(
    device: torch.device,
    num_pages: int = 1024,
    num_layers: int = 32,
    page_size: int = 16,
    num_heads: int = 8,
    head_size: int = 128,
    dtype: torch.dtype = torch.bfloat16,
) -> list[torch.Tensor]:
    """
    Initialize KV cache tensors on GPU for testing.
    """
    torch.random.manual_seed(42)

    gpu_tensors = [
        torch.rand(
            (2, num_pages, page_size, num_heads, head_size),
            dtype=dtype,
            device=device,
        )
        for _ in range(num_layers)
    ]

    return gpu_tensors


class ClientContext:
    """
    Client context that manages GPU KV cache tensors.
    """

    def __init__(
        self,
        device: torch.device,
        num_pages: int = 1024,
        num_layers: int = 32,
        page_size: int = 16,
        num_heads: int = 8,
        head_size: int = 128,
        dtype: torch.dtype = torch.bfloat16,
    ):
        self.device = device
        self.num_pages = num_pages
        self.num_layers = num_layers
        self.page_size = page_size
        self.num_heads = num_heads
        self.head_size = head_size
        self.dtype = dtype

        self.gpu_kv_caches = initialize_kv_cache(
            device, num_pages, num_layers, page_size, num_heads, head_size, dtype
        )

    def get_kv_cache(self) -> KVCache:
        """
        Wrap GPU tensors in CudaIPCWrapper for IPC communication.
        """
        return [CudaIPCWrapper(tensor) for tensor in self.gpu_kv_caches]

    def get_tensor_slice(
        self, layer: int, start_page: int, num_pages: int
    ) -> torch.Tensor:
        """
        Get a slice of the KV cache tensor for a specific layer.
        """
        return self.gpu_kv_caches[layer][:, start_page : start_page + num_pages]


def create_cache_key(index: int, model: str = "testmodel") -> IPCCacheEngineKey:
    """
    Create a cache key for testing.
    """
    global CHUNK_SIZE
    token_ids = [index] * CHUNK_SIZE
    return IPCCacheEngineKey.from_token_ids(
        model,
        1,
        0,
        token_ids,
        start=0,
        end=CHUNK_SIZE,
        request_id=f"test_request_{index}",
    )


BLOCKS_PER_KEY = 16


def lookup_all(
    client: MessageQueueClient,
    keys: list[IPCCacheEngineKey],
    timeout: float = DEFAULT_TIMEOUT,
) -> int:
    """Lookup all keys individually and return total found count."""
    total = 0
    for key in keys:
        lookup_key = key.no_worker_id_version()
        future = client.submit_request(
            RequestType.LOOKUP,
            [lookup_key],
            get_response_class(RequestType.LOOKUP),
        )
        result = future.result(timeout=timeout)
        total += result
    return total


def store_keys(
    client: MessageQueueClient,
    keys: list[IPCCacheEngineKey],
    instance_id: int,
    gpu_block_ids: list[int],
    event: torch.cuda.Event,
    timeout: float = DEFAULT_TIMEOUT,
) -> None:
    """Store keys one at a time using the single-key API."""
    for i, key in enumerate(keys):
        start = i * BLOCKS_PER_KEY
        end = start + BLOCKS_PER_KEY
        block_ids = gpu_block_ids[start:end]
        future = client.submit_request(
            RequestType.STORE,
            [key, instance_id, block_ids, event.ipc_handle()],
            get_response_class(RequestType.STORE),
        )
        result = future.to_cuda_future().result(timeout=timeout)
        assert result is True, f"Store should succeed for key {i}"


def retrieve_keys(
    client: MessageQueueClient,
    keys: list[IPCCacheEngineKey],
    instance_id: int,
    gpu_block_ids: list[int],
    event: torch.cuda.Event,
    timeout: float = DEFAULT_TIMEOUT,
) -> list[bool]:
    """Retrieve keys one at a time using the single-key API."""
    results = []
    for i, key in enumerate(keys):
        start = i * BLOCKS_PER_KEY
        end = start + BLOCKS_PER_KEY
        block_ids = gpu_block_ids[start:end]
        future = client.submit_request(
            RequestType.RETRIEVE,
            [key, instance_id, block_ids, event.ipc_handle()],
            get_response_class(RequestType.RETRIEVE),
        )
        result = future.to_cuda_future().result(timeout=timeout)
        results.append(result)
    return results


def server_process_runner(
    host: str, port: int, chunk_size: int, cpu_buffer_size: float
):
    """
    Entry point for the server process.
    """
    storage_manager_config = StorageManagerConfig(
        l1_manager_config=L1ManagerConfig(
            memory_config=L1MemoryManagerConfig(
                size_in_bytes=int(cpu_buffer_size * 1024**3),
                use_lazy=True,
            ),
        ),
        eviction_config=EvictionConfig(eviction_policy="LRU"),
    )
    run_cache_server(
        storage_manager_config=storage_manager_config,
        prometheus_config=DEFAULT_PROMETHEUS_CONFIG,
        host=host,
        port=port,
        chunk_size=chunk_size,
    )


@pytest.fixture(scope="module")
def server_process() -> Generator[mp.Process, None, None]:
    """
    Fixture that starts the cache server in a separate process.
    The server runs for the entire test module.
    """
    # Start server process
    mp.set_start_method("spawn", force=True)
    process = mp.Process(
        target=server_process_runner,
        args=(SERVER_HOST, SERVER_PORT, CHUNK_SIZE, CPU_BUFFER_SIZE),
        daemon=True,
    )
    process.start()

    # Wait for server to initialize
    time.sleep(2)

    yield process

    # Cleanup: terminate the server process
    if process.is_alive():
        process.terminate()
        process.join(timeout=5)
        if process.is_alive():
            process.kill()
            process.join()


@pytest.fixture(scope="module")
def zmq_context() -> Generator[zmq.Context, None, None]:
    """
    Fixture that provides a ZMQ context for the test module.
    """
    context = zmq.Context.instance()
    yield context
    # Context cleanup is handled by ZMQ


@pytest.fixture(scope="function")
def client(
    server_process: mp.Process, zmq_context: zmq.Context
) -> Generator[MessageQueueClient, None, None]:
    """
    Fixture that provides a message queue client for each test function.
    """
    client = MessageQueueClient(server_url=SERVER_URL, context=zmq_context)
    yield client
    # Client cleanup
    client.close()


@pytest.fixture(scope="function")
def client_context() -> Generator[ClientContext, None, None]:
    """
    Fixture that provides a client context with initialized KV cache.
    """
    if not torch.cuda.is_available():
        pytest.skip("CUDA is not available")

    device = torch.device("cuda:0")
    ctx = ClientContext(device=device)
    yield ctx

    # Cleanup GPU memory
    del ctx.gpu_kv_caches
    torch.cuda.empty_cache()


@pytest.fixture(scope="function")
def registered_instance(
    client: MessageQueueClient, client_context: ClientContext
) -> Generator[int, None, None]:
    """
    Fixture that registers a KV cache instance and returns the instance ID.
    Automatically unregisters after the test.
    """
    instance_id = os.getpid()

    # Register KV cache
    future = client.submit_request(
        RequestType.REGISTER_KV_CACHE,
        [instance_id, client_context.get_kv_cache(), "testmodel", 1],
        get_response_class(RequestType.REGISTER_KV_CACHE),
    )
    result = future.result(timeout=DEFAULT_TIMEOUT)
    assert result is None, "Register should return None"

    yield instance_id

    # Unregister KV cache
    try:
        client.submit_request(
            RequestType.CLEAR, [], get_response_class(RequestType.CLEAR)
        ).result(timeout=DEFAULT_TIMEOUT)
        future = client.submit_request(
            RequestType.UNREGISTER_KV_CACHE,
            [instance_id],
            get_response_class(RequestType.UNREGISTER_KV_CACHE),
        )
        future.result(timeout=DEFAULT_TIMEOUT)
    except Exception as e:
        print(f"Error during unregister: {e}")


# ============================================================================
# Test Functions
# ============================================================================


def test_server_running(server_process: mp.Process):
    """
    Test that the server process is running.
    """
    assert server_process.is_alive(), "Server process should be running"


@pytest.mark.skipif(
    not torch.cuda.is_available(),
    reason="Register/Unregister KV cache requires CUDA",
)
def test_register_unregister_kv_cache(
    client: MessageQueueClient, client_context: ClientContext
):
    """
    Test registering and unregistering a KV cache.
    """
    instance_id = os.getpid()

    # Register
    future = client.submit_request(
        RequestType.REGISTER_KV_CACHE,
        [instance_id, client_context.get_kv_cache(), "testmodel", 1],
        get_response_class(RequestType.REGISTER_KV_CACHE),
    )
    result = future.result(timeout=DEFAULT_TIMEOUT)
    assert result is None

    # Unregister
    future = client.submit_request(
        RequestType.UNREGISTER_KV_CACHE,
        [instance_id],
        get_response_class(RequestType.UNREGISTER_KV_CACHE),
    )
    result = future.result(timeout=DEFAULT_TIMEOUT)
    assert result is None


@pytest.mark.skipif(
    not torch.cuda.is_available(),
    reason="Store and Lookup require CUDA",
)
def test_store_and_lookup(
    client: MessageQueueClient,
    client_context: ClientContext,
    registered_instance: int,
):
    """
    Test storing KV cache entries and looking them up.
    """
    num_keys = 10
    keys = [create_cache_key(i) for i in range(num_keys)]
    gpu_block_ids = list(range(0, 16 * num_keys))
    event = torch.cuda.Event(interprocess=True)
    event.record()

    # Store
    store_keys(client, keys, registered_instance, gpu_block_ids, event)

    # Lookup - keys that exist
    lookup_result = lookup_all(client, keys)
    assert lookup_result == num_keys, "All stored keys should exist"

    # Lookup - keys that don't exist
    non_existent_keys = [create_cache_key(i + 1000) for i in range(5)]
    lookup_result2 = lookup_all(client, non_existent_keys)
    assert lookup_result2 == 0, "Non-existent keys should not be found"


@pytest.mark.skipif(
    not torch.cuda.is_available(),
    reason="Store, Retrieve, and Verify require CUDA",
)
def test_store_retrieve_verify(
    client: MessageQueueClient,
    client_context: ClientContext,
    registered_instance: int,
):
    """
    Test storing and retrieving KV cache entries, verifying correctness.
    """
    num_keys = 20
    keys = [create_cache_key(i) for i in range(num_keys)]
    event = torch.cuda.Event(interprocess=True)
    event.record()

    # Store at the beginning of the cache
    store_block_ids = list(range(0, 16 * num_keys))
    store_keys(client, keys, registered_instance, store_block_ids, event)

    event = torch.cuda.Event(interprocess=True)
    event.record()

    # Call look up to ensure the data is ready to be retrieved
    lookup_result = lookup_all(client, keys)
    assert lookup_result == num_keys

    # Retrieve to a different location in the cache
    # Use offset of 40 blocks (640 pages total needed: 320 + 320)
    retrieve_offset = 40 * 16
    retrieve_block_ids = list(range(retrieve_offset, retrieve_offset + 16 * num_keys))
    retrieve_result = retrieve_keys(
        client, keys, registered_instance, retrieve_block_ids, event
    )

    assert len(retrieve_result) == num_keys
    assert all(retrieve_result), "All keys should be retrieved successfully"

    # Verify correctness by comparing tensors
    for i in range(num_keys):
        for layer in range(client_context.num_layers):
            original_block = i * 16
            retrieved_block = retrieve_offset + i * 16

            original_tensor = client_context.gpu_kv_caches[layer][
                :, original_block : original_block + 16
            ]
            retrieved_tensor = client_context.gpu_kv_caches[layer][
                :, retrieved_block : retrieved_block + 16
            ]

            assert torch.allclose(original_tensor, retrieved_tensor, atol=1e-4), (
                f"Mismatch for key {i}, layer {layer}"
            )


@pytest.mark.skipif(
    not torch.cuda.is_available(),
    reason="Partial miss retrieval requires CUDA",
)
def test_retrieve_partial_miss(
    client: MessageQueueClient,
    client_context: ClientContext,
    registered_instance: int,
):
    """
    Test retrieving when some keys exist and some don't.
    The retrieve should return ALL FALSE if any key is missing.
    """
    # Store first 30 keys (480 pages)
    num_stored = 30
    stored_keys = [create_cache_key(i) for i in range(num_stored)]
    store_block_ids = list(range(0, 16 * num_stored))
    event = torch.cuda.Event(interprocess=True)
    event.record()

    store_keys(client, stored_keys, registered_instance, store_block_ids, event)

    # Lookup to ensure keys are stored
    lookup_result = lookup_all(client, stored_keys)
    assert lookup_result == num_stored

    # Try to retrieve 60 keys (only first 30 exist)
    # Total pages needed: 60 * 16 = 960 (< 1024)
    num_requested = 60
    all_keys = [create_cache_key(i) for i in range(num_requested)]
    # Start retrieve at offset 2 keys (32 pages)
    retrieve_offset_keys = 2
    retrieve_block_ids = list(
        range(retrieve_offset_keys * 16, (retrieve_offset_keys + num_requested) * 16)
    )

    event = torch.cuda.Event(interprocess=True)
    event.record()

    retrieve_result = retrieve_keys(
        client, all_keys, registered_instance, retrieve_block_ids, event
    )

    assert len(retrieve_result) == num_requested
    # First 30 keys exist, remaining 30 don't
    assert all(retrieve_result[:num_stored]), "Stored keys should be retrieved"
    assert not any(retrieve_result[num_stored:]), (
        "Non-existent keys should fail to retrieve"
    )

    # Doing look up again to ensure data is ready
    lookup_result_2 = lookup_all(client, stored_keys)
    assert lookup_result_2 == num_stored

    # Try to retrieve the first 30 keys only (all exist)
    retrieve_block_ids_2 = list(range(0, 16 * num_stored))
    event = torch.cuda.Event(interprocess=True)
    event.record()
    retrieve_result_2 = retrieve_keys(
        client, stored_keys, registered_instance, retrieve_block_ids_2, event
    )
    assert len(retrieve_result_2) == num_stored
    assert all(retrieve_result_2), "All stored keys should be retrieved successfully"


@pytest.mark.skipif(
    not torch.cuda.is_available(),
    reason="Multiple retrieve operations require CUDA",
)
def test_multiple_retrieve_operations(
    client: MessageQueueClient,
    client_context: ClientContext,
    registered_instance: int,
):
    """
    Test multiple retrieve operations:
    Store 8,8,8,8 keys and then retrieve 8,8,8,8 keys in sequence.
    """
    num_batches = 4
    keys_per_batch = 8
    pages_per_key = 16

    # Initialize the values in GPU KV cache
    for layer in range(client_context.num_layers):
        layer_cache = client_context.gpu_kv_caches[layer]
        for i in range(num_batches):
            start_page = (i * keys_per_batch) * pages_per_key
            end_page = start_page + (keys_per_batch * pages_per_key)
            layer_cache[:, start_page:end_page] = (i + 1) / num_batches

    # Store in batches
    for batch_idx in range(num_batches):
        keys = [
            create_cache_key(batch_idx * keys_per_batch + i)
            for i in range(keys_per_batch)
        ]
        blocks = list(
            range(
                (batch_idx * keys_per_batch) * 16,
                (batch_idx * keys_per_batch + keys_per_batch) * 16,
            )
        )
        event = torch.cuda.Event(interprocess=True)
        event.record()
        store_keys(client, keys, registered_instance, blocks, event)

    # Doing look up to ensure data is ready to be retrieved
    all_keys = [
        create_cache_key(batch_idx * keys_per_batch + i)
        for batch_idx in range(num_batches)
        for i in range(keys_per_batch)
    ]
    lookup_result = lookup_all(client, all_keys)
    assert lookup_result == num_batches * keys_per_batch, "All stored keys should exist"

    # Retrieve in batches
    retrieve_offset = 32  # Start retrieving at offset of 32 chunks
    event = torch.cuda.Event(interprocess=True)
    event.record()
    for batch_idx in range(num_batches):
        keys = [
            create_cache_key(batch_idx * keys_per_batch + i)
            for i in range(keys_per_batch)
        ]
        blocks = list(
            range(
                (batch_idx * keys_per_batch + retrieve_offset) * pages_per_key,
                (batch_idx * keys_per_batch + retrieve_offset + keys_per_batch)
                * pages_per_key,
            )
        )

        retrieve_result = retrieve_keys(
            client, keys, registered_instance, blocks, event
        )
        assert len(retrieve_result) == keys_per_batch
        assert all(retrieve_result), "All keys should be retrieved successfully"

    # Verify correctness
    for layer in range(client_context.num_layers):
        layer_cache = client_context.gpu_kv_caches[layer]
        for batch_idx in range(num_batches):
            start_page = (retrieve_offset + batch_idx * keys_per_batch) * pages_per_key
            end_page = start_page + (keys_per_batch * pages_per_key)
            retrieved_tensor = layer_cache[:, start_page:end_page]
            expected_value = (batch_idx + 1) / num_batches
            assert torch.allclose(
                retrieved_tensor,
                torch.full_like(retrieved_tensor, expected_value),
            ), f"Mismatch in batch {batch_idx}, layer {layer}"


@pytest.mark.skipif(
    not torch.cuda.is_available(),
    reason="Multiple store operations require CUDA",
)
def test_multiple_store_operations(
    client: MessageQueueClient,
    client_context: ClientContext,
    registered_instance: int,
):
    """
    Test multiple store operations in sequence.
    """
    # Store batch 1
    keys1 = [create_cache_key(i) for i in range(30)]
    blocks1 = list(range(0, 16 * 30))
    event = torch.cuda.Event(interprocess=True)
    event.record()
    store_keys(client, keys1, registered_instance, blocks1, event)

    # Store batch 2
    keys2 = [create_cache_key(i + 30) for i in range(20)]
    blocks2 = list(range(30 * 16, 50 * 16))

    # Test with the same event for 2 store requests
    store_keys(client, keys2, registered_instance, blocks2, event)

    # Verify all keys exist
    all_keys = keys1 + keys2
    lookup_result = lookup_all(client, all_keys)
    assert lookup_result == 50, "All stored keys from both batches should exist"


@pytest.mark.skipif(
    not torch.cuda.is_available(), reason="Get chunk size requires CUDA"
)
def test_get_chunk_size(
    client: MessageQueueClient,
):
    """
    Test retrieving the chunk size from the server.
    """
    chunk_size = client.submit_request(
        RequestType.GET_CHUNK_SIZE,
        [],
        get_response_class(RequestType.GET_CHUNK_SIZE),
    ).result(timeout=DEFAULT_TIMEOUT)

    assert chunk_size == CHUNK_SIZE, f"Chunk size should be {CHUNK_SIZE}"
