"""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 hashlib
import logging
import multiprocessing as mp
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

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()

        # 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)"
        )

        # 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 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

        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
        gpu_map = GpuCameraMap(is_producer=True)
        if not 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:
            gpu_map.set_bulk_mapping(mappings)
        logger.warning(f"GpuCameraMap initialized with {len(mappings)} camera mappings")

        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()
