"""NVDEC Worker Manager for StreamingGateway integration.

This module provides a manager for the NVDEC hardware decoding backend.
Supports fully dynamic camera configuration at runtime (add, remove, update)
with smart per-GPU restarts and debounced batching to minimize disruption.
Outputs to CUDA IPC ring buffers (NV12 format) for zero-copy GPU inference pipelines.
"""

import glob
import hashlib
import logging
import multiprocessing as mp
import os
import threading
import time
from typing import Dict, List, Optional, Any, Set

from .nvdec import (
    nvdec_pool_process,
    StreamConfig,
    DemuxerType,
    CUPY_AVAILABLE,
    PYNVCODEC_AVAILABLE,
    RING_BUFFER_AVAILABLE,
)

from matrice_common.stream.gpu_camera_map import GpuCameraMap
from matrice_common.stream.cuda_shm_ring_buffer import GlobalFrameCounter

logger = logging.getLogger(__name__)


def is_nvdec_available() -> bool:
    """Check if NVDEC backend is available.

    Requires:
        - CuPy with CUDA support
        - PyNvVideoCodec for NVDEC hardware decode
        - cuda_shm_ring_buffer module for CUDA IPC
    """
    return CUPY_AVAILABLE and PYNVCODEC_AVAILABLE and RING_BUFFER_AVAILABLE


def get_available_gpu_count() -> int:
    """Detect the number of available CUDA GPUs.

    Returns:
        Number of available GPUs, or 1 if detection fails.
    """
    if not CUPY_AVAILABLE:
        return 1

    try:
        import cupy as cp
        return cp.cuda.runtime.getDeviceCount()
    except Exception as e:
        logger.warning(f"Failed to detect GPU count: {e}, defaulting to 1")
        return 1


