#!/usr/bin/env python3
"""Streaming Gateway - CUDA IPC Video Producer (NVDEC Hardware Decode).

This module implements the producer side of the zero-copy video pipeline
using NVDEC hardware video decoding for maximum throughput.

Architecture:
=============

    ┌─────────────────────────────────────────────────────────────────────────┐
    │                    STREAMING GATEWAY (Producer)                         │
    ├─────────────────────────────────────────────────────────────────────────┤
    │                                                                         │
    │   ┌─────────────────────────────────────────────────────────────────┐   │
    │   │                   NVDEC Decoder Pool                            │   │
    │   │                                                                 │   │
    │   │  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐     │   │
    │   │  │  Decoder 0     │  │  Decoder 1     │  │  Decoder N     │     │   │
    │   │  │                │  │                │  │                │     │   │
    │   │  │   NVDEC HW     │  │   NVDEC HW     │  │   NVDEC HW     │     │   │
    │   │  │   decode       │  │   decode       │  │   decode       │     │   │
    │   │  │       ↓        │  │       ↓        │  │       ↓        │     │   │
    │   │  │  NV12 Resize   │  │  NV12 Resize   │  │  NV12 Resize   │     │   │
    │   │  │       ↓        │  │       ↓        │  │       ↓        │     │   │
    │   │  │   CUDA IPC     │  │   CUDA IPC     │  │   CUDA IPC     │     │   │
    │   │  │   Ring Buf     │  │   Ring Buf     │  │   Ring Buf     │     │   │
    │   │  │  (NV12 0.6MB)  │  │  (NV12 0.6MB)  │  (NV12 0.6MB)     │     │   │
    │   │  └────────────────┘  └────────────────┘  └────────────────┘     │   │
    │   │                                                                 │   │
    │   └─────────────────────────────────────────────────────────────────┘   │
    │                               │                                         │
    │                    Output: NV12 (H*1.5, W) uint8 = 0.6 MB               │
    │                    50% less IPC bandwidth than RGB                      │
    │                               ↓                                         │
    └───────────────────────────────┼─────────────────────────────────────────┘
                                    │
                         Consumer reads via CUDA IPC
                         → NV12→RGB→CHW→FP16 in one kernel
                         → TensorRT inference

Usage:
======
    python streaming_gateway.py --video videoplayback.mp4 --num-streams 100

Requirements:
=============
    - PyNvVideoCodec for NVDEC hardware decode
    - CuPy with CUDA support
    - cuda_shm_ring_buffer module
"""
from __future__ import annotations

import argparse
import logging
import multiprocessing as mp
import os
import time
import threading
import queue as thread_queue
import hashlib
import tempfile
import uuid
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import ClassVar, Dict, List, Optional, Tuple, Any, Generator
from urllib.parse import urlparse, urlunparse


class DemuxerType(Enum):
    """Demuxer backend selection.

    NVC: PyNvVideoCodec demuxer - fastest for local files
    GSTREAMER: GStreamer demuxer - provides ABSOLUTE RTP timestamps for RTSP
    """
    NVC = "nvc"
    GSTREAMER = "gstreamer"

import numpy as np

try:
    import requests
    REQUESTS_AVAILABLE = True
except ImportError:
    REQUESTS_AVAILABLE = False

try:
    import cupy as cp
    CUPY_AVAILABLE = True
except ImportError:
    CUPY_AVAILABLE = False
    cp = None

try:
    import PyNvVideoCodec as nvc
    PYNVCODEC_AVAILABLE = True
except ImportError:
    PYNVCODEC_AVAILABLE = False
    nvc = None

try:
    from matrice_common.stream.cuda_shm_ring_buffer import CudaIpcRingBuffer, GlobalFrameCounter
    RING_BUFFER_AVAILABLE = True
except (ImportError, AttributeError, RuntimeError, TypeError):
    # ImportError: module not found
    # AttributeError: cp.ndarray is None (cupy not available)
    # RuntimeError/TypeError: other initialization errors
    RING_BUFFER_AVAILABLE = False
    CudaIpcRingBuffer = None
    GlobalFrameCounter = None

from matrice_common.stream.gpu_camera_map import GpuCameraMap
GPU_CAMERA_MAP_AVAILABLE = True

# CRITICAL: PyNvVideoCodec bundles FFmpeg 6.x, OpenCV bundles FFmpeg 5.x, and
# GStreamer's gst-libav uses system FFmpeg 4.4. Loading all three in the same process
# causes SIGABRT/SIGSEGV when rtspsrc connects. The import-order approach is NOT sufficient
# because the package __init__.py imports OpenCV (via CameraStreamer), loading FFmpeg 5.x
# before GStreamer's rtspsrc needs to load its plugins.
#
# Solution: GStreamerSubprocessDemuxer runs GStreamer in a separate subprocess that never
# imports PyNvVideoCodec or OpenCV, avoiding the FFmpeg symbol conflict entirely.
# H264 bytes + RTP timestamps are streamed through a pipe to the parent process.
GSTREAMER_DEMUXER_AVAILABLE = False
GstRTPDemuxer = None
try:
    from .gstreamer_subprocess_demuxer import GStreamerSubprocessDemuxer as GstRTPDemuxer
    GSTREAMER_DEMUXER_AVAILABLE = True
except ImportError:
    try:
        from gstreamer_subprocess_demuxer import GStreamerSubprocessDemuxer as GstRTPDemuxer
        GSTREAMER_DEMUXER_AVAILABLE = True
    except ImportError:
        pass

logger = logging.getLogger(__name__)

# =============================================================================
# Fixed dimensions for NV12 output - ALL cameras resize to this size
# This matches the TensorRT model input requirements (640x640)
# =============================================================================
TARGET_WIDTH = 640
TARGET_HEIGHT = 640
NV12_HEIGHT = TARGET_HEIGHT + TARGET_HEIGHT // 2  # 960 = H * 1.5 for NV12 format

