# SPDX-License-Identifier: Apache-2.0
# Standard
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, NamedTuple, Optional, Set
import threading

# Third Party
import zmq.asyncio

# First Party
from lmcache.logging import init_logger
from lmcache.v1.cache_controller.locks import FastLockWithTimeout, RWLockWithTimeout
from lmcache.v1.cache_controller.message import BatchedKVOperationMsg, WorkerInfo
from lmcache.v1.utils.cache_utils import TTLListCache

logger = init_logger(__name__)


class KVChunkInfo(NamedTuple):
    """
    Represents the location information of a KV chunk in the cluster.
    This class is immutable and can be used as a dictionary key.
    """

    instance_id: str
    worker_id: int
    location: str


class FullSyncState(Enum):
    """State of full sync for a worker"""

    IDLE = "idle"  # Not in sync
    SYNCING = "syncing"  # Sync in progress
    COMPLETED = "completed"  # Sync completed
    FAILED = "failed"  # Sync failed/timeout


@dataclass
class WorkerSyncInfo:
    """Information about a worker's sync state"""

    sync_id: str
    state: FullSyncState
    start_time: float
    expected_total_keys: int
    expected_batch_count: int
    received_batches: Set[int] = field(default_factory=set)
    received_keys_count: int = 0
    last_activity_time: float = 0.0

    def __post_init__(self):
        if self.last_activity_time == 0.0:
            self.last_activity_time = self.start_time


@dataclass
class WorkerNode:
    """
    Represents a single worker with all its associated metadata.
    """

    worker_id: int
    ip: str
    port: int
    peer_init_url: Optional[str]
    socket: Optional[zmq.asyncio.Socket]
    registration_time: float
    last_heartbeat_time: float
    # Guarded by _lock
    seq_tracker: dict[str, int] = field(default_factory=dict)  # location -> seq_num
    kv_store: dict[str, set[int]] = field(
        default_factory=dict
    )  # location -> set[chunk_hash]
    sync_info: Optional[WorkerSyncInfo] = None  # Full sync state

    def __post_init__(self):
        # Fast lock with timeout for WorkerNode operations
        self._lock = FastLockWithTimeout()

    def handle_batched_kv_operations(
        self,
        msg: BatchedKVOperationMsg,
        on_seq_num_out_of_order: Optional[Callable[[], None]] = None,
        is_full_sync: bool = False,
    ) -> bool:
        """
        Handle batched KV operations with single lock acquisition.
        Logs warning and calls callback if sequence out of order is detected.

        Args:
            msg: The batched KV operation message.
            on_seq_num_out_of_order: Callback when sequence out of order detected.
            is_full_sync: If True, this is a full sync batch operation.
                         During full sync, sequence check is skipped.
                         When worker is syncing, non-full-sync operations are rejected.

        Returns:
            True if operations were processed, False if rejected
            (e.g., during syncing when is_full_sync=False).
        """
        seq_warning: Optional[tuple[int, int, int]] = None
        with self._lock:
            # During syncing, reject non-full-sync operations
            if (
                self.sync_info is not None
                and self.sync_info.state == FullSyncState.SYNCING
                and not is_full_sync
            ):
                return False

            location = msg.location

            if location not in self.kv_store:
                self.kv_store[location] = set()

            for op in msg.operations:
                # Apply operation first, then check sequence
                # (sequence check is skipped during full sync)
                if op.op_type.value == "admit":
                    self.kv_store[location].add(op.key)
                elif op.op_type.value == "evict":
                    self.kv_store[location].discard(op.key)
                else:
                    logger.error(f"Unknown op_type: {op.op_type}")

                # Skip sequence check during full sync
                if is_full_sync:
                    continue

                # Sequence check
                last_seq_num = self.seq_tracker.get(location)
                if last_seq_num is not None:
                    expected_seq = last_seq_num + 1
                    if op.seq_num != expected_seq and seq_warning is None:
                        seq_warning = (
                            expected_seq,
                            op.seq_num,
                            op.seq_num - expected_seq,
                        )
                self.seq_tracker[location] = op.seq_num

            # Clean up empty set
            if not self.kv_store[location]:
                self.kv_store.pop(location, None)

        # Log warning and call callback outside lock
        if seq_warning is not None:
            if on_seq_num_out_of_order is not None:
                on_seq_num_out_of_order()
            logger.warning(
                "KV batch sequence out of order detected: "
                "key=%s, expected_seq=%s, actual_seq=%s, gap=%s",
                (msg.instance_id, msg.worker_id, msg.location),
                seq_warning[0],
                seq_warning[1],
                seq_warning[2],
            )
        return True

    def has_kv(self, location: str, key: int) -> bool:
        """Check if a KV chunk exists in this worker."""
        with self._lock:
            return location in self.kv_store and key in self.kv_store[location]

    def get_kv_keys(self, location: str) -> set[int]:
        """Get all keys for a location."""
        with self._lock:
            keys = self.kv_store.get(location)
            if keys is None:
                return set()
            # Return a shallow copy for thread safety
            return set(keys)

    def clear_kv_store(self) -> None:
        """Clear all KV data for this worker."""
        with self._lock:
            self.kv_store.clear()
            self.seq_tracker.clear()

    def get_kv_count(self) -> int:
        """Get total count of KV chunks."""
        with self._lock:
            return sum(len(keys) for keys in self.kv_store.values())

    def update_seq_num(self, location: str, seq_num: int) -> None:
        """Update sequence number for a location."""
        with self._lock:
            self.seq_tracker[location] = seq_num

    def get_seq_num(self, location: str) -> Optional[int]:
        """Get sequence number for a location."""
        with self._lock:
            return self.seq_tracker.get(location)

    def find_key(
        self, key: int
    ) -> Optional[tuple[KVChunkInfo, Optional[str], set[int]]]:
        """
        Find a key in this worker's kv_store.
        Returns: (KVChunkInfo, peer_init_url, keys) if found, None otherwise.
        KVChunkInfo will have instance_id="" since WorkerNode doesn't know
        its instance.
        """
        with self._lock:
            for location, keys in self.kv_store.items():
                if key in keys:
                    # WorkerNode doesn't know its instance_id, so we leave it empty
                    # The caller should fill in the instance_id
                    return (
                        KVChunkInfo("", self.worker_id, location),
                        self.peer_init_url,
                        keys,
                    )
        return None

    def find_key_simple(self, key: int) -> Optional[KVChunkInfo]:
        """
        Find a key in this worker's kv_store, returning only KVChunkInfo.
        KVChunkInfo will have instance_id="" since WorkerNode doesn't know
        its instance.
        """
        with self._lock:
            for location, keys in self.kv_store.items():
                if key in keys:
                    return KVChunkInfo("", self.worker_id, location)
        return None

    def to_worker_info(self, instance_id: str) -> WorkerInfo:
        """Convert to WorkerInfo for backward compatibility."""
        # No need to lock here
        return WorkerInfo(
            instance_id=instance_id,
            worker_id=self.worker_id,
            ip=self.ip,
            port=self.port,
            peer_init_url=self.peer_init_url,
            registration_time=self.registration_time,
            last_heartbeat_time=self.last_heartbeat_time,
        )


