# SPDX-License-Identifier: Apache-2.0
"""End-to-end test for the ShmFile connector.

This test verifies that:
1. Data written via put() is persisted to a file.
2. Data read via get() matches the original data exactly.
3. The C++ subprocess (shm_file_worker) correctly attaches to
   POSIX shared memory and performs I/O without extra copies.
"""

# Standard
from multiprocessing import shared_memory
from typing import Optional
import asyncio
import ctypes
import os
import subprocess
import tempfile
import time

# Third Party
import pytest
import torch

# First Party
from lmcache.logging import init_logger
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.memory_management import (
    MixedMemoryAllocator,
)
from lmcache.v1.metadata import LMCacheMetadata
from lmcache.v1.storage_backend import LocalCPUBackend

# Local
from ..utils import (
    close_asyncio_loop,
    dumb_cache_engine_key,
    init_asyncio_loop,
)
from .shmfile_connector import ShmFileConnector

logger = init_logger(__name__)

SHM_NAME = "/lmcache_test_shm"
SHM_BUF_SIZE = 5 * 1024 * 1024 * 1024  # 5 GB


_CSRC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "csrc")
_BUILD_DIR = os.path.join(_CSRC_DIR, "build")
_WORKER_BIN = os.path.join(_BUILD_DIR, "shm_file_worker")