def setup_logging(quiet: bool = True):
    """Configure logging level based on quiet mode."""
    level = logging.WARNING if quiet else logging.INFO
    logging.basicConfig(
        level=level,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
    logging.getLogger('cuda_shm_ring_buffer').setLevel(logging.WARNING if quiet else logging.INFO)


# =============================================================================
# Video Downloader for HTTPS URLs (PyNvVideoCodec's FFmpeg lacks HTTPS support)
# =============================================================================

class VideoDownloader:
    """Downloads and caches video files from HTTPS URLs.

    PyNvVideoCodec uses a bundled FFmpeg that doesn't have HTTPS support.
    This class downloads HTTPS videos to local files before passing them
    to the NVDEC demuxer.

    Features:
    - URL deduplication: same video URL (ignoring query params) is only downloaded once
    - Disk caching: reuses existing files across runs
    - Progress tracking for large files
    - Dynamic timeout based on file size
    """

    # Configuration
    DOWNLOAD_TIMEOUT: ClassVar[int] = 300  # Base timeout in seconds
    DOWNLOAD_TIMEOUT_PER_100MB: ClassVar[int] = 300  # Additional seconds per 100MB
    MAX_DOWNLOAD_TIMEOUT: ClassVar[int] = 6000  # 100 minutes max
    DOWNLOAD_CHUNK_SIZE: ClassVar[int] = 8192

    # Singleton instance for process-wide caching
    _instance: ClassVar[Optional['VideoDownloader']] = None
    _lock: ClassVar[threading.Lock] = threading.Lock()

    def __new__(cls):
        """Singleton pattern for process-wide cache sharing."""
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialized = False  # type: ignore[has-type]
        return cls._instance

    def __init__(self):
        """Initialize the video downloader."""
        if self._initialized:  # type: ignore[has-type]
            return

        self._initialized = True
        self.downloaded_files: Dict[str, str] = {}
        self._normalized_url_to_path: Dict[str, str] = {}
        self._download_lock = threading.Lock()
        self.temp_dir = Path(tempfile.gettempdir()) / "nvdec_video_cache"
        self.temp_dir.mkdir(exist_ok=True)
        logger.info(f"VideoDownloader initialized, cache dir: {self.temp_dir}")

    def prepare_source(self, video_path: str, camera_id: str) -> str:
        """Prepare video source, downloading HTTPS URLs if needed.

        Args:
            video_path: Video file path, RTSP URL, or HTTPS URL
            camera_id: Camera identifier for logging

        Returns:
            Local file path (downloaded if HTTPS) or original path
        """
        if not self._is_https_url(video_path):
            return video_path

        if not REQUESTS_AVAILABLE:
            logger.warning(f"requests module not available, cannot download HTTPS URL for {camera_id}")
            return video_path

        local_path = self._download_video(video_path, camera_id)
        if local_path:
            return local_path

        logger.warning(f"Failed to download {video_path} for {camera_id}, will try URL directly (may fail)")
        return video_path

    def _is_https_url(self, source: str) -> bool:
        """Check if source is an HTTPS URL."""
        return source.startswith('https://')

    def _normalize_url(self, url: str) -> str:
        """Normalize URL by stripping query parameters for deduplication."""
        parsed = urlparse(url)
        return urlunparse((
            parsed.scheme,
            parsed.netloc,
            parsed.path,
            '', '', ''  # params, query, fragment
        ))

    def _get_url_hash(self, normalized_url: str) -> str:
        """Generate a short hash for consistent file naming."""
        return hashlib.md5(normalized_url.encode()).hexdigest()[:12]

    def _download_video(self, url: str, camera_id: str) -> Optional[str]:
        """Download video file from HTTPS URL with caching.

        Thread-safe: uses lock to prevent duplicate downloads.

        Args:
            url: HTTPS video URL
            camera_id: Camera identifier for logging

        Returns:
            Local file path or None if download failed
        """
        normalized_url = self._normalize_url(url)
        file_ext = Path(url.split('?')[0]).suffix or '.mp4'
        url_hash = self._get_url_hash(normalized_url)
        expected_path = self.temp_dir / f"video_{url_hash}{file_ext}"
        expected_path_str = str(expected_path)

        # Quick check: file already on disk
        if expected_path.exists():
            existing_size = expected_path.stat().st_size
            logger.info(
                f"[{camera_id}] Reusing cached video: {expected_path.name} "
                f"({existing_size / (1024*1024):.1f}MB)"
            )
            with self._download_lock:
                self.downloaded_files[url] = expected_path_str
                self._normalized_url_to_path[normalized_url] = expected_path_str
            return expected_path_str

        # Check memory cache
        with self._download_lock:
            if url in self.downloaded_files:
                local_path = self.downloaded_files[url]
                if os.path.exists(local_path):
                    logger.debug(f"[{camera_id}] Using cached path (exact URL match)")
                    return local_path

            if normalized_url in self._normalized_url_to_path:
                local_path = self._normalized_url_to_path[normalized_url]
                if os.path.exists(local_path):
                    logger.info(f"[{camera_id}] Reusing download (same base URL)")
                    self.downloaded_files[url] = local_path
                    return local_path

        # Need to download - acquire lock to prevent duplicate downloads
        with self._download_lock:
            # Double-check after acquiring lock
            if expected_path.exists():
                self.downloaded_files[url] = expected_path_str
                self._normalized_url_to_path[normalized_url] = expected_path_str
                return expected_path_str

            return self._do_download(url, expected_path, camera_id)

    def _do_download(self, url: str, dest_path: Path, camera_id: str) -> Optional[str]:
        """Perform the actual download. Must be called with _download_lock held."""
        content_length = 0
        file_size_mb = 0.0
        bytes_downloaded = 0
        timeout = self.DOWNLOAD_TIMEOUT

        try:
            # HEAD request to get file size
            try:
                head_response = requests.head(url, timeout=10, allow_redirects=True)
                content_length = int(head_response.headers.get('Content-Length', 0))
                file_size_mb = content_length / (1024 * 1024)
            except Exception as e:
                logger.debug(f"[{camera_id}] HEAD request failed: {e}")

            # Calculate dynamic timeout
            if content_length > 0:
                timeout = min(
                    self.DOWNLOAD_TIMEOUT + int(file_size_mb // 100) * self.DOWNLOAD_TIMEOUT_PER_100MB,
                    self.MAX_DOWNLOAD_TIMEOUT
                )
                logger.info(f"[{camera_id}] Downloading {file_size_mb:.1f}MB (timeout: {timeout}s)")
            else:
                logger.info(f"[{camera_id}] Downloading video (size unknown, timeout: {timeout}s)")

            # Download with progress tracking
            response = requests.get(url, stream=True, timeout=timeout)
            response.raise_for_status()

            if content_length == 0:
                content_length = int(response.headers.get('Content-Length', 0))
                file_size_mb = content_length / (1024 * 1024) if content_length > 0 else 0

            last_progress_log = 0

            with open(dest_path, 'wb') as f:
                for chunk in response.iter_content(chunk_size=self.DOWNLOAD_CHUNK_SIZE):
                    f.write(chunk)
                    bytes_downloaded += len(chunk)

                    # Log progress every 50MB for large files
                    if content_length > 50_000_000:
                        mb_downloaded = bytes_downloaded // (1024 * 1024)
                        if mb_downloaded - last_progress_log >= 50:
                            progress = (bytes_downloaded / content_length * 100) if content_length else 0
                            logger.info(
                                f"[{camera_id}] Download progress: "
                                f"{mb_downloaded}MB / {file_size_mb:.0f}MB ({progress:.1f}%)"
                            )
                            last_progress_log = mb_downloaded

            # Update caches
            normalized_url = self._normalize_url(url)
            dest_path_str = str(dest_path)
            self.downloaded_files[url] = dest_path_str
            self._normalized_url_to_path[normalized_url] = dest_path_str

            logger.info(
                f"[{camera_id}] Downloaded: {dest_path.name} "
                f"({bytes_downloaded / (1024*1024):.1f}MB)"
            )
            return dest_path_str

        except requests.Timeout:
            logger.error(
                f"[{camera_id}] Download timeout: {file_size_mb:.1f}MB, "
                f"got {bytes_downloaded/(1024*1024):.1f}MB in {timeout}s"
            )
        except requests.HTTPError as e:
            logger.error(f"[{camera_id}] HTTP error: {e.response.status_code} - {e.response.reason}")
        except IOError as e:
            logger.error(f"[{camera_id}] Disk I/O error: {e}")
        except Exception as e:
            logger.error(f"[{camera_id}] Download failed: {type(e).__name__}: {e}")

        # Cleanup partial download
        try:
            if dest_path.exists():
                dest_path.unlink()
        except Exception:
            pass

        return None

    def cleanup(self):
        """Clean up downloaded temporary files."""
        unique_files = set(self.downloaded_files.values())
        unique_files.update(self._normalized_url_to_path.values())

        for filepath in unique_files:
            try:
                if os.path.exists(filepath):
                    os.remove(filepath)
                    logger.debug(f"Removed temp file: {filepath}")
            except Exception as e:
                logger.warning(f"Failed to remove temp file {filepath}: {e}")

        self.downloaded_files.clear()
        self._normalized_url_to_path.clear()


# Global video downloader instance
_video_downloader: Optional[VideoDownloader] = None


def get_video_downloader() -> VideoDownloader:
    """Get or create the global VideoDownloader instance."""
    global _video_downloader
    if _video_downloader is None:
        _video_downloader = VideoDownloader()
    return _video_downloader


@dataclass
class StreamConfig:
    """Configuration for a single video stream."""
    camera_id: str
    video_path: str
    width: int = 640
    height: int = 640
    target_fps: int = 10
    gpu_id: int = 0
    stream_type: str = "file"  # "file" or "rtsp"
    demuxer_type: str = "nvc"  # "nvc" or "gstreamer"

    def __post_init__(self):
        """Auto-detect stream_type and demuxer_type from video_path."""
        if self.video_path.startswith("rtsp://") or self.video_path.startswith("rtsps://"):
            self.stream_type = "rtsp"
            # Auto-switch to GStreamer for RTSP if available (for ABSOLUTE RTP timestamps)
            if GSTREAMER_DEMUXER_AVAILABLE and self.demuxer_type == "nvc":
                self.demuxer_type = "gstreamer"
        elif self.video_path.startswith("http://") or self.video_path.startswith("https://"):
            self.stream_type = "file"  # Downloaded files
        else:
            self.stream_type = "file"


@dataclass
class GatewayConfig:
    """Configuration for the streaming gateway."""
    video_path: str
    num_streams: int = 100
    target_fps: int = 0  # 0 = unlimited, >0 = FPS limit per stream
    frame_width: int = 640
    frame_height: int = 640
    gpu_id: int = 0
    num_gpus: int = 1
    duration_sec: float = 30.0
    nvdec_pool_size: int = 8
    nvdec_burst_size: int = 4
    num_slots: int = 32
    demuxer_type: str = "nvc"  # "nvc" or "gstreamer"


@dataclass
class StreamState:
    """Track state for each logical stream in NVDEC pool."""
    stream_id: int
    camera_id: str
    video_path: str
    demuxer: Any  # NVC demuxer (None if using GStreamer)
    frames_decoded: int = 0
    width: int = 640
    height: int = 640
    empty_packets: int = 0
    decode_errors: int = 0  # Consecutive decode errors
    MAX_DECODE_ERRORS: int = 5  # Restart demuxer after this many consecutive errors
    stream_type: str = "file"  # "file" or "rtsp"
    source_fps: float = 30.0  # Source video FPS for timestamp calculation
    demuxer_type: str = "nvc"  # "nvc" or "gstreamer"
    # T0+PTS timestamp tracking for RTSP sync (Mode B)
    session_start_ns: int = 0  # Wall clock (ns) when RTSP connected (T0)
    session_id: str = ""  # Unique 8-char session ID (changes on reconnect)
    # NVDEC PTS tracking - use actual packet PTS instead of frame count calculation
    pts_timebase: int = 0  # Calculated from FPS * packet.duration (e.g., 30000 for 29.97fps)
    first_packet_pts: int = 0  # First PTS in stream (for relative time calculation)
    # GStreamer demuxer fields (for ABSOLUTE RTP timestamps)
    gst_demuxer: Any = None  # GstRTPDemuxer instance
    gst_demux_gen: Optional[Generator] = None  # demux() generator
    last_rtp_timestamp: Optional[int] = None  # Most recent 32-bit RTP timestamp
    first_rtp_timestamp: Optional[int] = None  # First RTP timestamp of session


# =============================================================================
# CUDA Kernel: NV12 Resize (no color conversion - 50% less bandwidth)
# =============================================================================

_nv12_resize_kernel = None


def _get_nv12_resize_kernel():
    """Get or compile the NV12 resize kernel.

    This kernel resizes NV12 directly (no color conversion).
    Output: concatenated Y (H*W) + UV ((H/2)*W) = H*W*1.5 bytes
    This is 50% smaller than RGB (H*W*3 bytes).

    Consumer will do: NV12→RGB→CHW→FP16 in one fused kernel.
    """
    global _nv12_resize_kernel
    if _nv12_resize_kernel is None and CUPY_AVAILABLE:
        _nv12_resize_kernel = cp.RawKernel(r'''
extern "C" __global__ void nv12_resize(
    const unsigned char* src_y,    
    const unsigned char* src_uv,   
    unsigned char* dst,            
    int src_h, int src_w,
    int dst_h, int dst_w,
    int y_stride, int uv_stride
) {
    int dst_x = blockIdx.x * blockDim.x + threadIdx.x;
    int dst_y = blockIdx.y * blockDim.y + threadIdx.y;

    // Total height in output: dst_h (Y) + dst_h/2 (UV) = dst_h * 1.5
    int total_h = dst_h + dst_h / 2;
    if (dst_x >= dst_w || dst_y >= total_h) return;

    float scale_x = (float)src_w / dst_w;
    float scale_y = (float)src_h / dst_h;

    if (dst_y < dst_h) {
        // Y plane region: resize Y
        int src_x = min((int)(dst_x * scale_x), src_w - 1);
        int src_y_coord = min((int)(dst_y * scale_y), src_h - 1);
        int src_idx = src_y_coord * y_stride + src_x;
        int dst_idx = dst_y * dst_w + dst_x;
        dst[dst_idx] = src_y[src_idx];
    } else {
        // UV plane region: resize UV (UV is at half vertical resolution)
        int uv_dst_y = dst_y - dst_h;  // 0 to dst_h/2-1
        int uv_src_y = min((int)(uv_dst_y * scale_y), src_h / 2 - 1);

        // UV is interleaved, so we copy pairs (U, V) together
        int src_uv_x = min((int)((dst_x / 2) * 2 * scale_x), src_w - 2);
        src_uv_x = (src_uv_x / 2) * 2;  // Ensure even

        int src_idx = uv_src_y * uv_stride + src_uv_x + (dst_x % 2);
        int dst_idx = dst_h * dst_w + uv_dst_y * dst_w + dst_x;
        dst[dst_idx] = src_uv[src_idx];
    }
}
''', 'nv12_resize')
    return _nv12_resize_kernel


def nv12_resize(y_plane: cp.ndarray, uv_plane: cp.ndarray,
                y_stride: int, uv_stride: int,
                src_h: int, src_w: int,
                dst_h: int = 640, dst_w: int = 640) -> cp.ndarray:
    """Resize NV12 without color conversion.

    Output: concatenated Y (H*W) + UV ((H/2)*W) as single buffer.
    Total size: H*W + (H/2)*W = H*W*1.5 bytes (50% of RGB).
    """
    kernel = _get_nv12_resize_kernel()
    if kernel is None:
        return None

    total_h = dst_h + dst_h // 2
    output = cp.empty((total_h, dst_w), dtype=cp.uint8)

    block = (16, 16)
    grid = ((dst_w + 15) // 16, (total_h + 15) // 16)

    kernel(grid, block, (
        y_plane, uv_plane, output,
        cp.int32(src_h), cp.int32(src_w),
        cp.int32(dst_h), cp.int32(dst_w),
        cp.int32(y_stride), cp.int32(uv_stride)
    ))

    return output


def surface_to_nv12(frame, target_h: int = 640, target_w: int = 640) -> Optional[cp.ndarray]:
    """Convert NVDEC surface to resized NV12 (50% smaller than RGB).

    Output: (H + H/2, W) uint8 - concatenated Y + UV planes.
    Total size: H*W*1.5 bytes (vs H*W*3 for RGB).
    """
    if not CUPY_AVAILABLE or frame is None:
        return None

    try:
        cuda_views = frame.cuda()
        if not cuda_views or len(cuda_views) < 2:
            return None

        # Extract Y plane
        y_view = cuda_views[0]
        y_cai = y_view.__cuda_array_interface__
        y_shape = tuple(y_cai['shape'])
        y_strides = tuple(y_cai['strides'])
        y_ptr = y_cai['data'][0]
        src_h, src_w = y_shape[:2]
        y_stride = y_strides[0]

        y_size = src_h * y_stride
        y_mem = cp.cuda.UnownedMemory(y_ptr, y_size, owner=frame)
        y_memptr = cp.cuda.MemoryPointer(y_mem, 0)
        y_plane = cp.ndarray((src_h, src_w), dtype=cp.uint8, memptr=y_memptr,
                            strides=(y_stride, 1))

        # Extract UV plane
        uv_view = cuda_views[1]
        uv_cai = uv_view.__cuda_array_interface__
        uv_shape = tuple(uv_cai['shape'])
        uv_strides = tuple(uv_cai['strides'])
        uv_ptr = uv_cai['data'][0]
        uv_stride = uv_strides[0]

        uv_h = uv_shape[0]
        uv_w = uv_shape[1] if len(uv_shape) > 1 else src_w
        uv_size = uv_h * uv_stride
        uv_mem = cp.cuda.UnownedMemory(uv_ptr, uv_size, owner=frame)
        uv_memptr = cp.cuda.MemoryPointer(uv_mem, 0)
        uv_plane = cp.ndarray((uv_h, uv_w), dtype=cp.uint8, memptr=uv_memptr,
                             strides=(uv_stride, 1))

        # NV12 resize (no color conversion - 50% smaller output!)
        nv12_frame = nv12_resize(y_plane, uv_plane, y_stride, uv_stride,
                                  src_h, src_w, target_h, target_w)
        # Synchronize to ensure resize kernel finishes reading from NVDEC surface
        # before next Decode() call can overwrite it (kernel runs on worker stream,
        # Decode uses stream 0 - they can overlap without this sync)
        # cp.cuda.get_current_stream().synchronize()
        # TODO: synchronize() is slower, benchmark both before enabling this
        # Add channel dimension for ring buffer compatibility: (H*1.5, W) -> (H*1.5, W, 1)
        return nv12_frame[:, :, cp.newaxis] if nv12_frame is not None else None

    except Exception as e:
        # Safely handle any characters in error message (CUDA errors may contain Unicode like ×)
        try:
            err_msg = str(e).encode('ascii', errors='replace').decode('ascii')
        except Exception:
            try:
                err_msg = repr(str(e))[:200]
            except Exception:
                err_msg = "failed to format error message"
        logger.warning(f"surface_to_nv12 failed: {err_msg}")
        return None


# =============================================================================
# NVDEC Decoder Pool
# =============================================================================

class NVDECDecoderPool:
    """Pool of NVDEC decoders that time-multiplex streams.

    Each decoder is exclusively owned by one worker thread.
    Outputs NV12: 1.5*H*W bytes (50% smaller than RGB).

    Supports two demuxer backends:
    - NVC (default): PyNvVideoCodec demuxer, fastest for local files
    - GStreamer: Provides ABSOLUTE RTP timestamps for RTSP streams
    """

    def __init__(self, pool_size: int, gpu_id: int = 0, demuxer_type: str = "nvc"):
        self.pool_size = pool_size
        self.gpu_id = gpu_id
        self.demuxer_type = DemuxerType(demuxer_type)
        self.decoders = []
        self.streams_per_decoder: List[List[StreamState]] = [[] for _ in range(pool_size)]

        if not PYNVCODEC_AVAILABLE:
            raise RuntimeError("PyNvVideoCodec not available")

        if CUPY_AVAILABLE:
            cp.cuda.Device(gpu_id).use()

        for i in range(pool_size):
            try:
                decoder = nvc.CreateDecoder(
                    gpuid=gpu_id,
                    codec=nvc.cudaVideoCodec.H264,
                    usedevicememory=True
                )
                self.decoders.append(decoder)
            except Exception as e:
                logger.warning(f"Failed to create decoder {i}: {e}")
                break

        self.actual_pool_size = len(self.decoders)
        logger.info(f"Created NVDEC pool: {self.actual_pool_size}/{pool_size} decoders on GPU {gpu_id}, demuxer={demuxer_type}")

    def assign_stream(self, stream_id: int, camera_id: str, video_path: str,
                      width: int = 640, height: int = 640,
                      stream_type: str = "file", demuxer_type: str = None) -> bool:
        """Assign a stream to a decoder (round-robin).

        Supports two demuxer backends:
        - NVC: PyNvVideoCodec demuxer (fastest for files)
        - GStreamer: Provides ABSOLUTE RTP timestamps for RTSP

        Args:
            stream_id: Stream identifier
            camera_id: Camera identifier
            video_path: Video file path or RTSP URL
            width: Target width
            height: Target height
            stream_type: Source type ("file" or "rtsp")
            demuxer_type: Override demuxer type ("nvc" or "gstreamer"), uses pool default if None
        """
        if self.actual_pool_size == 0:
            return False

        decoder_idx = stream_id % self.actual_pool_size

        # Use specified demuxer type or pool default
        use_demuxer_type = DemuxerType(demuxer_type) if demuxer_type else self.demuxer_type

        if use_demuxer_type == DemuxerType.GSTREAMER:
            # Use GStreamer demuxer for ABSOLUTE RTP timestamps
            return self._assign_stream_gstreamer(
                decoder_idx, stream_id, camera_id, video_path,
                width, height, stream_type
            )
        else:
            # Use NVC demuxer (default, fastest for files)
            return self._assign_stream_nvc(
                decoder_idx, stream_id, camera_id, video_path,
                width, height, stream_type
            )

    def _assign_stream_nvc(self, decoder_idx: int, stream_id: int, camera_id: str,
                           video_path: str, width: int, height: int,
                           stream_type: str) -> bool:
        """Assign stream using NVC (PyNvVideoCodec) demuxer."""
        # Download HTTPS URLs to local files (PyNvVideoCodec lacks HTTPS support)
        downloader = get_video_downloader()
        local_path = downloader.prepare_source(video_path, camera_id)

        try:
            demuxer = nvc.CreateDemuxer(local_path)
        except Exception as e:
            logger.error(f"Failed to create NVC demuxer for {camera_id}: {e}")
            return False

        # Extract source FPS from demuxer for video timestamp calculation
        source_fps = 30.0  # Default fallback
        try:
            fps_num = demuxer.FrameRate()[0]
            fps_den = demuxer.FrameRate()[1]
            if fps_den > 0:
                source_fps = fps_num / fps_den
                logger.debug(f"{camera_id}: Detected source FPS = {source_fps:.2f}")
        except Exception as e:
            logger.debug(f"{camera_id}: Could not get FPS from demuxer, using default: {e}")

        # T0+PTS sync: For RTSP streams, record wall clock time (T0) and generate session_id
        session_start_ns = 0
        session_id = ""
        if stream_type == "rtsp":
            session_start_ns = time.time_ns()
            session_id = str(uuid.uuid4())[:8]
            logger.info(f"{camera_id}: RTSP session started - session_id={session_id}, T0={session_start_ns}")

        stream_state = StreamState(
            stream_id=stream_id,
            camera_id=camera_id,
            video_path=local_path,
            demuxer=demuxer,
            width=width,
            height=height,
            stream_type=stream_type,
            source_fps=source_fps,
            demuxer_type="nvc",
            session_start_ns=session_start_ns,
            session_id=session_id,
        )
        self.streams_per_decoder[decoder_idx].append(stream_state)
        return True

    def _assign_stream_gstreamer(self, decoder_idx: int, stream_id: int, camera_id: str,
                                  video_path: str, width: int, height: int,
                                  stream_type: str) -> bool:
        """Assign stream using GStreamer demuxer (for ABSOLUTE RTP timestamps).

        Single attempt - returns False immediately if source not available.
        Caller handles background retry for failed cameras.
        """
        if not GSTREAMER_DEMUXER_AVAILABLE or GstRTPDemuxer is None:
            logger.error(f"GStreamer demuxer not available for {camera_id}")
            return False

        try:
            gst_demuxer = GstRTPDemuxer(video_path, use_tcp=True)
            gst_demuxer.open(quiet=True)
            source_fps = gst_demuxer.fps
            logger.warning(f"{camera_id}: GStreamer demuxer opened, {gst_demuxer.width}x{gst_demuxer.height}@{source_fps:.1f}fps")
        except Exception as e:
            logger.warning(f"{camera_id}: GStreamer demuxer open failed: {e}")
            return False

        # For GStreamer RTSP, session_id tracks reconnections
        session_start_ns = time.time_ns()
        session_id = gst_demuxer.session_id

        stream_state = StreamState(
            stream_id=stream_id,
            camera_id=camera_id,
            video_path=video_path,
            demuxer=None,  # Not used for GStreamer
            width=gst_demuxer.width or width,
            height=gst_demuxer.height or height,
            stream_type=stream_type,
            source_fps=source_fps,
            demuxer_type="gstreamer",
            session_start_ns=session_start_ns,
            session_id=session_id,
            gst_demuxer=gst_demuxer,
            gst_demux_gen=gst_demuxer.demux(),
            first_rtp_timestamp=gst_demuxer.first_rtp_timestamp,
        )
        self.streams_per_decoder[decoder_idx].append(stream_state)
        return True

    def decode_round(self, decoder_idx: int, frames_per_stream: int = 4,
                     target_h: int = 640, target_w: int = 640) -> Tuple[int, List[Tuple[str, cp.ndarray, int, str, str, int, Optional[int]]]]:
        """Decode frames and convert to NV12.

        Supports two demuxer backends:
        - NVC: PyNvVideoCodec demuxer (fastest for local files)
        - GStreamer: Provides ABSOLUTE RTP timestamps for RTSP streams

        Returns:
            (total_frames, [(camera_id, nv12_tensor, timestamp_ns, stream_type, session_id, session_start_ns, rtp_timestamp), ...])
            where:
            - timestamp_ns: For RTSP = T0 + PTS (absolute wall clock ns), for files = PTS (video-relative ns)
            - stream_type: "rtsp" or "file"
            - session_id: 8-char unique ID for RTSP (changes on reconnect), empty for files
            - session_start_ns: T0 wall clock when RTSP connected (0 for files)
            - rtp_timestamp: ABSOLUTE 32-bit RTP timestamp (GStreamer only, None for NVC)
        """
        if decoder_idx >= self.actual_pool_size:
            return 0, []

        decoder = self.decoders[decoder_idx]
        streams = self.streams_per_decoder[decoder_idx]
        total_frames = 0
        decoded_frames = []

        if not streams:
            return 0, []

        for stream in streams:
            frames_this_stream = 0

            # Route to appropriate demuxer based on type
            if stream.demuxer_type == "gstreamer" and stream.gst_demuxer is not None:
                # GStreamer demuxer path - ABSOLUTE RTP timestamps
                frames_this_stream, stream_decoded = self._decode_gstreamer_stream(
                    stream, decoder, frames_per_stream, target_h, target_w
                )
                decoded_frames.extend(stream_decoded)
                total_frames += frames_this_stream
            else:
                # NVC demuxer path (default)
                frames_this_stream, stream_decoded = self._decode_nvc_stream(
                    stream, decoder, frames_per_stream, target_h, target_w
                )
                decoded_frames.extend(stream_decoded)
                total_frames += frames_this_stream

        return total_frames, decoded_frames

    def _decode_gstreamer_stream(
        self, stream: StreamState, decoder: Any, frames_per_stream: int,
        target_h: int, target_w: int
    ) -> Tuple[int, List[Tuple[str, cp.ndarray, int, str, str, int, Optional[int]]]]:
        """Decode frames using GStreamer demuxer (for ABSOLUTE RTP timestamps).

        GStreamer extracts H264 NAL units with raw RTP timestamps, then feeds them
        to PyNvVideoCodec for GPU decode. This provides ABSOLUTE RTP timestamps
        that persist across reconnections.

        Returns:
            (frames_decoded, [(camera_id, tensor, timestamp_ns, stream_type, session_id, session_start_ns, rtp_timestamp), ...])
        """
        frames_this_stream = 0
        decoded_frames = []

        while frames_this_stream < frames_per_stream:
            try:
                # Get H264 NAL units with RTP timestamps from GStreamer
                h264_result = next(stream.gst_demux_gen)

                if h264_result is None:
                    # EOF or error - restart stream
                    self._restart_gstreamer_stream(stream)
                    break

                h264_bytes, rtp_ts, rtp_ts_ns, absolute_ns = h264_result

                if h264_bytes is None or len(h264_bytes) == 0:
                    stream.empty_packets += 1
                    if stream.empty_packets >= 10:
                        self._restart_gstreamer_stream(stream)
                    continue

                frames_before = frames_this_stream

                # Feed H264 bytes to PyNvVideoCodec decoder via PacketData
                # (Decode() expects PacketData, not raw bytes)
                try:
                    import numpy as _np
                    _h264_arr = _np.frombuffer(h264_bytes, dtype=_np.uint8)
                    _pkt = nvc.PacketData()
                    _pkt.bsl_data = _h264_arr.ctypes.data
                    _pkt.bsl = len(h264_bytes)
                    for surface in decoder.Decode(_pkt):
                        # Use ABSOLUTE RTP timestamp if available, otherwise rtp_ts_ns
                        if absolute_ns and absolute_ns > 0:
                            decode_timestamp_ns = absolute_ns
                        elif rtp_ts_ns and rtp_ts_ns > 0:
                            decode_timestamp_ns = rtp_ts_ns
                        else:
                            # Fallback to frame count calculation
                            decode_timestamp_ns = int(stream.frames_decoded * 1_000_000_000 / stream.source_fps)

                        tensor = surface_to_nv12(surface, target_h, target_w)

                        if tensor is not None:
                            # Include RTP timestamp in output tuple for GStreamer
                            decoded_frames.append((
                                stream.camera_id, tensor, decode_timestamp_ns,
                                stream.stream_type, stream.session_id,
                                stream.session_start_ns, rtp_ts
                            ))
                            frames_this_stream += 1
                            stream.frames_decoded += 1
                            stream.empty_packets = 0
                            stream.decode_errors = 0

                            # Track RTP timestamps
                            stream.last_rtp_timestamp = rtp_ts
                            if stream.first_rtp_timestamp is None:
                                stream.first_rtp_timestamp = rtp_ts

                        if frames_this_stream >= frames_per_stream:
                            break

                except Exception as decode_err:
                    stream.decode_errors += 1
                    if stream.decode_errors == 1:
                        logger.warning(f"{stream.camera_id}: GStreamer decode error: {decode_err}")

                    if stream.decode_errors >= stream.MAX_DECODE_ERRORS:
                        logger.warning(f"{stream.camera_id}: Too many decode errors, restarting GStreamer demuxer")
                        self._restart_gstreamer_stream(stream)
                        break

                if frames_this_stream == frames_before:
                    stream.empty_packets += 1
                    # Allow more empty packets when waiting for first IDR frame
                    # (NVDEC won't produce surfaces until SPS/PPS + IDR arrive)
                    empty_limit = 100 if stream.frames_decoded == 0 else 10
                    if stream.empty_packets >= empty_limit:
                        self._restart_gstreamer_stream(stream)

            except StopIteration:
                # EOF - restart stream
                logger.debug(f"{stream.camera_id}: GStreamer stream EOF, restarting")
                self._restart_gstreamer_stream(stream)
                break

            except Exception as demux_err:
                logger.warning(f"{stream.camera_id}: GStreamer demux error: {demux_err}")
                stream.decode_errors += 1
                if stream.decode_errors >= stream.MAX_DECODE_ERRORS:
                    self._restart_gstreamer_stream(stream)
                break

            if frames_this_stream >= frames_per_stream:
                break

        return frames_this_stream, decoded_frames

    def _restart_gstreamer_stream(self, stream: StreamState) -> None:
        """Restart GStreamer demuxer on EOF or error."""
        try:
            if stream.gst_demuxer:
                stream.gst_demuxer.close()
                stream.gst_demuxer.open(quiet=True)
                stream.gst_demux_gen = stream.gst_demuxer.demux()

                # Generate new session on reconnect
                stream.session_start_ns = time.time_ns()
                stream.session_id = stream.gst_demuxer.session_id
                stream.first_rtp_timestamp = stream.gst_demuxer.first_rtp_timestamp
                stream.last_rtp_timestamp = None
                stream.decode_errors = 0
                stream.empty_packets = 0
                stream.frames_decoded = 0

                logger.info(f"{stream.camera_id}: GStreamer demuxer restarted, new session_id={stream.session_id}")
        except Exception as e:
            logger.error(f"{stream.camera_id}: Failed to restart GStreamer demuxer: {e}")
            time.sleep(2)  # Backoff to avoid hammering RTSP source on persistent failures

    def _decode_nvc_stream(
        self, stream: StreamState, decoder: Any, frames_per_stream: int,
        target_h: int, target_w: int
    ) -> Tuple[int, List[Tuple[str, cp.ndarray, int, str, str, int, Optional[int]]]]:
        """Decode frames using NVC (PyNvVideoCodec) demuxer.

        Returns:
            (frames_decoded, [(camera_id, tensor, timestamp_ns, stream_type, session_id, session_start_ns, rtp_timestamp), ...])
            Note: rtp_timestamp is always None for NVC demuxer
        """
        frames_this_stream = 0
        decoded_frames = []

        while frames_this_stream < frames_per_stream:
            try:
                packet = stream.demuxer.Demux()
                if packet is None:
                    stream.demuxer = nvc.CreateDemuxer(stream.video_path)
                    stream.empty_packets = 0
                    # Reset frame counter and PTS tracking on loop for consistent timestamps
                    stream.frames_decoded = 0
                    stream.pts_timebase = 0  # Will be recalculated from first packet
                    stream.first_packet_pts = 0
                    # For RTSP: Generate new session on reconnect (T0+PTS sync)
                    if stream.stream_type == "rtsp":
                        stream.session_start_ns = time.time_ns()
                        stream.session_id = str(uuid.uuid4())[:8]
                        logger.info(f"{stream.camera_id}: RTSP reconnected - new session_id={stream.session_id}")
                    packet = stream.demuxer.Demux()
                    if packet is None:
                        break

                frames_before = frames_this_stream

                # Get packet PTS directly from NVDEC demuxer (more accurate than frame count)
                packet_pts = getattr(packet, 'pts', 0) or 0
                packet_duration = getattr(packet, 'duration', 0) or 0

                # Calculate timebase on first packet with valid duration
                if stream.pts_timebase == 0 and packet_duration > 0:
                    # timebase = fps * pts_per_frame (e.g., 29.97 * 1001 = 30000)
                    stream.pts_timebase = int(round(stream.source_fps * packet_duration))
                    stream.first_packet_pts = packet_pts
                    if stream.pts_timebase == 0:
                        # Fallback: 90000 for RTSP (RTP standard), 30000 for files
                        stream.pts_timebase = 90000 if stream.stream_type == "rtsp" else 30000

                # Wrap decode loop in try/except to catch decode errors
                try:
                    for surface in decoder.Decode(packet):
                        # Calculate timestamp using actual NVDEC packet PTS (T0+PTS approach):
                        # - RTSP: T0 (session_start_ns) + PTS = absolute wall clock time
                        # - File: PTS only = video-relative timestamp (for video timeline sync)
                        if stream.pts_timebase > 0:
                            # Use actual packet PTS from NVDEC
                            pts_ns = (packet_pts - stream.first_packet_pts) * 1_000_000_000 // stream.pts_timebase
                        else:
                            # Fallback to frame count calculation
                            pts_ns = int(stream.frames_decoded * 1_000_000_000 / stream.source_fps)

                        if stream.stream_type == "rtsp":
                            # T0 + PTS: Absolute wall clock time when frame was captured
                            decode_timestamp_ns = stream.session_start_ns + pts_ns
                        else:
                            # Video file: PTS only (video-relative timeline)
                            decode_timestamp_ns = pts_ns

                        tensor = surface_to_nv12(surface, target_h, target_w)

                        if tensor is not None:
                            # NVC demuxer: rtp_timestamp is None
                            decoded_frames.append((
                                stream.camera_id, tensor, decode_timestamp_ns,
                                stream.stream_type, stream.session_id,
                                stream.session_start_ns, None
                            ))
                            frames_this_stream += 1
                            stream.frames_decoded += 1
                            stream.empty_packets = 0
                            stream.decode_errors = 0  # Reset on success

                        if frames_this_stream >= frames_per_stream:
                            break

                except Exception as decode_err:
                    # Handle decode errors - these can happen with corrupted packets
                    stream.decode_errors += 1
                    if stream.decode_errors == 1:
                        # Only log first error per stream to avoid spam
                        logger.warning(f"{stream.camera_id}: Decode error: {decode_err}")

                    if stream.decode_errors >= stream.MAX_DECODE_ERRORS:
                        # Too many consecutive errors - restart demuxer
                        logger.warning(
                            f"{stream.camera_id}: {stream.decode_errors} consecutive decode errors, "
                            f"restarting demuxer"
                        )
                        try:
                            stream.demuxer = nvc.CreateDemuxer(stream.video_path)
                            stream.decode_errors = 0
                            stream.empty_packets = 0
                            # Reset PTS tracking on demuxer restart
                            stream.pts_timebase = 0
                            stream.first_packet_pts = 0
                            # For RTSP: Generate new session on error recovery (T0+PTS sync)
                            if stream.stream_type == "rtsp":
                                stream.session_start_ns = time.time_ns()
                                stream.session_id = str(uuid.uuid4())[:8]
                                logger.info(f"{stream.camera_id}: Demuxer restarted, new session_id={stream.session_id}")
                            else:
                                logger.info(f"{stream.camera_id}: Demuxer restarted successfully")
                        except Exception as restart_err:
                            logger.error(f"{stream.camera_id}: Failed to restart demuxer: {restart_err}")
                            break  # Exit this stream's loop

                if frames_this_stream == frames_before:
                    stream.empty_packets += 1
                    if stream.empty_packets >= 3:
                        stream.demuxer = nvc.CreateDemuxer(stream.video_path)
                        stream.empty_packets = 0
                        # Reset PTS tracking on demuxer restart
                        stream.pts_timebase = 0
                        stream.first_packet_pts = 0
                        # For RTSP: Generate new session on empty packet recovery
                        if stream.stream_type == "rtsp":
                            stream.session_start_ns = time.time_ns()
                            stream.session_id = str(uuid.uuid4())[:8]
                            logger.debug(f"{stream.camera_id}: RTSP empty packet recovery, new session_id={stream.session_id}")

            except Exception as demux_err:
                # Handle demux errors
                logger.warning(f"{stream.camera_id}: Demux error: {demux_err}")
                stream.decode_errors += 1
                if stream.decode_errors >= stream.MAX_DECODE_ERRORS:
                    try:
                        stream.demuxer = nvc.CreateDemuxer(stream.video_path)
                        stream.decode_errors = 0
                        # Reset PTS tracking on demuxer restart
                        stream.pts_timebase = 0
                        stream.first_packet_pts = 0
                        # For RTSP: Generate new session on demux error recovery
                        if stream.stream_type == "rtsp":
                            stream.session_start_ns = time.time_ns()
                            stream.session_id = str(uuid.uuid4())[:8]
                            logger.info(f"{stream.camera_id}: Demuxer restarted after demux errors, new session_id={stream.session_id}")
                        else:
                            logger.info(f"{stream.camera_id}: Demuxer restarted after demux errors")
                    except Exception:
                        pass
                break

            if frames_this_stream >= frames_per_stream:
                break

        return frames_this_stream, decoded_frames

    def get_camera_ids_for_decoder(self, decoder_idx: int) -> List[str]:
        """Get camera IDs for a decoder."""
        if decoder_idx >= self.actual_pool_size:
            return []
        return [s.camera_id for s in self.streams_per_decoder[decoder_idx]]

    def get_source_fps_for_decoder(self, decoder_idx: int) -> float:
        """Get average source FPS for streams assigned to a decoder.

        Used when target_fps=0 to cap output to source video rate.
        """
        if decoder_idx >= self.actual_pool_size:
            return 30.0
        streams = self.streams_per_decoder[decoder_idx]
        if not streams:
            return 30.0
        return sum(s.source_fps for s in streams) / len(streams)

    def close(self):
        """Close all decoders and demuxers."""
        self.decoders.clear()
        for streams in self.streams_per_decoder:
            for stream in streams:
                # Close GStreamer demuxers if present
                if stream.gst_demuxer is not None:
                    try:
                        stream.gst_demuxer.close()
                    except Exception as e:
                        logger.debug(f"Error closing GStreamer demuxer for {stream.camera_id}: {e}")
            streams.clear()


# =============================================================================
# Worker Thread
# =============================================================================

def nvdec_pool_worker(
    worker_id: int,
    decoder_idx: int,
    pool: NVDECDecoderPool,
    ring_buffers: Dict[str, CudaIpcRingBuffer],
    frame_counter: GlobalFrameCounter,
    duration_sec: float,
    result_queue: thread_queue.Queue,
    stop_event: threading.Event,
    burst_size: int = 4,
    target_h: int = 640,
    target_w: int = 640,
    target_fps: int = 0,
    shared_frame_count: Optional[mp.Value] = None,  # type: ignore[valid-type]
    gpu_frame_count: Optional[mp.Value] = None,  # type: ignore[valid-type]
):
    """NVDEC worker thread.

    Decodes frames and writes NV12 tensors to ring buffers.
    Uses dedicated CUDA stream per worker for kernel overlap.
    Supports FPS limiting when target_fps > 0.

    Args:
        shared_frame_count: Global counter (all GPUs)
        gpu_frame_count: Per-GPU counter (this GPU only)
    """
    if CUPY_AVAILABLE:
        cp.cuda.Device(pool.gpu_id).use()
        cuda_stream = cp.cuda.Stream(non_blocking=True)
    else:
        cuda_stream = None

    local_frames = 0
    local_errors = 0
    frames_since_counter_update = 0
    counter_batch_size = 100
    start_time = time.perf_counter()
    camera_ids = pool.get_camera_ids_for_decoder(decoder_idx)
    num_streams = len(camera_ids)

    # FPS limiting: calculate frames per second target for this worker
    # Each worker handles num_streams cameras at target_fps each
    # When target_fps <= 0, use source video FPS to cap output rate
    if target_fps <= 0:
        # Use source video FPS - get average for this decoder's streams
        source_fps = pool.get_source_fps_for_decoder(decoder_idx)
        effective_target_fps = source_fps
        fps_mode = f", FPS limit=source ({source_fps:.1f})/stream"
    else:
        effective_target_fps = float(target_fps)
        fps_mode = f", FPS limit={target_fps}/stream"

    fps_limit_enabled = num_streams > 0  # Always limit to source or target FPS
    if fps_limit_enabled:
        # Total target frames per second for all streams handled by this worker
        worker_target_fps = effective_target_fps * num_streams
        frame_interval = 1.0 / worker_target_fps
        next_frame_time = start_time
    else:
        frame_interval = 0.0
        next_frame_time = 0.0

    logger.debug(f"Worker {worker_id}: decoder={decoder_idx}, cams={num_streams}{fps_mode}")

    while not stop_event.is_set():
        if time.perf_counter() - start_time >= duration_sec:
            break

        # FPS limiting: wait until next scheduled frame time
        if fps_limit_enabled:
            current_time = time.perf_counter()
            if current_time < next_frame_time:
                sleep_time = next_frame_time - current_time
                if sleep_time > 0.0001:  # Only sleep if > 100us
                    time.sleep(sleep_time)

        try:
            with cuda_stream:
                # Always use fixed TARGET dimensions to ensure consistent NV12 output
                num_frames, decoded_frames = pool.decode_round(
                    decoder_idx,
                    frames_per_stream=burst_size,
                    target_h=TARGET_HEIGHT,  # Always 640
                    target_w=TARGET_WIDTH    # Always 640
                )

                for cam_id, tensor, decode_timestamp_ns, stream_type, session_id, session_start_ns, rtp_timestamp in decoded_frames:
                    if cam_id in ring_buffers:
                        try:
                            # Validate frame shape matches expected NV12 dimensions
                            expected_shape = (NV12_HEIGHT, TARGET_WIDTH, 1)  # (960, 640, 1)
                            if tensor.shape != expected_shape:
                                local_errors += 1
                                if local_errors <= 5:
                                    logger.error(
                                        f"Worker {worker_id} shape mismatch for {cam_id}: "
                                        f"got {tensor.shape}, expected {expected_shape}"
                                    )
                                continue  # Skip this frame

                            # Pass T0+PTS timestamp for frame-accurate sync (Mode B)
                            # For RTSP: T0 + PTS = absolute wall clock ns
                            # For files: PTS only = video-relative ns
                            # rtp_timestamp: ABSOLUTE 32-bit RTP timestamp (GStreamer only)
                            ring_buffers[cam_id].write_frame_fast(
                                tensor,
                                # sync=True, # TODO: sync=True is slower, sync=False is faster, benchmark both before enabling this
                                sync=False,
                                timestamp_ns=decode_timestamp_ns,
                                rtp_timestamp=rtp_timestamp if rtp_timestamp is not None else 0
                            )
                            # Store session info in ring buffer for Mode B sync
                            # Consumer can use this to correlate frames with detection timestamps
                            if session_id:  # Only set for RTSP streams
                                ring_buffers[cam_id].set_session_info(session_id, session_start_ns)
                            local_frames += 1
                            frames_since_counter_update += 1

                            # Update global counter (all GPUs)
                            if shared_frame_count is not None:
                                with shared_frame_count.get_lock():
                                    shared_frame_count.value += 1

                            # Update per-GPU counter (this GPU only)
                            if gpu_frame_count is not None:
                                with gpu_frame_count.get_lock():
                                    gpu_frame_count.value += 1

                            # Update next frame time for FPS limiting
                            if fps_limit_enabled:
                                next_frame_time += frame_interval

                        except Exception as e:
                            local_errors += 1
                            if local_errors <= 3:
                                logger.error(f"Worker {worker_id} write error: {e}")

                if decoded_frames:
                    # Sync all buffers that received frames in this round
                    # Critical for cross-container IPC - ensures GPU writes are visible
                    synced_cams = set()
                    for cam_id, _, _, _, _, _, _ in decoded_frames:
                        if cam_id in ring_buffers and cam_id not in synced_cams:
                            ring_buffers[cam_id].sync_writes()
                            synced_cams.add(cam_id)

            if num_frames == 0:
                time.sleep(0.0001)
                continue

            if frames_since_counter_update >= counter_batch_size:
                frame_counter.increment()
                frames_since_counter_update = 0

        except Exception as e:
            local_errors += 1
            if local_errors <= 3:
                logger.error(f"Worker {worker_id} error: {e}")

    if frames_since_counter_update > 0:
        frame_counter.increment()

    elapsed = time.perf_counter() - start_time
    result_queue.put({
        "worker_id": worker_id,
        "decoder_idx": decoder_idx,
        "elapsed_sec": elapsed,
        "total_frames": local_frames,
        "total_errors": local_errors,
        "num_streams": len(camera_ids),
        "fps": local_frames / elapsed if elapsed > 0 else 0,
    })


# =============================================================================
# GPU Process
# =============================================================================

def nvdec_pool_process(
    process_id: int,
    camera_configs: List[StreamConfig],
    pool_size: int,
    duration_sec: float,
    result_queue: mp.Queue,
    stop_event: mp.Event,  # type: ignore[valid-type]
    burst_size: int = 4,
    num_slots: int = 32,
    target_fps: int = 0,
    shared_frame_count: Optional[mp.Value] = None,  # type: ignore[valid-type]
    gpu_frame_counts: Optional[Dict[int, mp.Value]] = None,  # type: ignore[valid-type]
    total_num_streams: int = 0,
    total_num_gpus: int = 1,
    demuxer_type: str = "nvc",
):
    """NVDEC process for one GPU.

    Creates NV12 ring buffers: (H*1.5, W) = 0.6 MB/frame.

    Args:
        gpu_frame_counts: Dict mapping gpu_id -> per-GPU frame counter (for per-GPU stats)
        shared_frame_count: Global frame counter (for overall stats)
        total_num_streams: Total streams across ALL GPUs (for global per-stream calc)
        total_num_gpus: Total number of GPUs (for context in logging)
        demuxer_type: Demuxer backend ("nvc" or "gstreamer")
    """
    if not camera_configs:
        return

    gpu_id = camera_configs[0].gpu_id
    target_h = camera_configs[0].height
    target_w = camera_configs[0].width

    # Get per-GPU counter (or fall back to shared if not provided)
    gpu_frame_count: Any = gpu_frame_counts.get(gpu_id) if gpu_frame_counts else None

    if CUPY_AVAILABLE:
        cp.cuda.Device(gpu_id).use()

    # Connect to global frame counter (created by main process)
    frame_counter = GlobalFrameCounter(is_producer=True)
    max_retries = 50
    for retry in range(max_retries):
        try:
            if os.path.exists("/dev/shm/global_frame_counter"):
                frame_counter.connect()
                logger.info(f"Process {process_id}: Connected to GlobalFrameCounter")
                break
        except Exception:
            if retry == max_retries - 1:
                raise
        time.sleep(0.1)
    else:
        raise RuntimeError(f"Process {process_id}: GlobalFrameCounter not found")

    # Create decoder pool
    try:
        pool = NVDECDecoderPool(pool_size, gpu_id, demuxer_type)
    except Exception as e:
        logger.error(f"Process {process_id}: Failed to create decoder pool: {e}")
        result_queue.put({
            "process_id": process_id,
            "error": str(e),
            "total_frames": 0,
            "total_errors": 1,
        })
        return

    if pool.actual_pool_size == 0:
        result_queue.put({
            "process_id": process_id,
            "error": "No decoders created",
            "total_frames": 0,
            "total_errors": 1,
        })
        return

    # Create NV12 ring buffers with FIXED dimensions (640x640 -> 960x640 for NV12)
    # ALL cameras resize to TARGET_WIDTH x TARGET_HEIGHT regardless of source resolution
    ring_buffers: Dict[str, CudaIpcRingBuffer] = {}
    frame_size_mb = TARGET_WIDTH * NV12_HEIGHT * 1 / 1e6  # 640 * 960 * 1 channel

    try:
        # Connect to GPU camera map (read-only from workers).
        # The main process (NVDECWorkerManager.start()) already initialized the map
        # and wrote ALL camera-GPU mappings. Worker processes must NOT re-initialize
        # or write to the map — doing so causes a race condition where each worker
        # overwrites the others' entries, leaving a partial/stale map.
        # See: docs/deployment-runbook.md "GPU Mismatch / Stale GpuCameraMap"
        gpu_map = GpuCameraMap(is_producer=True)
        for retry in range(50):
            if gpu_map.connect():
                logger.info(f"Process {process_id}: Connected to GpuCameraMap")
                break
            time.sleep(0.1)
        else:
            raise RuntimeError(f"Process {process_id}: Failed to connect to GpuCameraMap")

        # Collect GPU mappings for bulk write
        gpu_mappings = {}
        failed_cameras = []  # (stream_id, config) for background retry

        for i, config in enumerate(camera_configs):
            # Use fixed dimensions - ALL cameras output 640x640 (960x640 for NV12)
            rb = CudaIpcRingBuffer.create_producer(
                config.camera_id,
                gpu_id=config.gpu_id,
                num_slots=num_slots,
                width=TARGET_WIDTH,      # Always 640
                height=NV12_HEIGHT,      # Always 960 (640 * 1.5 for NV12)
                channels=1,
            )
            ring_buffers[config.camera_id] = rb

            # Track GPU assignment for this camera
            gpu_mappings[config.camera_id] = config.gpu_id

            success = pool.assign_stream(
                stream_id=i,
                camera_id=config.camera_id,
                video_path=config.video_path,
                width=TARGET_WIDTH,   # Use fixed width
                height=TARGET_HEIGHT,  # Use fixed height
                stream_type=config.stream_type,  # Pass stream type for timestamp mode
            )
            if not success:
                failed_cameras.append((i, config))

        # GPU mappings already written by main process (NVDECWorkerManager.start()).
        # Workers only log their local assignment for debugging.
        if gpu_mappings:
            logger.warning(f"Process {process_id}: {len(gpu_mappings)} cameras on GPU {process_id}")

        if failed_cameras:
            logger.warning(f"Process {process_id}: {len(failed_cameras)} cameras failed initial connect, retrying in background")

        logger.warning(f"Process {process_id}: {pool.actual_pool_size} decoders, "
                   f"{len(camera_configs)} streams, NV12 ({frame_size_mb:.1f} MB/frame)")

        thread_stop_event = threading.Event()
        thread_result_queue = thread_queue.Queue()  # type: ignore[var-annotated]

        # Background retry for cameras that failed initial connect
        def _retry_failed_cameras(failed_list, pool, stop_evt):
            """Retry failed cameras with backoff in background. Non-blocking to other cameras."""
            backoff = 5.0
            remaining = list(failed_list)
            while remaining and not stop_evt.is_set():
                stop_evt.wait(backoff)
                if stop_evt.is_set():
                    break
                still_failed = []
                for stream_id, cfg in remaining:
                    if stop_evt.is_set():
                        break
                    ok = pool.assign_stream(
                        stream_id=stream_id,
                        camera_id=cfg.camera_id,
                        video_path=cfg.video_path,
                        width=TARGET_WIDTH,
                        height=TARGET_HEIGHT,
                        stream_type=cfg.stream_type,
                    )
                    if ok:
                        logger.info(f"Background retry: {cfg.camera_id} connected successfully")
                    else:
                        still_failed.append((stream_id, cfg))
                remaining = still_failed
                backoff = min(backoff * 1.5, 30.0)
            if not remaining:
                logger.info(f"Process {process_id}: All failed cameras connected via background retry")

        if failed_cameras:
            retry_thread = threading.Thread(
                target=_retry_failed_cameras,
                args=(failed_cameras, pool, thread_stop_event),
                daemon=True,
            )
            retry_thread.start()

        threads = []
        for decoder_idx in range(pool.actual_pool_size):
            t = threading.Thread(
                target=nvdec_pool_worker,
                args=(
                    process_id * 100 + decoder_idx,
                    decoder_idx,
                    pool,
                    ring_buffers,
                    frame_counter,
                    duration_sec,
                    thread_result_queue,
                    thread_stop_event,
                    burst_size,
                    target_h,
                    target_w,
                    target_fps,
                    shared_frame_count,
                    gpu_frame_count,  # Per-GPU counter
                )
            )
            t.start()
            threads.append(t)

        # Progress monitoring loop with current/avg FPS tracking
        start_time = time.perf_counter()
        last_report_time = start_time
        last_gpu_frame_count = 0
        last_global_frame_count = 0
        report_interval = 5.0
        processing_start_time: Optional[float] = None
        gpu_frames_at_start = 0
        global_frames_at_start = 0
        num_gpu_streams = len(camera_configs)

        while not stop_event.is_set():  # type: ignore[attr-defined]
            current_time = time.perf_counter()
            if current_time - start_time >= duration_sec:
                break

            # Periodic progress report with current and average FPS
            if current_time - last_report_time >= report_interval:
                elapsed = current_time - start_time
                remaining = max(0, duration_sec - elapsed)

                # Get per-GPU frame count (this GPU only)
                gpu_frames = gpu_frame_count.value if gpu_frame_count else 0
                gpu_interval_frames = gpu_frames - last_gpu_frame_count
                gpu_interval_fps = gpu_interval_frames / report_interval
                gpu_per_stream_fps = gpu_interval_fps / num_gpu_streams if num_gpu_streams > 0 else 0

                # Get global frame count (all GPUs)
                global_frames = shared_frame_count.value if shared_frame_count else 0  # type: ignore[union-attr,attr-defined]
                global_interval_frames = global_frames - last_global_frame_count
                global_interval_fps = global_interval_frames / report_interval
                global_per_stream_fps = global_interval_fps / total_num_streams if total_num_streams > 0 else 0

                # Track when processing actually starts (exclude warmup)
                if processing_start_time is None and gpu_frames > 0:
                    processing_start_time = last_report_time
                    gpu_frames_at_start = last_gpu_frame_count
                    global_frames_at_start = last_global_frame_count

                # Calculate average FPS excluding warmup
                if processing_start_time is not None:
                    processing_elapsed = current_time - processing_start_time

                    # Per-GPU averages
                    gpu_processing_frames = gpu_frames - gpu_frames_at_start
                    gpu_avg_fps = gpu_processing_frames / processing_elapsed if processing_elapsed > 0 else 0
                    gpu_avg_per_stream = gpu_avg_fps / num_gpu_streams if num_gpu_streams > 0 else 0

                    # Global averages
                    global_processing_frames = global_frames - global_frames_at_start
                    global_avg_fps = global_processing_frames / processing_elapsed if processing_elapsed > 0 else 0
                    global_avg_per_stream = global_avg_fps / total_num_streams if total_num_streams > 0 else 0

                    # Log per-GPU stats
                    logger.info(
                        f"GPU{gpu_id} [{elapsed:5.1f}s] {gpu_frames:,} frames ({num_gpu_streams} cams) | "
                        f"cur: {gpu_interval_fps:,.0f} FPS ({gpu_per_stream_fps:.1f}/cam) | "
                        f"avg: {gpu_avg_fps:,.0f} FPS ({gpu_avg_per_stream:.1f}/cam)"
                    )

                    # Log global stats (only from GPU0 to avoid spam)
                    if gpu_id == 0:
                        logger.info(
                            f"GLOBAL [{elapsed:5.1f}s] {global_frames:,} frames ({total_num_streams} cams, {total_num_gpus} GPUs) | "
                            f"cur: {global_interval_fps:,.0f} FPS ({global_per_stream_fps:.1f}/cam) | "
                            f"avg: {global_avg_fps:,.0f} FPS ({global_avg_per_stream:.1f}/cam) | "
                            f"{remaining:.0f}s left"
                        )

                last_gpu_frame_count = gpu_frames
                last_global_frame_count = global_frames
                last_report_time = current_time

            time.sleep(0.1)

        thread_stop_event.set()

        for t in threads:
            t.join(timeout=30.0)

        total_frames = 0
        total_errors = 0
        elapsed = time.perf_counter() - start_time

        while not thread_result_queue.empty():
            try:
                r = thread_result_queue.get_nowait()
                total_frames += r.get("total_frames", 0)
                total_errors += r.get("total_errors", 0)
            except:
                break

        pool.close()
        for rb in ring_buffers.values():
            rb.close()

        result_queue.put({
            "process_id": process_id,
            "elapsed_sec": elapsed,
            "total_frames": total_frames,
            "total_errors": total_errors,
            "num_streams": len(camera_configs),
            "pool_size": pool.actual_pool_size,
            "fps": total_frames / elapsed if elapsed > 0 else 0,
            "per_stream_fps": total_frames / elapsed / len(camera_configs) if elapsed > 0 and camera_configs else 0,
        })

    except Exception as e:
        logger.error(f"Process {process_id} error: {e}")
        import traceback
        traceback.print_exc()

        pool.close()
        for rb in ring_buffers.values():
            rb.close()

        result_queue.put({
            "process_id": process_id,
            "error": str(e),
            "total_frames": 0,
            "total_errors": 1,
        })


# =============================================================================
# Streaming Gateway
# =============================================================================

class StreamingGateway:
    """Multi-stream video producer outputting NV12 tensors (minimal IPC payload)."""

    def __init__(self, config: GatewayConfig):
        self.config = config
        self._workers: List[mp.Process] = []
        self._stop_event = mp.Event()
        self._result_queue = mp.Queue()  # type: ignore[var-annotated]

    def start(self) -> Dict:
        """Start the gateway."""
        if not CUPY_AVAILABLE:
            raise RuntimeError("CuPy is required")
        if not RING_BUFFER_AVAILABLE:
            raise RuntimeError("CUDA IPC ring buffer not available")
        if not PYNVCODEC_AVAILABLE:
            raise RuntimeError("PyNvVideoCodec required")
        return self._start_nvdec_pool()

    def _start_nvdec_pool(self) -> Dict:
        """Start NVDEC pool across GPUs."""
        num_gpus = min(self.config.num_gpus, 8)
        streams_per_gpu = self.config.num_streams // num_gpus
        extra_streams = self.config.num_streams % num_gpus

        logger.info(f"Starting NVDEC on {num_gpus} GPU(s): {self.config.num_streams} streams, "
                   f"pool_size={self.config.nvdec_pool_size}/GPU, output=NV12 (0.6 MB)")

        ctx = mp.get_context("spawn")
        self._stop_event = ctx.Event()
        self._result_queue = ctx.Queue()

        # Shared counter for real-time FPS tracking (use 'L' for large counts)
        shared_frame_count = ctx.Value('L', 0)

        stream_idx = 0
        for gpu_id in range(num_gpus):
            n_streams = streams_per_gpu + (1 if gpu_id < extra_streams else 0)

            gpu_configs = []
            for i in range(n_streams):
                config = StreamConfig(
                    camera_id=f"cam_{stream_idx:04d}",
                    video_path=self.config.video_path,
                    width=self.config.frame_width,
                    height=self.config.frame_height,
                    target_fps=self.config.target_fps,
                    gpu_id=gpu_id,
                )
                gpu_configs.append(config)
                stream_idx += 1

            p = ctx.Process(
                target=nvdec_pool_process,
                args=(gpu_id, gpu_configs, self.config.nvdec_pool_size,
                      self.config.duration_sec, self._result_queue, self._stop_event,
                      self.config.nvdec_burst_size, self.config.num_slots,
                      self.config.target_fps, shared_frame_count, None,
                      self.config.num_streams, num_gpus, self.config.demuxer_type)
            )
            p.start()
            self._workers.append(p)  # type: ignore[arg-type]
            logger.info(f"GPU {gpu_id}: {n_streams} streams")
            time.sleep(0.1)

        # Progress monitoring loop - print progress every 5 seconds
        start_time = time.perf_counter()
        last_report_time = start_time
        last_frame_count = 0
        report_interval = 5.0  # seconds
        processing_start_time: Optional[float] = None  # Track when actual processing starts
        frames_at_processing_start = 0

        print(f"  [  0.0s] Started {num_gpus} GPU workers...")

        while any(p.is_alive() for p in self._workers):
            time.sleep(0.5)
            current_time = time.perf_counter()

            # Periodic progress report with real-time FPS
            if current_time - last_report_time >= report_interval:
                elapsed = current_time - start_time
                remaining = max(0, self.config.duration_sec - elapsed)

                # Read current frame count
                current_frames = shared_frame_count.value
                interval_frames = current_frames - last_frame_count
                interval_fps = interval_frames / report_interval  # Current throughput
                per_stream_fps = interval_fps / self.config.num_streams if self.config.num_streams > 0 else 0

                # Track when processing actually starts (exclude warmup from avg)
                if processing_start_time is None and current_frames > 0:
                    processing_start_time = last_report_time  # Use previous report time
                    frames_at_processing_start = last_frame_count

                # Calculate average FPS excluding warmup time
                if processing_start_time is not None:
                    processing_elapsed = current_time - processing_start_time
                    processing_frames = current_frames - frames_at_processing_start
                    avg_fps = processing_frames / processing_elapsed if processing_elapsed > 0 else 0
                    print(f"  [{elapsed:5.1f}s] {current_frames:,} frames | cur: {interval_fps:,.0f} FPS ({per_stream_fps:.1f}/stream) | avg: {avg_fps:,.0f} FPS | {remaining:.0f}s left")
                else:
                    print(f"  [{elapsed:5.1f}s] Warming up... | {remaining:.0f}s left")

                last_report_time = current_time
                last_frame_count = current_frames

        # Wait for all workers to fully complete
        for p in self._workers:  # type: ignore[assignment]
            p.join(timeout=5)

        results = []
        while not self._result_queue.empty():
            results.append(self._result_queue.get())

        for r in results:
            if "error" in r:
                logger.error(f"NVDEC error: {r['error']}")

        total_frames = sum(r.get("total_frames", 0) for r in results)
        total_errors = sum(r.get("total_errors", 0) for r in results)
        total_elapsed = max((r.get("elapsed_sec", 0) for r in results), default=0)

        aggregate_fps = total_frames / total_elapsed if total_elapsed > 0 else 0
        per_stream_fps = aggregate_fps / self.config.num_streams if self.config.num_streams > 0 else 0

        return {
            "num_streams": self.config.num_streams,
            "num_gpus": num_gpus,
            "pool_size": self.config.nvdec_pool_size,
            "duration_sec": total_elapsed,
            "total_frames": total_frames,
            "total_errors": total_errors,
            "aggregate_fps": aggregate_fps,
            "per_stream_fps": per_stream_fps,
            "gpu_results": results,
        }

    def stop(self):
        """Stop all workers."""
        self._stop_event.set()
        for p in self._workers:
            p.join(timeout=5)
            if p.is_alive():
                p.terminate()


# =============================================================================
# CLI
# =============================================================================

def main():
    parser = argparse.ArgumentParser(description="Streaming Gateway - CUDA IPC Producer (NV12)")
    parser.add_argument("--video", "-v", required=True, help="Video file path")
    parser.add_argument("--num-streams", "-n", type=int, default=100, help="Number of streams")
    parser.add_argument("--fps", type=int, default=0, help="Target FPS limit per stream (0=source video FPS)")
    parser.add_argument("--width", type=int, default=640, help="Frame width")
    parser.add_argument("--height", type=int, default=640, help="Frame height")
    parser.add_argument("--duration", "-d", type=float, default=30.0, help="Duration in seconds")
    parser.add_argument("--gpu", type=int, default=0, help="Primary GPU ID")
    parser.add_argument("--num-gpus", "-g", type=int, default=1, help="Number of GPUs (1-8)")
    parser.add_argument("--pool-size", type=int, default=8, help="NVDEC pool size per GPU")
    parser.add_argument("--burst-size", type=int, default=4, help="Frames per stream before rotating")
    parser.add_argument("--slots", type=int, default=32, help="Ring buffer slots per camera")
    parser.add_argument("--demuxer", type=str, default="nvc", choices=["nvc", "gstreamer"],
                        help="Demuxer backend: 'nvc' (PyNvVideoCodec, default) or 'gstreamer' (RTP timestamps for RTSP)")
    parser.add_argument("--quiet", "-q", action="store_true", help="Quiet mode - only show final results")
    args = parser.parse_args()

    # Validate GStreamer availability if requested
    if args.demuxer == "gstreamer" and not GSTREAMER_DEMUXER_AVAILABLE:
        logger.warning("GStreamer demuxer requested but not available, falling back to NVC")
        args.demuxer = "nvc"

    # Setup logging based on quiet mode
    setup_logging(quiet=args.quiet)

    config = GatewayConfig(
        video_path=args.video,
        num_streams=args.num_streams,
        target_fps=args.fps,
        frame_width=args.width,
        frame_height=args.height,
        gpu_id=args.gpu,
        num_gpus=args.num_gpus,
        duration_sec=args.duration,
        nvdec_pool_size=args.pool_size,
        nvdec_burst_size=args.burst_size,
        num_slots=args.slots,
        demuxer_type=args.demuxer,
    )

    frame_size = args.width * args.height * 1.5
    demuxer_info = "GStreamer (RTP timestamps)" if args.demuxer == "gstreamer" else "NVC (PyNvVideoCodec)"
    output_fmt = f"NV12 ({args.width}x{args.height}x1.5 = {frame_size/1e6:.1f} MB/frame)"
    fps_limit_str = f"{args.fps} FPS/stream" if args.fps > 0 else "source FPS"

    if not args.quiet:
        print("\n" + "=" * 60)
        print("      STREAMING GATEWAY - CUDA IPC Producer (NV12)")
        print("=" * 60)
        print(f"  Video:      {args.video}")
        print(f"  Streams:    {args.num_streams}")
        print(f"  GPUs:       {args.num_gpus}")
        print(f"  Pool size:  {args.pool_size} NVDEC decoders/GPU")
        print(f"  Demuxer:    {demuxer_info}")
        print(f"  FPS limit:  {fps_limit_str}")
        print(f"  Output:     {output_fmt}")
        print(f"  Duration:   {args.duration}s")
        print("=" * 60)

    gateway = StreamingGateway(config)

    try:
        results = gateway.start()
        # Clean summary output
        print("\n")
        print("=" * 60)
        print("         STREAMING GATEWAY BENCHMARK RESULTS")
        print("=" * 60)
        print(f"  Video:        {args.video}")
        print(f"  Streams:      {args.num_streams}")
        print(f"  GPUs:         {args.num_gpus}")
        print(f"  FPS limit:    {fps_limit_str}")
        print(f"  Duration:     {args.duration}s")
        print("-" * 60)
        print(f"  Total Frames: {results['total_frames']:,}")
        print("-" * 60)
        print(f"  >>> AGGREGATE FPS: {results['aggregate_fps']:,.0f} <<<")
        print(f"  >>> PER-STREAM FPS: {results['per_stream_fps']:.1f} <<<")
        print("=" * 60)
        print()
    except KeyboardInterrupt:
        gateway.stop()
        print("\nStopped")


if __name__ == "__main__":
    main()