@dataclass
class InstanceNode:
    """
    Represents an instance with all its workers.
    Tree structure: InstanceNode -> WorkerNode
    Each InstanceNode has its own lock for thread-safe worker operations.
    """

    instance_id: str
    # Guarded by _rwlock
    workers: dict[int, WorkerNode] = field(
        default_factory=dict
    )  # worker_id -> WorkerNode

    def __post_init__(self):
        # RW lock for protecting workers dict access
        self._rwlock = RWLockWithTimeout()

    def add_worker(self, worker_node: WorkerNode) -> None:
        """Add a worker to this instance."""
        with self._rwlock.write_lock(timeout=10):
            self.workers[worker_node.worker_id] = worker_node

    def remove_worker(self, worker_id: int) -> Optional[WorkerNode]:
        """Remove and return a worker from this instance."""
        with self._rwlock.write_lock(timeout=10):
            return self.workers.pop(worker_id, None)

    def get_worker(self, worker_id: int) -> Optional[WorkerNode]:
        """Get a worker by worker_id. Uses optimistic locking (lock-free read)."""
        # Optimistic read: workers dict rarely changes
        return self.workers.get(worker_id)

    def get_worker_ids(self) -> list[int]:
        """Get sorted list of worker IDs."""
        # Snapshot keys to avoid RuntimeError if dict changes during iteration
        return sorted(list(self.workers.keys()))

    def has_workers(self) -> bool:
        """Check if instance has any workers."""
        # Optimistic read: workers dict rarely changes
        return len(self.workers) > 0

    def get_all_worker_infos(self) -> list[WorkerInfo]:
        """Get WorkerInfo for all workers in this instance."""
        # Snapshot values to avoid RuntimeError if dict changes during iteration
        workers_snapshot = list(self.workers.values())
        return [worker.to_worker_info(self.instance_id) for worker in workers_snapshot]

    def find_key(
        self, key: int
    ) -> Optional[tuple[KVChunkInfo, Optional[str], set[int]]]:
        """
        Find a key in any worker within this instance.
        Returns: (KVChunkInfo, peer_init_url, keys) if found, None otherwise.
        """
        # Snapshot workers to avoid RuntimeError if dict changes during iteration
        workers_snapshot = list(self.workers.items())
        for worker_id, worker_node in workers_snapshot:
            if result := worker_node.find_key(key):
                # Fill in the instance_id in KVChunkInfo
                kv_info, peer_init_url, keys = result
                return (
                    KVChunkInfo(self.instance_id, worker_id, kv_info.location),
                    peer_init_url,
                    keys,
                )
        return None

    def find_key_simple(self, key: int) -> Optional[KVChunkInfo]:
        """
        Find a key in any worker within this instance, returning only KVChunkInfo.
        """
        # Snapshot workers to avoid RuntimeError if dict changes during iteration
        workers_snapshot = list(self.workers.items())
        for worker_id, worker_node in workers_snapshot:
            if kv_info := worker_node.find_key_simple(key):
                return KVChunkInfo(self.instance_id, worker_id, kv_info.location)
        return None

    def has_worker_with_ip(self, ip: str) -> bool:
        """
        Check if any worker in this instance has the specified IP address.
        """
        # Snapshot workers to avoid RuntimeError if dict changes during iteration
        return any(worker.ip == ip for worker in list(self.workers.values()))

    def get_total_kv_count(self) -> int:
        """
        Get total count of KV chunks across all workers in this instance.
        """
        # Snapshot workers to avoid RuntimeError if dict changes during iteration
        return sum(worker.get_kv_count() for worker in list(self.workers.values()))


