"""Camera publisher module for streaming video frames via ZMQ.

This module provides functionality to capture video from a webcam using OpenCV
and publish raw YUYV frames via ZeroMQ PUB socket for subscribers to receive.
"""

import hashlib
import json
import select
import socket
import sys
import termios
import threading
import time
import tty
from datetime import datetime
from typing import Optional

import cv2
import numpy as np
import zmq
import zstandard

from pogucam.image_processing_texts import add_publisher_message, PUBLISHER_MESSAGE


# Get hostname once at module load
HOSTNAME = socket.gethostname()


class TerminalInputHandler:
    """Handles non-blocking keyboard input from terminal.

    This class manages terminal raw mode for single-character input
    and provides a thread-safe way to check for keypresses.
    """

    def __init__(self) -> None:
        """Initialize the terminal input handler."""
        self._old_settings: Optional[termios.struct_termios] = None
        self._running = False
        self._input_thread: Optional[threading.Thread] = None
        self._input_queue: list[str] = []
        self._lock = threading.Lock()

    def start(self) -> None:
        """Start the terminal input handler in raw mode."""
        # Save current terminal settings
        self._old_settings = termios.tcgetattr(sys.stdin)

        # Set terminal to raw mode for single character input
        tty.setcbreak(sys.stdin.fileno())

        self._running = True
        self._input_thread = threading.Thread(target=self._read_input, daemon=True)
        self._input_thread.start()

    def _read_input(self) -> None:
        """Read input characters in a separate thread."""
        while self._running:
            try:
                # Use select to check if input is available (with timeout)
                if select.select([sys.stdin], [], [], 0.1)[0]:
                    char = sys.stdin.read(1)
                    if char:
                        with self._lock:
                            self._input_queue.append(char)
            except (OSError, IOError):
                # Terminal might be closed
                break

    def get_key(self) -> Optional[str]:
        """Get a single keypress from the input queue.

        Returns:
            The key character if available, None otherwise.
        """
        with self._lock:
            if self._input_queue:
                return self._input_queue.pop(0)
        return None

    def stop(self) -> None:
        """Stop the input handler and restore terminal settings."""
        self._running = False

        if self._input_thread is not None:
            self._input_thread.join(timeout=0.5)

        # Restore terminal settings
        if self._old_settings is not None:
            termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old_settings)
            self._old_settings = None