class NVDECWorkerManager:
    """Manager for NVDEC worker processes - fully dynamic camera configuration.

    This manager wraps the existing nvdec_pool_process function to integrate
    with StreamingGateway. Key features:

    - Fully dynamic camera configuration (add, remove, update at runtime)
    - Smart per-GPU restarts: only restarts workers for GPUs whose cameras changed
    - Debounced batching: rapid successive changes are batched into a single restart
    - Stable GPU assignment: cameras are assigned to GPUs via consistent hashing,
      so add/remove of one camera doesn't shuffle other cameras across GPUs
    - Outputs to CUDA IPC ring buffers (not Redis/Kafka)
    - NV12 format output (50% smaller than RGB)
    - One worker process per GPU
    """

    def __init__(
        self,
        camera_configs: List[Dict[str, Any]],
        stream_config: Dict[str, Any],  # Unused but kept for interface consistency
        gpu_id: int = 0,
        num_gpus: int = 0,  # 0 = auto-detect all available GPUs
        nvdec_pool_size: int = 8,
        nvdec_burst_size: int = 4,
        frame_width: int = 640,
        frame_height: int = 640,
        num_slots: int = 32,
        target_fps: int = 0,  # 0 = use per-camera FPS from config
        duration_sec: float = 0,  # 0 = infinite
        demuxer_type: str = "nvc",  # "nvc" or "gstreamer"
        restart_delay: float = 1.0,  # Debounce delay for batching changes (seconds)
    ):
        """Initialize NVDEC Worker Manager.

        Args:
            camera_configs: List of camera configuration dicts with keys:
                - camera_id or stream_key: Unique identifier (used for ring buffer naming)
                - source: Video file path or RTSP URL
                - width: Optional frame width (default: frame_width)
                - height: Optional frame height (default: frame_height)
                - fps: FPS limit for this camera (used by default)
            stream_config: Stream configuration (unused, for interface consistency)
            gpu_id: Primary GPU device ID (starting GPU for round-robin assignment)
            num_gpus: Number of GPUs to use (0 = auto-detect all available GPUs)
            nvdec_pool_size: Number of NVDEC decoders per GPU
            nvdec_burst_size: Frames per stream before rotating to next
            frame_width: Default output frame width (used if camera config doesn't specify)
            frame_height: Default output frame height (used if camera config doesn't specify)
            num_slots: Ring buffer slots per camera
            target_fps: Global FPS override (0 = use per-camera FPS from config)
            duration_sec: Duration to run (0 = infinite until stop)
            demuxer_type: Demuxer backend - "nvc" (PyNvVideoCodec, fastest for files) or
                          "gstreamer" (provides ABSOLUTE RTP timestamps for RTSP streams)
            restart_delay: Seconds to wait before restarting after a config change,
                           allowing multiple rapid changes to be batched into one restart
        """
        if not is_nvdec_available():
            raise RuntimeError(
                "NVDEC not available. Requires CuPy, PyNvVideoCodec, and cuda_shm_ring_buffer"
            )

        self.camera_configs = list(camera_configs)
        self.stream_config = stream_config
        self.gpu_id = gpu_id

        # Auto-detect GPUs if num_gpus is 0
        if num_gpus <= 0:
            detected_gpus = get_available_gpu_count()
            self.num_gpus = min(detected_gpus, 8)  # Max 8 GPUs
            logger.info(f"Auto-detected {detected_gpus} GPU(s), using {self.num_gpus}")
        else:
            self.num_gpus = min(num_gpus, 8)  # Max 8 GPUs
        self.nvdec_pool_size = nvdec_pool_size
        self.nvdec_burst_size = nvdec_burst_size
        self.frame_width = frame_width
        self.frame_height = frame_height
        self.num_slots = num_slots
        self.target_fps = target_fps
        self.duration_sec = duration_sec if duration_sec > 0 else float('inf')
        self.demuxer_type = demuxer_type
        self._restart_delay = restart_delay

        # Per-GPU worker tracking
        self._gpu_workers: Dict[int, mp.Process] = {}
        self._gpu_stop_events: Dict[int, Any] = {}

        # Shared multiprocessing primitives (created in start(), persist across per-GPU restarts)
        self._mp_ctx: Optional[Any] = None
        self._result_queue: Optional[mp.Queue] = None
        self._shared_frame_count: Optional[Any] = None
        self._gpu_frame_counts: Dict[int, Any] = {}
        self._start_time: Optional[float] = None
        self._is_running = False

        # Debounced restart support
        self._restart_lock = threading.Lock()
        self._restart_timer: Optional[threading.Timer] = None
        self._gpus_needing_restart: Set[int] = set()
        self._gpu_map: Optional[GpuCameraMap] = None

        # Convert camera configs to StreamConfig objects and assign to GPUs
        self._stream_configs: List[StreamConfig] = []
        self._gpu_camera_assignments: Dict[int, List[StreamConfig]] = {
            i: [] for i in range(self.num_gpus)
        }
        self._camera_to_gpu: Dict[str, int] = {}

        self._prepare_camera_configs()

        logger.info(
            f"NVDECWorkerManager initialized: {len(camera_configs)} cameras, "
            f"{self.num_gpus} GPU(s), pool_size={nvdec_pool_size}, demuxer={demuxer_type}, "
            f"restart_delay={restart_delay}s"
        )

    # ========================================================================
    # GPU Assignment
    # ========================================================================

    def _get_gpu_for_camera(self, camera_id: str) -> int:
        """Assign a camera to a GPU using consistent hashing.

        Unlike index-based round-robin, this ensures a camera always maps to the
        same GPU regardless of other cameras being added or removed. This is
        critical for per-GPU restarts: adding camera C to GPU0 should NOT
        cause cameras on GPU1 to be reshuffled and restarted.

        Args:
            camera_id: Unique camera identifier

        Returns:
            GPU ID for this camera
        """
        hash_bytes = hashlib.md5(camera_id.encode()).digest()[:4]
        hash_val = int.from_bytes(hash_bytes, 'little')
        return (self.gpu_id + hash_val) % self.num_gpus

    # ========================================================================
    # Camera Config Preparation
    # ========================================================================

    def _prepare_camera_configs(self):
        """Convert dict configs to StreamConfig and distribute across GPUs.

        Ring buffers are named using camera_id for SHM identification.
        Per-camera FPS from config is used by default (target_fps=0 means use config FPS).
        Uses consistent hashing for stable GPU assignment across adds/removes.
        """
        self._stream_configs.clear()
        self._gpu_camera_assignments = {i: [] for i in range(self.num_gpus)}
        self._camera_to_gpu.clear()

        for i, config in enumerate(self.camera_configs):
            camera_id = config.get('camera_id') or config.get('stream_key') or f"cam_{i:04d}"

            source = config.get('source') or config.get('video_path')
            if not source:
                logger.warning(f"Camera {camera_id} has no source, skipping")
                continue

            width = config.get('width') or self.frame_width
            height = config.get('height') or self.frame_height

            if self.target_fps > 0:
                fps = self.target_fps
            else:
                fps = config.get('fps', 10)

            gpu_id = self._get_gpu_for_camera(camera_id)

            stream_config = StreamConfig(
                camera_id=camera_id,
                video_path=source,
                width=width,
                height=height,
                target_fps=fps,
                gpu_id=gpu_id,
                demuxer_type=self.demuxer_type,
            )

            self._stream_configs.append(stream_config)
            self._gpu_camera_assignments[gpu_id].append(stream_config)
            self._camera_to_gpu[camera_id] = gpu_id

            logger.debug(f"Camera {camera_id}: source={source}, {width}x{height}@{fps}fps, GPU{gpu_id}")

    def _snapshot_gpu_camera_ids(self) -> Dict[int, Set[str]]:
        """Take a snapshot of current camera IDs per GPU for change detection."""
        return {
            gpu_id: {sc.camera_id for sc in cams}
            for gpu_id, cams in self._gpu_camera_assignments.items()
        }

    def _get_affected_gpus(
        self,
        old_snapshot: Dict[int, Set[str]],
        new_snapshot: Dict[int, Set[str]],
    ) -> Set[int]:
        """Compare snapshots to find which GPUs had their camera list change."""
        affected: Set[int] = set()
        all_gpus = set(old_snapshot.keys()) | set(new_snapshot.keys())
        for gpu_id in all_gpus:
            if old_snapshot.get(gpu_id, set()) != new_snapshot.get(gpu_id, set()):
                affected.add(gpu_id)
        return affected

    # ========================================================================
    # Worker Lifecycle (per-GPU granularity)
    # ========================================================================

    def _start_gpu_worker(self, gpu_id: int) -> None:
        """Start a single GPU worker process.

        Args:
            gpu_id: GPU device ID to start worker for
        """
        gpu_cameras = self._gpu_camera_assignments.get(gpu_id, [])
        if not gpu_cameras:
            return

        if self._mp_ctx is None:
            logger.error("Cannot start GPU worker: multiprocessing context not initialized")
            return

        stop_event = self._mp_ctx.Event()
        self._gpu_stop_events[gpu_id] = stop_event

        if gpu_id not in self._gpu_frame_counts:
            self._gpu_frame_counts[gpu_id] = self._mp_ctx.Value('L', 0)

        total_num_streams = len(self._stream_configs)
        total_num_gpus = len([
            g for g in range(self.num_gpus) if self._gpu_camera_assignments.get(g)
        ])

        p = self._mp_ctx.Process(
            target=nvdec_pool_process,
            args=(
                gpu_id,
                gpu_cameras,
                self.nvdec_pool_size,
                self.duration_sec,
                self._result_queue,
                stop_event,
                self.nvdec_burst_size,
                self.num_slots,
                self.target_fps,
                self._shared_frame_count,
                self._gpu_frame_counts,
                total_num_streams,
                total_num_gpus,
                self.demuxer_type,
            ),
            name=f"NVDECWorker-GPU{gpu_id}",
            daemon=False,
        )
        p.start()
        self._gpu_workers[gpu_id] = p
        logger.info(
            f"Started NVDEC worker on GPU {gpu_id} (PID: {p.pid}) "
            f"with {len(gpu_cameras)} cameras"
        )

    def _stop_gpu_worker(self, gpu_id: int, timeout: float = 15.0) -> None:
        """Stop a single GPU worker process gracefully.

        Args:
            gpu_id: GPU device ID to stop worker for
            timeout: Maximum time to wait for graceful shutdown
        """
        if gpu_id not in self._gpu_workers:
            return

        stop_event = self._gpu_stop_events.get(gpu_id)
        if stop_event:
            stop_event.set()

        p = self._gpu_workers[gpu_id]
        p.join(timeout=timeout)
        if p.is_alive():
            logger.warning(f"NVDECWorker-GPU{gpu_id} did not stop gracefully, terminating")
            p.terminate()
            p.join(timeout=2.0)

        del self._gpu_workers[gpu_id]
        self._gpu_stop_events.pop(gpu_id, None)
        logger.info(f"Stopped NVDEC worker on GPU {gpu_id}")

    def _restart_gpu_worker(self, gpu_id: int) -> None:
        """Stop and restart a single GPU worker with its current camera assignment."""
        self._stop_gpu_worker(gpu_id)
        if self._gpu_camera_assignments.get(gpu_id):
            self._start_gpu_worker(gpu_id)
        else:
            logger.info(f"GPU {gpu_id} has no cameras after config change, worker not restarted")

    # ========================================================================
    # Debounced Restart (batches rapid changes into a single restart)
    # ========================================================================

    def _schedule_restart(self, affected_gpus: Optional[Set[int]] = None) -> None:
        """Schedule a debounced restart for the affected GPUs.

        Multiple calls within the restart_delay window are batched: the timer
        resets each time, and all accumulated GPU IDs are restarted together
        when the timer finally fires.

        Args:
            affected_gpus: Set of GPU IDs that need restart, or None for all GPUs
        """
        with self._restart_lock:
            if affected_gpus:
                self._gpus_needing_restart.update(affected_gpus)
            else:
                self._gpus_needing_restart = set(range(self.num_gpus))

            if self._restart_timer is not None:
                self._restart_timer.cancel()

            self._restart_timer = threading.Timer(
                self._restart_delay,
                self._execute_scheduled_restart,
            )
            self._restart_timer.daemon = True
            self._restart_timer.start()

            logger.info(
                f"Restart scheduled in {self._restart_delay}s for GPU(s): "
                f"{sorted(self._gpus_needing_restart)}"
            )

    def _execute_scheduled_restart(self) -> None:
        """Execute the debounced restart (called by the timer thread).

        Grabs the accumulated set of GPU IDs atomically, then performs
        the actual stop/start outside the lock to avoid blocking callers.
        """
        with self._restart_lock:
            gpus_to_restart = self._gpus_needing_restart.copy()
            self._gpus_needing_restart.clear()
            self._restart_timer = None

        if not gpus_to_restart:
            return

        sorted_gpus = sorted(gpus_to_restart)
        total_cams = sum(
            len(self._gpu_camera_assignments.get(g, [])) for g in sorted_gpus
        )
        logger.info(
            f"Executing batched restart for GPU(s) {sorted_gpus} "
            f"({total_cams} cameras affected)"
        )

        # Update GpuCameraMap with current camera-GPU assignments
        # so inference containers see the new mapping before workers start
        if self._gpu_map is not None:
            mappings = {cfg.camera_id: cfg.gpu_id for cfg in self._stream_configs}
            self._gpu_map.set_bulk_mapping(mappings)
            logger.info(f"GpuCameraMap updated with {len(mappings)} camera mappings")

        # Stop affected workers first (in parallel they'll all get the signal)
        for gpu_id in sorted_gpus:
            self._stop_gpu_worker(gpu_id)

        # Start workers with latest camera assignments
        for gpu_id in sorted_gpus:
            if self._gpu_camera_assignments.get(gpu_id):
                self._start_gpu_worker(gpu_id)

        logger.info(f"Batched restart complete for GPU(s) {sorted_gpus}")

    # ========================================================================
    # Public Lifecycle Methods
    # ========================================================================

    def _clean_stale_shm(self) -> None:
        """Remove stale SHM files from previous runs.

        Cleans up cuda_ipc ring buffers, gpu_camera_map, global_frame_counter,
        and loky semaphores left behind by a previous SG instance. This prevents
        consumers from reading stale frames and ensures a clean startup.
        """
        shm_patterns = [
            "/dev/shm/cuda_ipc_*",
            "/dev/shm/global_frame_counter",
            "/dev/shm/gpu_camera_map",
            "/dev/shm/sem.loky-*",
        ]
        removed = 0
        for pattern in shm_patterns:
            for path in glob.glob(pattern):
                try:
                    os.remove(path)
                    removed += 1
                except OSError as e:
                    logger.warning(f"Failed to remove stale SHM {path}: {e}")
        if removed:
            logger.warning(f"Cleaned {removed} stale SHM files from previous run")

    def start(self) -> None:
        """Start NVDEC worker processes (one per GPU).

        Initializes shared multiprocessing primitives and starts a worker
        process for each GPU that has cameras assigned. If no cameras are
        configured, primitives are still created so that later add_camera
        calls can schedule per-GPU starts without a full restart.
        """
        if self._is_running:
            logger.warning("NVDECWorkerManager is already running")
            return

        # Auto-clean stale SHM files from previous runs
        self._clean_stale_shm()

        self._mp_ctx = mp.get_context("spawn")
        self._result_queue = self._mp_ctx.Queue()
        self._shared_frame_count = self._mp_ctx.Value('L', 0)
        self._start_time = time.perf_counter()

        # Pre-create per-GPU frame counters for all GPUs
        for gpu_id in range(self.num_gpus):
            self._gpu_frame_counts[gpu_id] = self._mp_ctx.Value('L', 0)

        # Initialize GpuCameraMap in main process (before spawning workers)
        # so it's available for all GPU worker processes regardless of which GPUs have cameras
        self._gpu_map = GpuCameraMap(is_producer=True)
        if not self._gpu_map.initialize():
            raise RuntimeError("Failed to initialize GpuCameraMap")
        mappings = {}
        for cfg in self._stream_configs:
            mappings[cfg.camera_id] = cfg.gpu_id
        if mappings:
            self._gpu_map.set_bulk_mapping(mappings)
        logger.warning(f"GpuCameraMap initialized with {len(mappings)} camera mappings")

        # Initialize GlobalFrameCounter in main process before spawning workers
        self._global_frame_counter = GlobalFrameCounter(is_producer=True)
        if not self._global_frame_counter.initialize():
            raise RuntimeError("Failed to initialize GlobalFrameCounter")
        logger.info("GlobalFrameCounter initialized in main process")

        if not self._stream_configs:
            logger.info(
                "No cameras configured, NVDEC infrastructure ready for dynamic camera addition"
            )
        else:
            total_num_gpus = len([
                g for g in range(self.num_gpus) if self._gpu_camera_assignments[g]
            ])
            logger.info(
                f"Starting NVDEC: {len(self._stream_configs)} cameras "
                f"across {total_num_gpus} GPUs"
            )

            for gpu_id in range(self.num_gpus):
                if self._gpu_camera_assignments[gpu_id]:
                    self._start_gpu_worker(gpu_id)

        self._is_running = True
        logger.info(f"NVDECWorkerManager started: {len(self._gpu_workers)} GPU workers active")

    def stop(self, timeout: float = 15.0) -> None:
        """Stop all worker processes and cancel any pending restart.

        Args:
            timeout: Maximum time to wait for each worker to stop gracefully
        """
        if not self._is_running:
            logger.warning("NVDECWorkerManager is not running")
            return

        logger.info("Stopping NVDECWorkerManager...")

        # Cancel any pending debounced restart
        with self._restart_lock:
            if self._restart_timer is not None:
                self._restart_timer.cancel()
                self._restart_timer = None
            self._gpus_needing_restart.clear()

        # Stop all GPU workers
        for gpu_id in list(self._gpu_workers.keys()):
            self._stop_gpu_worker(gpu_id, timeout)

        self._is_running = False
        logger.info("NVDECWorkerManager stopped")

    def restart_workers(self) -> None:
        """Full restart of all workers (stops everything, then starts fresh).

        This is the heavy-weight approach. Prefer add_camera/remove_camera/update_camera
        which use smart per-GPU restarts with debouncing.
        """
        self.stop()
        self.start()

    # ========================================================================
    # Dynamic Camera Management (smart per-GPU restarts)
    # ========================================================================

    def add_camera(self, camera_config: Dict[str, Any]) -> bool:
        """Add a new camera at runtime.

        The config change is applied immediately. If the manager is running,
        a debounced per-GPU restart is scheduled so that rapid successive adds
        are batched into a single restart affecting only the relevant GPU(s).

        Args:
            camera_config: Camera configuration dict

        Returns:
            True if the camera was accepted (restart may be pending)
        """
        camera_id = camera_config.get('camera_id') or camera_config.get('stream_key')
        if not camera_id:
            logger.error("Camera config missing camera_id/stream_key")
            return False

        if camera_id in self._camera_to_gpu:
            logger.warning(f"Camera {camera_id} already exists in NVDEC config")
            return False

        # Snapshot before change
        old_snapshot = self._snapshot_gpu_camera_ids()

        self.camera_configs.append(camera_config)
        self._prepare_camera_configs()

        # Snapshot after change
        new_snapshot = self._snapshot_gpu_camera_ids()
        affected = self._get_affected_gpus(old_snapshot, new_snapshot)

        if not self._is_running:
            logger.info(f"Camera {camera_id} added to config (manager not yet started)")
            return True

        self._schedule_restart(affected)
        logger.info(
            f"Camera {camera_id} queued for add → GPU(s) {sorted(affected)} "
            f"will restart in {self._restart_delay}s"
        )
        return True

    def remove_camera(self, stream_key: str) -> bool:
        """Remove a camera at runtime.

        Args:
            stream_key: Camera ID / stream key to remove

        Returns:
            True if the camera was found and removed (restart may be pending)
        """
        found = False
        for i, config in enumerate(self.camera_configs):
            cid = config.get('camera_id') or config.get('stream_key')
            if cid == stream_key:
                del self.camera_configs[i]
                found = True
                break

        if not found:
            logger.warning(f"Camera {stream_key} not found in NVDEC config")
            return False

        old_snapshot = self._snapshot_gpu_camera_ids()
        self._prepare_camera_configs()
        new_snapshot = self._snapshot_gpu_camera_ids()
        affected = self._get_affected_gpus(old_snapshot, new_snapshot)

        if not self._is_running:
            logger.info(f"Camera {stream_key} removed from config (manager not yet started)")
            return True

        self._schedule_restart(affected)
        logger.info(
            f"Camera {stream_key} queued for removal → GPU(s) {sorted(affected)} "
            f"will restart in {self._restart_delay}s"
        )
        return True

    def update_camera(self, camera_config: Dict[str, Any]) -> bool:
        """Update a camera's configuration at runtime.

        Args:
            camera_config: Updated camera configuration dict

        Returns:
            True if the camera was found and updated (restart may be pending)
        """
        camera_id = camera_config.get('camera_id') or camera_config.get('stream_key')
        if not camera_id:
            logger.error("Camera config missing camera_id/stream_key")
            return False

        found = False
        for i, config in enumerate(self.camera_configs):
            cid = config.get('camera_id') or config.get('stream_key')
            if cid == camera_id:
                self.camera_configs[i] = camera_config
                found = True
                break

        if not found:
            logger.warning(f"Camera {camera_id} not found in NVDEC config for update")
            return False

        old_snapshot = self._snapshot_gpu_camera_ids()
        self._prepare_camera_configs()
        new_snapshot = self._snapshot_gpu_camera_ids()
        affected = self._get_affected_gpus(old_snapshot, new_snapshot)

        # For updates, even if the camera set didn't change (same IDs),
        # we still need to restart the GPU to pick up new config (e.g. new source URL)
        if not affected:
            affected = {self._camera_to_gpu[camera_id]} if camera_id in self._camera_to_gpu else set()

        if not self._is_running:
            logger.info(f"Camera {camera_id} updated in config (manager not yet started)")
            return True

        self._schedule_restart(affected)
        logger.info(
            f"Camera {camera_id} queued for update → GPU(s) {sorted(affected)} "
            f"will restart in {self._restart_delay}s"
        )
        return True

    # ========================================================================
    # Statistics & Properties
    # ========================================================================

    def get_worker_statistics(self) -> Dict[str, Any]:
        """Return statistics from workers.

        Returns:
            Dict with worker count, camera count, FPS metrics, per-GPU stats, etc.
        """
        stats: Dict[str, Any] = {
            'backend': 'nvdec',
            'num_workers': len(self._gpu_workers),
            'running_workers': sum(1 for p in self._gpu_workers.values() if p.is_alive()),
            'total_cameras': len(self._stream_configs),
            'gpu_assignments': {
                gpu_id: len(cameras)
                for gpu_id, cameras in self._gpu_camera_assignments.items()
            },
            'nvdec_config': {
                'gpu_id': self.gpu_id,
                'num_gpus': self.num_gpus,
                'pool_size': self.nvdec_pool_size,
                'burst_size': self.nvdec_burst_size,
                'frame_size': f"{self.frame_width}x{self.frame_height}",
                'num_slots': self.num_slots,
                'target_fps': self.target_fps,
                'demuxer_type': self.demuxer_type,
            },
        }

        # Add frame count and FPS
        if self._shared_frame_count:
            total_frames = self._shared_frame_count.value  # type: ignore[attr-defined]
            stats['total_frames'] = total_frames

            if self._start_time:
                elapsed = time.perf_counter() - self._start_time
                stats['elapsed_sec'] = elapsed
                stats['aggregate_fps'] = total_frames / elapsed if elapsed > 0 else 0
                stats['per_stream_fps'] = (
                    stats['aggregate_fps'] / len(self._stream_configs)  # type: ignore[operator]
                    if self._stream_configs else 0
                )

        # Add per-GPU frame counts and FPS
        if self._gpu_frame_counts and self._start_time:
            elapsed = time.perf_counter() - self._start_time
            gpu_stats = {}
            for gpu_id, counter in self._gpu_frame_counts.items():
                gpu_frames = counter.value  # type: ignore[attr-defined]
                num_cams = len(self._gpu_camera_assignments.get(gpu_id, []))
                gpu_fps = gpu_frames / elapsed if elapsed > 0 else 0
                gpu_per_cam = gpu_fps / num_cams if num_cams > 0 else 0
                gpu_stats[f'GPU{gpu_id}'] = {
                    'frames': gpu_frames,
                    'cameras': num_cams,
                    'fps': gpu_fps,
                    'fps_per_cam': gpu_per_cam,
                }
            stats['per_gpu_stats'] = gpu_stats

        # Collect any available results from queue (non-blocking)
        gpu_results = []
        if self._result_queue:
            while True:
                try:
                    result = self._result_queue.get_nowait()
                    gpu_results.append(result)
                except Exception:
                    break
        stats['gpu_results'] = gpu_results

        return stats

    def get_camera_assignments(self) -> Dict[str, int]:
        """Return mapping of camera_id to GPU ID.

        Returns:
            Dict mapping camera_id -> gpu_id
        """
        return self._camera_to_gpu.copy()

    @property
    def is_running(self) -> bool:
        """Check if the manager is currently running."""
        return self._is_running

    def __enter__(self):
        """Context manager entry."""
        self.start()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Context manager exit."""
        self.stop()