def _build_worker_binary() -> str:
    """Build shm_file_worker from tests/v1/shm_allocator/csrc/
    and return the path to the compiled binary."""
    env_bin = os.environ.get("SHM_FILE_WORKER_BIN")
    if env_bin and os.path.isfile(env_bin):
        return env_bin

    if os.path.isfile(_WORKER_BIN):
        return _WORKER_BIN

    os.makedirs(_BUILD_DIR, exist_ok=True)
    try:
        subprocess.check_call(
            ["cmake", ".."],
            cwd=_BUILD_DIR,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        subprocess.check_call(
            ["make", "shm_file_worker"],
            cwd=_BUILD_DIR,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
    except (FileNotFoundError, subprocess.CalledProcessError):
        pytest.skip(
            "Cannot compile shm_file_worker. "
            "Ensure cmake and a C++ compiler are available."
        )
    if not os.path.isfile(_WORKER_BIN):
        pytest.skip("shm_file_worker build failed.")
    return _WORKER_BIN


def _get_metadata():
    kv_shape = (32, 2, 256, 8, 128)
    dtype = torch.bfloat16
    return LMCacheMetadata(
        model_name="test_model",
        world_size=1,
        local_world_size=1,
        worker_id=0,
        local_worker_id=0,
        kv_dtype=dtype,
        kv_shape=kv_shape,
    )


def _create_shmfs_config(
    storage_dir: str,
    worker_binary: str,
    shm_name: str = SHM_NAME,
    worker_addr: Optional[str] = None,
) -> LMCacheEngineConfig:
    """Build config with shmfs.* extra_config entries."""
    extra = {
        "shmfs.storage_dir": storage_dir,
        "shmfs.shm_name": shm_name,
        "shmfs.worker_binary": worker_binary,
        "shm_name": shm_name,
    }
    if worker_addr:
        extra["shmfs.worker_addr"] = worker_addr
    return LMCacheEngineConfig.from_defaults(
        extra_config=extra,
    )


def _create_local_cpu_backend(memory_allocator):
    config = LMCacheEngineConfig.from_defaults()
    metadata = _get_metadata()
    return LocalCPUBackend(
        config=config,
        metadata=metadata,
        memory_allocator=memory_allocator,
    )


@pytest.fixture
def shm_allocator():
    """Create a MixedMemoryAllocator backed by POSIX shm."""
    allocator = MixedMemoryAllocator(SHM_BUF_SIZE, shm_name=SHM_NAME)
    yield allocator
    allocator.close()


@pytest.fixture
def worker_binary():
    worker_binary_path = _build_worker_binary()
    logger.info(f"Using shm_file_worker binary: {worker_binary_path}")
    return worker_binary_path


class TestShmFileConnector:
    """End-to-end tests for ShmFileConnector."""

    def test_put_and_get_roundtrip(self, shm_allocator, worker_binary):
        """Write data via put(), then read via get() and
        verify byte-for-byte equality."""
        with tempfile.TemporaryDirectory() as tmp_dir:
            async_loop, async_thread = init_asyncio_loop()
            try:
                backend = _create_local_cpu_backend(shm_allocator)
                cfg = _create_shmfs_config(
                    tmp_dir,
                    worker_binary,
                )
                connector = ShmFileConnector(
                    loop=async_loop,
                    local_cpu_backend=backend,
                    config=cfg,
                )

                key = dumb_cache_engine_key()

                # Verify key does not exist initially
                fut = asyncio.run_coroutine_threadsafe(
                    connector.exists(key), async_loop
                )
                assert not fut.result()

                # Allocate and fill with deterministic data
                shape = torch.Size([2, 32, 256, 1024])
                dtype = torch.bfloat16
                mem_obj = backend.allocate(shape, dtype)
                assert mem_obj is not None
                mem_obj.ref_count_up()

                torch.manual_seed(42)
                test_data = torch.randint(
                    0,
                    100,
                    mem_obj.raw_data.shape,
                    dtype=torch.int64,
                )
                mem_obj.raw_data.copy_(test_data.to(torch.float32).to(dtype))

                # Put
                fut = asyncio.run_coroutine_threadsafe(
                    connector.put(key, mem_obj), async_loop
                )
                fut.result()

                # Verify file exists on disk
                fut = asyncio.run_coroutine_threadsafe(
                    connector.exists(key), async_loop
                )
                assert fut.result()

                # Get
                fut = asyncio.run_coroutine_threadsafe(connector.get(key), async_loop)
                retrieved = fut.result()
                assert retrieved is not None

                # Compare
                orig = mem_obj.tensor
                got = retrieved.tensor
                assert orig is not None
                assert got is not None
                assert orig.shape == got.shape
                assert torch.equal(orig, got), "Data mismatch after roundtrip!"

                # Clean up
                fut = asyncio.run_coroutine_threadsafe(connector.close(), async_loop)
                fut.result()
                backend.close()
            finally:
                close_asyncio_loop(async_loop, async_thread)

    def test_get_nonexistent_key(self, shm_allocator, worker_binary):
        """get() on a missing key returns None."""
        with tempfile.TemporaryDirectory() as tmp_dir:
            async_loop, async_thread = init_asyncio_loop()
            try:
                backend = _create_local_cpu_backend(shm_allocator)
                cfg = _create_shmfs_config(
                    tmp_dir,
                    worker_binary,
                )
                connector = ShmFileConnector(
                    loop=async_loop,
                    local_cpu_backend=backend,
                    config=cfg,
                )

                key = dumb_cache_engine_key(id=999)
                fut = asyncio.run_coroutine_threadsafe(connector.get(key), async_loop)
                assert fut.result() is None

                fut = asyncio.run_coroutine_threadsafe(connector.close(), async_loop)
                fut.result()
                backend.close()
            finally:
                close_asyncio_loop(async_loop, async_thread)

    def test_list_keys(self, shm_allocator, worker_binary):
        """list() returns keys for all stored files."""
        with tempfile.TemporaryDirectory() as tmp_dir:
            async_loop, async_thread = init_asyncio_loop()
            try:
                backend = _create_local_cpu_backend(shm_allocator)
                cfg = _create_shmfs_config(
                    tmp_dir,
                    worker_binary,
                )
                connector = ShmFileConnector(
                    loop=async_loop,
                    local_cpu_backend=backend,
                    config=cfg,
                )

                shape = torch.Size([2, 32, 256, 1024])
                dtype = torch.bfloat16

                keys = [dumb_cache_engine_key(id=i) for i in range(3)]

                for k in keys:
                    mem_obj = backend.allocate(shape, dtype)
                    assert mem_obj is not None
                    mem_obj.ref_count_up()
                    fut = asyncio.run_coroutine_threadsafe(
                        connector.put(k, mem_obj), async_loop
                    )
                    fut.result()

                fut = asyncio.run_coroutine_threadsafe(connector.list(), async_loop)
                listed = set(fut.result())
                expected = {k.to_string() for k in keys}
                assert listed == expected

                fut = asyncio.run_coroutine_threadsafe(connector.close(), async_loop)
                fut.result()
                backend.close()
            finally:
                close_asyncio_loop(async_loop, async_thread)


class TestShmWorkerSubprocess:
    """Test the C++ shm_file_worker directly."""

    def test_allocator_receives_shm_name_from_config(self):
        """Verify that initialize_allocator passes shm_name
        when extra_config contains shm_name."""
        config = LMCacheEngineConfig.from_defaults(
            extra_config={"shm_name": "/lmc_prod_test"},
            max_local_cpu_size=0.001,
        )
        metadata = _get_metadata()
        backend = LocalCPUBackend(config=config, metadata=metadata)
        alloc = backend.memory_allocator
        assert isinstance(alloc, MixedMemoryAllocator)
        assert alloc.shm_name == "/lmc_prod_test"
        backend.close()

    def test_allocator_no_shm_name_without_extra_config(self):
        """Verify that shm_name is None when extra_config
        does not contain shm_name."""
        config = LMCacheEngineConfig.from_defaults(
            max_local_cpu_size=0.001,
        )
        metadata = _get_metadata()
        backend = LocalCPUBackend(config=config, metadata=metadata)
        alloc = backend.memory_allocator
        assert isinstance(alloc, MixedMemoryAllocator)
        assert alloc.shm_name is None
        backend.close()

    def test_worker_attach_and_io(self, worker_binary):
        """Verify the worker can attach, write, and read."""
        shm_test_name = "lmcache_worker_test"
        shm_test_size = 4096

        # Create shm segment from Python side
        shm = shared_memory.SharedMemory(
            name=shm_test_name, create=True, size=shm_test_size
        )

        try:
            # Write known pattern into shm
            msg = b"Hello SHM Worker!"
            shm.buf[:17] = msg

            # Get the base address of shm buffer
            arr_t = ctypes.c_uint8 * shm_test_size
            arr = arr_t.from_buffer(shm.buf)
            base_addr = ctypes.addressof(arr)

            proc = subprocess.Popen(
                [worker_binary],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                bufsize=1,
            )

            def send(cmd):
                assert proc.stdin is not None
                assert proc.stdout is not None
                proc.stdin.write(cmd + "\n")
                proc.stdin.flush()
                return proc.stdout.readline().strip()

            # Attach with base_addr
            resp = send("ATTACH /%s %d %d" % (shm_test_name, shm_test_size, base_addr))
            assert resp == "OK", "ATTACH failed: %s" % resp

            # Write shm content to a temp file (data_ptr = base_addr)
            with tempfile.NamedTemporaryFile(delete=False) as f:
                tmp_path = f.name
            try:
                resp = send("WRITE %s %d 17" % (tmp_path, base_addr))
                assert resp.startswith("OK"), "WRITE failed: %s" % resp

                with open(tmp_path, "rb") as f:
                    assert f.read() == msg

                # Now zero the shm region and read back
                shm.buf[:17] = b"\x00" * 17
                resp = send("READ %s %d 17" % (tmp_path, base_addr))
                assert resp.startswith("OK"), "READ failed: %s" % resp
                assert bytes(shm.buf[:17]) == msg
            finally:
                os.unlink(tmp_path)

            send("QUIT")
            proc.wait(timeout=5)
        finally:
            del arr  # Release exported pointer before close
            shm.close()
            shm.unlink()

    def test_worker_tcp_mode(self, worker_binary):
        """Verify the worker can run in TCP mode and
        communicate via socket."""
        shm_test_name = "lmcache_tcp_test"
        shm_test_size = 4096
        tcp_port = 19800

        shm = shared_memory.SharedMemory(
            name=shm_test_name,
            create=True,
            size=shm_test_size,
        )

        try:
            msg = b"Hello TCP Worker!"
            shm.buf[:17] = msg

            arr_t = ctypes.c_uint8 * shm_test_size
            arr = arr_t.from_buffer(shm.buf)
            base_addr = ctypes.addressof(arr)

            # Start worker in TCP mode
            proc = subprocess.Popen(
                [
                    worker_binary,
                    "--listen",
                    "127.0.0.1:%d" % tcp_port,
                ],
                stderr=subprocess.PIPE,
            )

            # Wait for worker to start listening
            time.sleep(0.5)

            # Standard
            import socket as sock_mod

            conn = sock_mod.socket(sock_mod.AF_INET, sock_mod.SOCK_STREAM)
            conn.connect(("127.0.0.1", tcp_port))

            def send_tcp(cmd):
                conn.sendall((cmd + "\n").encode("utf-8"))
                buf = b""
                while b"\n" not in buf:
                    data = conn.recv(4096)
                    if not data:
                        return "ERROR connection closed"
                    buf += data
                return buf.split(b"\n", 1)[0].decode().strip()

            # Attach
            resp = send_tcp(
                "ATTACH /%s %d %d" % (shm_test_name, shm_test_size, base_addr)
            )
            assert resp == "OK", "ATTACH failed: %s" % resp

            # Write to file
            with tempfile.NamedTemporaryFile(delete=False) as f:
                tmp_path = f.name
            try:
                resp = send_tcp("WRITE %s %d 17" % (tmp_path, base_addr))
                assert resp.startswith("OK"), "WRITE failed: %s" % resp

                with open(tmp_path, "rb") as f:
                    assert f.read() == msg

                # Zero and read back
                shm.buf[:17] = b"\x00" * 17
                resp = send_tcp("READ %s %d 17" % (tmp_path, base_addr))
                assert resp.startswith("OK"), "READ failed: %s" % resp
                assert bytes(shm.buf[:17]) == msg
            finally:
                os.unlink(tmp_path)

            send_tcp("QUIT")
            conn.close()
            proc.wait(timeout=5)
        finally:
            del arr
            shm.close()
            shm.unlink()