class ImageBuffer:
    """Buffer for image averaging and accumulation.

    This class maintains a circular buffer of up to 9 images and supports
    both averaging and accumulation modes for pixel processing.

    For N > 1, the buffer only returns a processed frame when all N frames
    are new (fresh). After returning a frame, the next N-1 frames are
    skipped (not published) until all N frames are new again.

    Attributes:
        max_size: Maximum buffer size (1-9).
        mode: Processing mode - 'average' or 'accumulate'.
    """

    def __init__(self, max_size: int = 1) -> None:
        """Initialize the image buffer.

        Args:
            max_size: Initial buffer size (1-9, default: 1).
        """
        self._buffer: list[np.ndarray] = []
        self._timestamps: list[tuple[float, float]] = []  # (ts_before, ts_after) for each frame
        self._max_size = max(1, min(9, max_size))
        self._mode: str = "average"  # 'average' or 'accumulate'
        self._frames_added_since_publish = 0  # Track fresh frames

    @property
    def max_size(self) -> int:
        """Get the current maximum buffer size."""
        return self._max_size

    @max_size.setter
    def max_size(self, size: int) -> None:
        """Set the maximum buffer size (1-9).

        Args:
            size: New buffer size.
        """
        self._max_size = max(1, min(9, size))
        # Trim buffer if needed
        while len(self._buffer) > self._max_size:
            self._buffer.pop(0)
            self._timestamps.pop(0)
        # Reset fresh frame counter
        self._frames_added_since_publish = 0

    @property
    def mode(self) -> str:
        """Get the current processing mode."""
        return self._mode

    def toggle_mode(self) -> str:
        """Toggle between averaging and accumulation modes.

        Returns:
            The new mode after toggling.
        """
        self._mode = "accumulate" if self._mode == "average" else "average"
        # Clear buffer when switching modes
        self._buffer.clear()
        self._timestamps.clear()
        self._frames_added_since_publish = 0
        return self._mode

    def set_mode(self, mode: str) -> None:
        """Set the processing mode explicitly.

        Args:
            mode: 'average' or 'accumulate'.
        """
        if mode not in ("average", "accumulate"):
            raise ValueError("Mode must be 'average' or 'accumulate'")
        if self._mode != mode:
            self._mode = mode
            self._buffer.clear()
            self._timestamps.clear()
            self._frames_added_since_publish = 0

    def add_frame(self, frame: np.ndarray, ts_before: float = 0.0, ts_after: float = 0.0) -> None:
        """Add a frame to the buffer with timestamps.

        Args:
            frame: The image frame to add.
            ts_before: Timestamp before frame capture (default: 0.0).
            ts_after: Timestamp after frame capture (default: 0.0).
        """
        # Store a copy to avoid reference issues
        self._buffer.append(frame.copy())
        self._timestamps.append((ts_before, ts_after))
        self._frames_added_since_publish += 1
        # Maintain buffer size
        while len(self._buffer) > self._max_size:
            self._buffer.pop(0)
            self._timestamps.pop(0)

    def get_timestamps(self) -> tuple[float, float]:
        """Get the timestamps for the accumulated frame.

        Returns:
            Tuple of (ts_before_first_frame, ts_after_last_frame).
        """
        if not self._timestamps:
            return (0.0, 0.0)
        ts_before = self._timestamps[0][0]  # First frame's ts_before
        ts_after = self._timestamps[-1][1]  # Last frame's ts_after
        return (ts_before, ts_after)

    def should_publish(self) -> bool:
        """Check if we should publish a frame.

        Returns True when all N frames in the buffer are new (fresh).
        For N=1, always returns True.

        Returns:
            True if buffer is ready and all frames are fresh.
        """
        if len(self._buffer) < self._max_size:
            return False
        # For N=1, always publish
        if self._max_size == 1:
            return True
        # For N>1, only publish when all N frames are fresh
        return self._frames_added_since_publish >= self._max_size

    def mark_published(self) -> None:
        """Mark that a frame was published.

        This resets the fresh frame counter and clears the buffer
        to start collecting new frames.
        """
        self._frames_added_since_publish = 0
        self._buffer.clear()
        self._timestamps.clear()

    def get_processed_frame(self) -> Optional[np.ndarray]:
        """Get the processed frame based on current mode.

        Returns:
            Processed frame (averaged or accumulated), or None if buffer is empty.
        """
        if not self._buffer:
            return None

        if len(self._buffer) == 1:
            return self._buffer[0].copy()

        if self._mode == "average":
            return self._average_frames()
        else:  # accumulate
            return self._accumulate_frames()

    def _average_frames(self) -> np.ndarray:
        """Average all frames in the buffer.

        Returns:
            Averaged frame.
        """
        # Stack frames and compute mean along the first axis
        # Convert to float32 for accurate averaging, then convert back
        stacked = np.stack(self._buffer, axis=0).astype(np.float32)
        averaged = np.mean(stacked, axis=0).astype(self._buffer[0].dtype)
        return averaged

    def _accumulate_frames(self) -> np.ndarray:
        """Accumulate (sum) all frames in the buffer.

        For YUYV format with shape (H, W, 2), channel 0 is Y (unsigned)
        and channel 1 contains alternating U and V (signed, offset by 128).

        Returns:
            Accumulated frame (clamped to valid range).
        """
        if len(self._buffer) == 0:
            return None

        # Get frame shape
        first_frame = self._buffer[0]
        num_frames = len(self._buffer)

        # Stack frames for processing
        stacked = np.stack(self._buffer, axis=0).astype(np.int16)

        # Check if this is YUYV format (3D array with 2 channels)
        if len(first_frame.shape) == 3 and first_frame.shape[2] == 2:
            # YUYV format: shape (H, W, 2)
            # Channel 0: Y values (unsigned)
            # Channel 1: Alternating U and V values (signed, offset 128)

            # Y is unsigned - accumulate directly
            y_accumulated = np.sum(stacked[:, :, :, 0], axis=0)

            # U/V are signed (offset 128) - convert to signed, accumulate, convert back
            uv_stacked = stacked[:, :, :, 1].copy()
            uv_stacked -= 128  # Convert to signed
            uv_accumulated = np.sum(uv_stacked, axis=0)
            # Scale down by number of frames to prevent saturation
            uv_accumulated = uv_accumulated // num_frames
            uv_accumulated += 128  # Convert back to unsigned

            # Clamp both channels
            y_accumulated = np.clip(y_accumulated, 0, 255)
            uv_accumulated = np.clip(uv_accumulated, 0, 255)

            # Reconstruct frame
            result = np.stack([y_accumulated, uv_accumulated], axis=2).astype(np.uint8)

        else:
            # RGB/BGR format: 3D array with 3 channels - simple accumulation with clamping
            accumulated = np.sum(stacked, axis=0)
            result = np.clip(accumulated, 0, 255).astype(np.uint8)

        return result

    def clear(self) -> None:
        """Clear the buffer."""
        self._buffer.clear()

    def is_ready(self) -> bool:
        """Check if buffer has enough frames for processing.

        Returns:
            True if buffer has frames, False otherwise.
        """
        return len(self._buffer) > 0

    def __len__(self) -> int:
        """Return the current number of frames in buffer."""
        return len(self._buffer)