class RegistryTree:
    """
    Central registry managing the tree structure of instances and workers.
    Structure: instance_id -> InstanceNode -> WorkerNode

    Lock hierarchy (from coarse to fine):
    1. RegistryTree._rwlock: protects instances dict access
    2. InstanceNode._rwlock: protects workers dict access
    3. WorkerNode._lock: protects kv_store and seq_tracker access

    This fine-grained locking allows concurrent operations on different
    instances/workers, improving throughput significantly.
    """

    def __init__(self):
        # Guarded by _rwlock
        # instance_id -> InstanceNode
        self.instances: dict[str, InstanceNode] = {}
        # RW lock only for protecting instances dict access
        self._rwlock = RWLockWithTimeout()
        # Atomic counter for sequence discontinuity (protected by _counter_lock)
        self._seq_discontinuity_count = 0
        self._counter_lock = threading.Lock()
        # Cache for get_all_worker_infos (for Prometheus metrics)
        self._worker_info_cache: TTLListCache[WorkerInfo] = TTLListCache[WorkerInfo]()
        # Cache for get_all_worker_nodes (for FullSyncTracker and other use cases)
        self._worker_node_cache: TTLListCache[tuple[str, WorkerNode]] = TTLListCache[
            tuple[str, WorkerNode]
        ]()

    def get_seq_discontinuity_count(self) -> int:
        """Get the count of sequence discontinuities (thread-safe)."""
        # Lock-free read, no need for lock
        return self._seq_discontinuity_count

    def _incr_seq_discontinuity_count(self) -> None:
        """Increment the sequence discontinuity counter (thread-safe)."""
        with self._counter_lock:
            self._seq_discontinuity_count += 1

    def _get_or_create_instance(self, instance_id: str) -> InstanceNode:
        """Get or create an instance node. Internal use only."""
        # Optimistic read first: instances dict rarely changes
        instance_node = self.instances.get(instance_id)
        if instance_node is not None:
            return instance_node

        # Need to create, use write lock
        with self._rwlock.write_lock(timeout=10):
            # Double-check after acquiring write lock
            instance_node = self.instances.get(instance_id)
            if instance_node is None:
                instance_node = InstanceNode(instance_id=instance_id)
                self.instances[instance_id] = instance_node
            return instance_node

    def _get_instance(self, instance_id: str) -> Optional[InstanceNode]:
        """Get an instance node. Uses optimistic locking (lock-free read)."""
        # Optimistic read: instances dict rarely changes
        return self.instances.get(instance_id)

    def register_worker(
        self,
        instance_id: str,
        worker_id: int,
        ip: str,
        port: int,
        peer_init_url: Optional[str],
        socket: zmq.asyncio.Socket,
        registration_time: float,
    ) -> WorkerNode:
        """Register a new worker, creating instance if needed."""
        # Get or create instance (locks instances dict)
        instance_node = self._get_or_create_instance(instance_id)

        # Create worker node
        worker_node = WorkerNode(
            worker_id=worker_id,
            ip=ip,
            port=port,
            peer_init_url=peer_init_url,
            socket=socket,
            registration_time=registration_time,
            last_heartbeat_time=registration_time,
        )
        # Add worker (locks workers dict in instance_node)
        instance_node.add_worker(worker_node)
        return worker_node

    def deregister_worker(
        self, instance_id: str, worker_id: int
    ) -> Optional[WorkerNode]:
        """Deregister a worker and clean up empty instances."""
        instance_node = self._get_instance(instance_id)
        if instance_node is None:
            return None

        # Remove worker (locks workers dict in instance_node)
        worker_node = instance_node.remove_worker(worker_id)

        # Clean up empty instance (need write lock on instances)
        if not instance_node.has_workers():
            # TODO(baoloongmao): Move timeout values to configuration
            with self._rwlock.write_lock(timeout=100):
                # Double-check after acquiring write lock
                # Use pop to avoid KeyError if another thread already removed it
                if not instance_node.has_workers() and instance_id in self.instances:
                    self.instances.pop(instance_id, None)

        return worker_node

    def get_worker(self, instance_id: str, worker_id: int) -> Optional[WorkerNode]:
        """Get a specific worker."""
        instance_node = self._get_instance(instance_id)
        if instance_node is None:
            return None
        return instance_node.get_worker(worker_id)

    def get_instance(self, instance_id: str) -> Optional[InstanceNode]:
        """Get an instance by instance_id."""
        return self._get_instance(instance_id)

    def get_instance_by_ip(self, ip: str) -> Optional[InstanceNode]:
        """Get an instance by IP address. Returns first instance if multiple exist."""
        # Snapshot to avoid RuntimeError if dict changes during iteration
        for instance_node in list(self.instances.values()):
            if instance_node.has_worker_with_ip(ip):
                return instance_node
        return None

    def get_instances_by_ip(self, ip: str) -> list[InstanceNode]:
        """Get all instances by IP address."""
        # Snapshot to avoid RuntimeError if dict changes during iteration
        result = []
        for instance_node in list(self.instances.values()):
            if instance_node.has_worker_with_ip(ip):
                result.append(instance_node)
        return result

    def get_worker_ids(self, instance_id: str) -> list[int]:
        """Get sorted list of worker IDs for an instance."""
        instance_node = self._get_instance(instance_id)
        if instance_node is None:
            return []
        return instance_node.get_worker_ids()

    def get_all_worker_infos_cached(
        self, timeout_seconds: Optional[float] = None
    ) -> list[WorkerInfo]:
        """Get WorkerInfo for all workers across all instances.

        Args:
            timeout_seconds: Cache timeout in seconds.
                - If None (default): use the cache's default timeout.
                - If 0: cache immediately expires, always get fresh data
                  and update cache.
                - If > 0: use cache with specified timeout.

        Returns:
            List of WorkerInfo, either from cache or fresh data
        """
        return self._worker_info_cache.get_cached(
            lambda: self._get_all_worker_infos_fresh(), timeout_override=timeout_seconds
        )

    def _get_all_worker_infos_fresh(self) -> list[WorkerInfo]:
        """Internal method to get fresh WorkerInfo without cache."""
        # Snapshot to avoid RuntimeError if dict changes during iteration
        result = []
        for instance_node in list(self.instances.values()):
            result.extend(instance_node.get_all_worker_infos())
        return result

    def get_all_worker_nodes_cached(
        self, timeout_seconds: Optional[float] = None
    ) -> list[tuple[str, WorkerNode]]:
        """Get all (instance_id, WorkerNode) tuples across all instances, with caching.

        Args:
            timeout_seconds: Cache timeout in seconds.
                - If None (default): use the cache's default timeout.
                - If 0: cache immediately expires, always get fresh data
                  and update cache.
                - If > 0: use cache with specified timeout.

        Returns:
            List of (instance_id, WorkerNode) tuples, either from cache or fresh data
        """
        return self._worker_node_cache.get_cached(
            lambda: self._get_all_worker_nodes_fresh(), timeout_override=timeout_seconds
        )

    def _get_all_worker_nodes_fresh(self) -> list[tuple[str, WorkerNode]]:
        """Internal method to get fresh (instance_id, WorkerNode) tuples
        without cache."""
        # Snapshot to avoid RuntimeError if dict changes during iteration
        result = []
        for instance_id, instance_node in self.instances.items():
            for worker_node in instance_node.workers.values():
                result.append((instance_id, worker_node))
        return result

    def update_heartbeat(
        self, instance_id: str, worker_id: int, timestamp: float
    ) -> bool:
        """Update worker heartbeat timestamp. Returns True if successful."""
        instance_node = self._get_instance(instance_id)
        if instance_node is None:
            return False
        worker_node = instance_node.get_worker(worker_id)
        if worker_node is None:
            return False
        worker_node.last_heartbeat_time = timestamp
        return True

    def handle_batched_kv_operations(
        self, msg: BatchedKVOperationMsg, is_full_sync: bool = False
    ) -> bool:
        """
        Handle batched KV operations by forwarding to WorkerNode.

        Args:
            msg: The batched KV operation message.
            is_full_sync: If True, this is a full sync batch operation.

        Returns:
            True if operations were processed successfully.
            False if worker not found or operations were rejected.
        """
        instance_node = self._get_instance(msg.instance_id)
        if instance_node is None:
            return False
        worker_node = instance_node.get_worker(msg.worker_id)
        if worker_node is None:
            return False
        return worker_node.handle_batched_kv_operations(
            msg,
            on_seq_num_out_of_order=self._incr_seq_discontinuity_count,
            is_full_sync=is_full_sync,
        )

    def find_kv(
        self,
        key: int,
        exclude_instance_id: Optional[str] = None,
    ) -> Optional[KVChunkInfo]:
        """
        Find a KV chunk across all workers.

        Args:
            key: The KV chunk key to find.
            exclude_instance_id: Instance ID to exclude
            (all workers in this instance will be excluded).

        Returns: KVChunkInfo if found, None otherwise.
        """
        with self._rwlock.read_lock(timeout=1):
            for instance_id, instance_node in self.instances.items():
                # Exclude all workers in the specified instance
                if (
                    exclude_instance_id is not None
                    and instance_id == exclude_instance_id
                ):
                    continue
                result = instance_node.find_key_simple(key)
                if result is not None:
                    return result
            return None

    def find_kv_with_worker_info(
        self,
        key: int,
        exclude_instance_id: Optional[str] = None,
    ) -> Optional[tuple[KVChunkInfo, Optional[str], set[int]]]:
        """
        Find a KV chunk and return worker info in one lookup.
        Optimized for batched_p2p_lookup to avoid multiple lookups.

        Returns: (KVChunkInfo, peer_init_url, kv_keys) if found, None otherwise.
        """
        # Snapshot instances to avoid RuntimeError if dict changes during iteration
        for instance_id, instance_node in list(self.instances.items()):
            if exclude_instance_id is not None and instance_id == exclude_instance_id:
                continue
            result = instance_node.find_key(key)
            if result is not None:
                return result
        return None

    def get_total_kv_count(self) -> int:
        """Get total count of KV chunks across all workers."""
        # Snapshot to avoid RuntimeError if dict changes during iteration
        total = 0
        for instance_node in list(self.instances.values()):
            total += instance_node.get_total_kv_count()
        return total

    def get_worker_kv_keys(
        self, instance_id: str, worker_id: int, location: str
    ) -> set[int]:
        """Get all KV keys for a specific worker and location."""
        instance_node = self._get_instance(instance_id)
        if instance_node is None:
            return set()
        worker_node = instance_node.get_worker(worker_id)
        if worker_node is None:
            return set()
        return worker_node.get_kv_keys(location)

    def clear_worker_kv(
        self, instance_id: str, worker_id: int, location: Optional[str] = None
    ) -> bool:
        """
        Clear all KV chunks for a specific worker and location.
        Returns True if successful, False if worker not found.
        """
        worker_node = self.get_worker(instance_id, worker_id)
        if worker_node is None:
            return False
        with worker_node._lock:
            if location is None:
                worker_node.kv_store.clear()
                worker_node.seq_tracker.clear()
                return True
            if location in worker_node.kv_store:
                del worker_node.kv_store[location]
            if location in worker_node.seq_tracker:
                del worker_node.seq_tracker[location]
        return True

    def clear_all_worker_kv(self, instance_id: str, worker_id: int) -> bool:
        """
        Clear all KV chunks for a worker across all locations.
        Returns True if successful, False if worker not found.
        """
        worker_node = self.get_worker(instance_id, worker_id)
        if worker_node is None:
            return False

        worker_node.clear_kv_store()
        return True