def _format_timestamp(ts: float) -> str:
    """Format timestamp as human-readable date and time.

    Args:
        ts: Unix timestamp.

    Returns:
        Formatted string: "YYYY-MM-DD HH:MM:SS.sss"
    """
    dt = datetime.fromtimestamp(ts)
    return dt.strftime("%Y-%m-%d %H:%M:%S.") + f"{int((ts % 1) * 1_000_000):06d}"


class CameraPublisher:
    """Publishes raw camera frames via ZMQ.

    Attributes:
        camera_index: Index of the camera device to use.
        bind_address: ZMQ bind address (e.g., "tcp://*:5555").
        fps_limit: Maximum frames per second to publish (0 for unlimited).
        capture_width: Capture width in pixels (default: 1920).
        capture_height: Capture height in pixels (default: 1080).
        capture_format: Capture pixel format (default: "YUYV").
        compression_level: Zstd compression level (0-22, default: 1).
    """

    # OpenCV fourcc codes for pixel formats
    FORMAT_FOURCC = {
        "YUYV": cv2.VideoWriter_fourcc("Y", "U", "Y", "V"),
        "MJPG": cv2.VideoWriter_fourcc("M", "J", "P", "G"),
    }

    def __init__(
        self,
        camera_index: int = 0,
        bind_address: str = "tcp://*:5555",
        fps_limit: int = 5,
        capture_width: int = 1920,
        capture_height: int = 1080,
        capture_format: str = "YUYV",
        compression_level: int = 1,
    ) -> None:
        """Initialize the camera publisher.

        Args:
            camera_index: Index of the camera device (default: 0).
            bind_address: ZMQ bind address (default: "tcp://*:5555").
            fps_limit: Maximum FPS to publish, 0 for unlimited (default: 5).
            capture_width: Capture width in pixels (default: 1920).
            capture_height: Capture height in pixels (default: 1080).
            capture_format: Capture pixel format (default: "YUYV").
            compression_level: Zstd compression level 1-22 (default: 1).
        """
        self.camera_index = camera_index
        self.bind_address = bind_address
        self.fps_limit = fps_limit
        self.capture_width = capture_width
        self.capture_height = capture_height
        self.capture_format = capture_format
        self.compression_level = compression_level

        self._context: Optional[zmq.Context] = None
        self._socket: Optional[zmq.Socket] = None
        self._cap: Optional[cv2.VideoCapture] = None
        self._running = False
        self._sequence = 0
        self._compressor: Optional[zstandard.ZstdCompressor] = None
        self._input_handler: Optional[TerminalInputHandler] = None
        self._image_buffer = ImageBuffer(max_size=1)

    def _configure_camera(self) -> None:
        """Configure camera capture settings."""
        # Disable automatic RGB conversion for raw format capture
        if self.capture_format == "YUYV":
            self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 0)

        # Set capture resolution
        self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.capture_width)
        self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.capture_height)

        # Set capture format
        if self.capture_format in self.FORMAT_FOURCC:
            fourcc = self.FORMAT_FOURCC[self.capture_format]
            self._cap.set(cv2.CAP_PROP_FOURCC, fourcc)

    def _create_header(
        self,
        width: int,
        height: int,
        fmt: str,
        ts_before: float,
        ts_after: float,
        compressed: bool = False,
        original_size: int = 0,
    ) -> bytes:
        """Create JSON header for the frame.

        Args:
            width: Frame width in pixels.
            height: Frame height in pixels.
            fmt: Pixel format string (e.g., "YUYV").
            ts_before: Timestamp before cap.read().
            ts_after: Timestamp after cap.read().
            compressed: Whether the frame data is compressed.
            original_size: Original uncompressed size in bytes.

        Returns:
            JSON header as bytes.
        """
        header = {
            "width": width,
            "height": height,
            "fmt": fmt,
            "seq": self._sequence,
            "ts_before": _format_timestamp(ts_before),
            "ts_after": _format_timestamp(ts_after),
            "compressed": compressed,
            "compression": "zstd" if compressed else None,
            "compression_level": self.compression_level if compressed else None,
            "original_size": original_size if compressed else None,
            "hostname": HOSTNAME,
        }
        return json.dumps(header).encode("utf-8")

    def _compute_checksum(self, data: bytes) -> str:
        """Compute MD5 checksum of data.

        Args:
            data: Raw bytes to checksum.

        Returns:
            Hexadecimal checksum string.
        """
        return hashlib.md5(data).hexdigest()

    def _handle_keypress(self, key: str) -> bool:
        """Handle a keypress from the terminal.

        Args:
            key: The character that was pressed.

        Returns:
            True if the application should continue running, False to quit.
        """
        key = key.lower()

        # Handle numeric keys 1-9 for buffer size
        if key in "123456789":
            new_size = int(key)
            self._image_buffer.max_size = new_size
            mode_str = self._image_buffer.mode.upper()
            print(
                f"\n[Buffer size: {new_size} | Mode: {mode_str}]"
            )
        elif key == 'a':
            new_mode = self._image_buffer.toggle_mode()
            mode_str = new_mode.upper()
            print(f"\n[Mode switched to: {mode_str}]")
        elif key == 'q':
            print("\n'q' pressed - quitting...")
            return False
        elif key == '\x03':  # Ctrl+C
            print("\nCtrl+C pressed - quitting...")
            return False

        return True

    def _get_status_line(self) -> str:
        """Get current status line for display.

        Returns:
            Status string showing buffer configuration.
        """
        mode = self._image_buffer.mode.upper()
        size = self._image_buffer.max_size
        current = len(self._image_buffer)
        return f"[Buffer: {current}/{size} | Mode: {mode}]"

    def _create_blue_yuyv_frame(self) -> np.ndarray:
        """Create a synthetic blue image in YUYV format.

        Returns:
            A 640x480 YUYV frame with blue color.
            YUYV format: Y (luma) followed by U (Cb) and V (Cr) chroma.
            For blue: Y=41, U=240, V=110 (typical blue in YUV)
        """
        width = 640
        height = 480

        # Blue color in YUV: Y=41, U=240 (Cb), V=110 (Cr)
        # YUYV format stores: [Y0, U, Y1, V] for each pair of pixels
        # So for a blue image, we need:
        # - Y values around 41 (luma for blue)
        # - U values around 240 (high Cb = blue)
        # - V values around 110 (low Cr = not red)

        # Create YUYV frame: shape (height, width, 2) where each "pixel" is YU or YV
        # Actually YUYV is interleaved: Y0 U Y1 V for two pixels
        # So for width pixels, we have width*2 bytes per row
        frame = np.zeros((height, width, 2), dtype=np.uint8)

        # Fill with blue color
        # YUYV layout: [Y0, U, Y1, V] for pixels 0 and 1
        # For solid blue: Y=41, U=240, V=110
        y_value = 41
        u_value = 240
        v_value = 110

        # Channel 0 gets Y values (alternating Y0, Y1)
        frame[:, :, 0] = y_value

        # Channel 1 gets alternating U and V
        # For even columns: U, for odd columns: V
        frame[:, ::2, 1] = u_value   # U values at even positions
        frame[:, 1::2, 1] = v_value  # V values at odd positions

        return frame

    def start(self) -> None:
        """Start the camera publisher.

        Opens the camera, binds the ZMQ socket, and starts publishing frames.
        Press Ctrl+C to stop.

        Raises:
            RuntimeError: If camera cannot be opened or ZMQ socket cannot bind.
        """
        # Initialize terminal input handler
        self._input_handler = TerminalInputHandler()
        self._input_handler.start()

        # Initialize ZMQ context and socket
        self._context = zmq.Context()
        self._socket = self._context.socket(zmq.PUB)
        self._socket.bind(self.bind_address)

        # Initialize compressor if compression level > 0
        if self.compression_level > 0:
            self._compressor = zstandard.ZstdCompressor(level=self.compression_level)
            print(f"Zstd compression enabled (level {self.compression_level})")

        # Check if we're using synthetic blue image mode (camera_index == -1)
        use_synthetic = self.camera_index == -1

        if not use_synthetic:
            # Open camera with V4L2 backend for raw format support
            self._cap = cv2.VideoCapture(self.camera_index, cv2.CAP_V4L2)
            if not self._cap.isOpened():
                raise RuntimeError(
                    f"Failed to open camera with index {self.camera_index}"
                )

            # Configure camera settings
            self._configure_camera()

            # Verify actual capture settings
            actual_width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            actual_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
            actual_format = int(self._cap.get(cv2.CAP_PROP_FOURCC))
            actual_fourcc = "".join(
                [chr((actual_format >> 8 * i) & 0xFF) for i in range(4)]
            )

            print(f"Camera publisher started on {self.bind_address}")
            print(
                f"Capture settings: {actual_width}x{actual_height} "
                f"format={actual_fourcc}"
            )
        else:
            print(f"Camera publisher started on {self.bind_address}")
            print("Capture settings: 640x480 format=YUYV (synthetic blue image)")

        print("Commands: 'q' = quit")
        print("          '1'-'9' = buffer size (number of frames to average/accumulate)")
        print("          'a' = toggle between AVERAGE and ACCUMULATE mode")
        print("Press Ctrl+C to stop")

        self._running = True
        frame_time = 1.0 / self.fps_limit if self.fps_limit > 0 else 0
        last_frame_time = 0.0
        frame_count = 0

        # FPS calculation variables
        fps_warmup_seconds = 5.0
        fps_start_time: Optional[float] = None
        fps_frame_count = 0
        current_fps = 0.0

        try:
            while self._running:
                # Check for keyboard input
                if self._input_handler is not None:
                    key = self._input_handler.get_key()
                    if key is not None:
                        if not self._handle_keypress(key):
                            break

                # Frame rate limiting
                if frame_time > 0:
                    elapsed = time.time() - last_frame_time
                    if elapsed < frame_time:
                        time.sleep(frame_time - elapsed)
                    last_frame_time = time.time()

                # Timestamp before capture
                ts_before = time.time()

                if use_synthetic:
                    # Generate synthetic blue frame with text overlay
                    frame = self._create_blue_yuyv_frame()
                    frame = add_publisher_message(frame, PUBLISHER_MESSAGE, line2=HOSTNAME)
                    ret = True
                else:
                    # Capture frame from camera
                    ret, frame = self._cap.read()

                # Timestamp after capture
                ts_after = time.time()

                if not ret:
                    # Track first failure time and failure count
                    if not hasattr(self, '_first_fail_time'):
                        self._first_fail_time = time.time()
                        self._fail_count = 0
                    self._fail_count += 1
                    
                    # Calculate elapsed time since first failure
                    elapsed = time.time() - self._first_fail_time
                    elapsed_mins = int(elapsed // 60)
                    elapsed_secs = int(elapsed % 60)
                    elapsed_str = f"{elapsed_mins:02d}:{elapsed_secs:02d}"
                    
                    # Calculate time until quit (default 20 seconds)
                    quit_timeout = 20.0
                    remaining = max(0, quit_timeout - elapsed)
                    quit_mins = int(remaining // 60)
                    quit_secs = int(remaining % 60)
                    quit_str = f"{quit_mins:02d}:{quit_secs:02d}"
                    
                    # First failure uses normal print, subsequent use \r to overwrite
                    end_char = "\n" if self._fail_count == 1 else "\r"
                    print(f"Failed to capture frame - elapsed: {elapsed_str}, quits in {quit_str}", end=end_char)
                    
                    # Quit after timeout
                    if remaining <= 0:
                        print("\nCamera capture timeout - quitting...")
                        break
                    continue
                else:
                    # Reset failure tracking on successful capture
                    if hasattr(self, '_first_fail_time'):
                        delattr(self, '_first_fail_time')
                        delattr(self, '_fail_count')

                height, width = frame.shape[:2]

                # Increment frame count for every captured frame
                frame_count += 1

                # Print frame count update on every read (before buffering decision)
                status = self._get_status_line()
                print(
                    f"Frame:{frame_count:7d} "
                    f"Seq:{self._sequence:7d} ",
#                    f"(capturing...)",
                    end="\r",
                )

                # Add frame to buffer with timestamps
                self._image_buffer.add_frame(frame, ts_before, ts_after)

                # Check if we should publish (all N frames are fresh)
                if not self._image_buffer.should_publish():
                    continue

                # Get timestamps for the accumulated frame
                # ts_before = timestamp before first frame
                # ts_after = timestamp after last frame
                ts_before, ts_after = self._image_buffer.get_timestamps()

                # Get processed frame from buffer
                processed_frame = self._image_buffer.get_processed_frame()
                if processed_frame is None:
                    continue

                # Mark that we're publishing (resets buffer and fresh frame counter)
                self._image_buffer.mark_published()

                # Only increment sequence when we actually publish
                self._sequence += 1

                # Get raw frame bytes from processed frame
                frame_bytes = processed_frame.tobytes()
                original_size = len(frame_bytes)

                # Compress frame if compression is enabled
                if self._compressor is not None:
                    compressed_data = self._compressor.compress(frame_bytes)
                    is_compressed = True
                    image_data = compressed_data
                else:
                    is_compressed = False
                    image_data = frame_bytes

                # Compute checksum on original (uncompressed) data
                checksum = self._compute_checksum(frame_bytes)

                # Create header with compression info using accumulated timestamps
                header = self._create_header(
                    width, height, self.capture_format, ts_before, ts_after,
                    compressed=is_compressed, original_size=original_size
                )

                # Update header with checksum
                header_dict = json.loads(header.decode("utf-8"))
                header_dict["checksum"] = checksum
                header = json.dumps(header_dict).encode("utf-8")

                # Send multipart message using zero-copy
                # [header_json, image_bytes]
                header_frame = zmq.Frame(header, copy=False)
                image_frame = zmq.Frame(image_data, copy=False)
                self._socket.send_multipart([header_frame, image_frame])

                # FPS calculation with 5-second warmup
                if fps_start_time is None:
                    fps_start_time = time.time()

                elapsed_total = time.time() - fps_start_time
                if elapsed_total >= fps_warmup_seconds:
                    fps_frame_count += 1
                    current_fps = fps_frame_count / elapsed_total
                else:
                    fps_frame_count += 1

                # Format ts_after for display
                ts_after_str = _format_timestamp(ts_after)

                status = self._get_status_line()

                print(
                    f"Frame:{frame_count:7d} "
                    f"Seq:{self._sequence:7d} "
                    f"Res:{width:4d}x{height:4d} "
                    f"Time:{ts_after_str} "
                    f"; {current_fps:5.1f} fps; "
                    f"{status}",
                    end="\r",
                )

        except KeyboardInterrupt:
            print("\nStopping publisher...")
        finally:
            self.stop()

    def stop(self) -> None:
        """Stop the camera publisher and release resources."""
        self._running = False

        # Stop input handler and restore terminal
        if self._input_handler is not None:
            self._input_handler.stop()
            self._input_handler = None

        if self._cap is not None:
            self._cap.release()
            self._cap = None

        if self._socket is not None:
            self._socket.close()
            self._socket = None

        if self._context is not None:
            self._context.term()
            self._context = None

        print("Publisher stopped")


def run_publisher(
    camera: int = 0,
    bind_address: str = "tcp://*:5555",
    fps: int = 5,
    width: int = 1920,
    height: int = 1080,
    format: str = "YUYV",
    compression: int = 1,
) -> None:
    """Run the camera publisher with the specified parameters.

    Args:
        camera: Camera device index.
        bind_address: ZMQ bind address.
        fps: Maximum frames per second (0 for unlimited).
        width: Capture width in pixels.
        height: Capture height in pixels.
        format: Capture pixel format ("YUYV" or "MJPG").
        compression: Zstd compression level 1-22, 0 to disable (default: 1).
    """
    publisher = CameraPublisher(
        camera_index=camera,
        bind_address=bind_address,
        fps_limit=fps,
        capture_width=width,
        capture_height=height,
        capture_format=format,
        compression_level=compression,
    )
    publisher.start()
