"""
nvr_processor.py — Production-grade NVR AI detection pipeline.

Architecture:
  RTSPProcessor threads  →  upload_queue  →  UploaderWorker thread
  Each camera runs its own capture + YOLO inference loop, writing
  RAM-backed segments via FFmpeg. Detected segments are handed off
  to a single background uploader (MinIO + MongoDB).
"""

from __future__ import annotations

import json
import logging
import os
import queue
import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import cv2
from ultralytics import YOLO

# ---------------------------------------------------------------------------
# Optional production dependencies — gracefully degraded to MOCK mode
# ---------------------------------------------------------------------------
try:
    import boto3
    from botocore.exceptions import BotoCoreError, ClientError
    _BOTO3_AVAILABLE = True
except ImportError:
    _BOTO3_AVAILABLE = False

try:
    from pymongo import MongoClient
    from pymongo.errors import PyMongoError
    _PYMONGO_AVAILABLE = True
except ImportError:
    _PYMONGO_AVAILABLE = False

# ---------------------------------------------------------------------------
# Logging — structured, level-aware, thread-safe
# ---------------------------------------------------------------------------
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s — %(message)s"
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
logger = logging.getLogger("nvr")


# ---------------------------------------------------------------------------
# Configuration dataclasses — validated at startup, never raw dicts downstream
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class MinioConfig:
    endpoint: str
    access_key: str
    secret_key: str
    bucket: str


@dataclass(frozen=True)
class MongoConfig:
    uri: str
    db: str
    collection: str


@dataclass(frozen=True)
class PipelineConfig:
    video_sources: list[str]
    model_path: str
    target_object: str
    segment_duration_seconds: int
    target_yolo_fps: int = 5
    ram_cache_dir: str = "/dev/shm/nvr_cache"
    reconnect_base_delay: float = 2.0
    reconnect_max_delay: float = 60.0
    upload_queue_maxsize: int = 100
    minio: Optional[MinioConfig] = None
    mongo: Optional[MongoConfig] = None

    def __post_init__(self) -> None:
        if not self.video_sources:
            raise ValueError("video_sources must not be empty.")
        if self.segment_duration_seconds <= 0:
            raise ValueError("segment_duration_seconds must be > 0.")
        if self.target_yolo_fps <= 0:
            raise ValueError("target_yolo_fps must be > 0.")
        if not Path(self.model_path).exists():
            raise FileNotFoundError(f"YOLO model not found: {self.model_path}")


@dataclass
class UploadTask:
    camera_id: int
    file_path: str
    timestamp: datetime
    detected_object: str


# ---------------------------------------------------------------------------
# Config loader — merges file + environment overrides
# ---------------------------------------------------------------------------
def load_config(config_path: str = "config.json") -> PipelineConfig:
    """Load JSON config and apply Docker environment variable overrides."""
    with open(config_path) as f:
        raw = json.load(f)

    # Environment variable overrides for containerised deployments
    storage_ip = os.environ.get("STORAGE_NODE_IP")
    if storage_ip:
        raw.setdefault("minio", {})["endpoint"] = f"{storage_ip}:9000"
        raw.setdefault("mongodb", {})["uri"] = f"mongodb://{storage_ip}:27017"

    minio_cfg: Optional[MinioConfig] = None
    if "minio" in raw:
        m = raw["minio"]
        minio_cfg = MinioConfig(
            endpoint=m["endpoint"],
            access_key=m["access_key"],
            secret_key=m["secret_key"],
            bucket=m["bucket"],
        )

    mongo_cfg: Optional[MongoConfig] = None
    if "mongodb" in raw:
        db = raw["mongodb"]
        mongo_cfg = MongoConfig(
            uri=db["uri"],
            db=db["db"],
            collection=db["collection"],
        )

    return PipelineConfig(
        video_sources=raw["video_sources"],
        model_path=raw["model_path"],
        target_object=raw["target_object"],
        segment_duration_seconds=raw["segment_duration_seconds"],
        target_yolo_fps=raw.get("target_yolo_fps", 5),
        ram_cache_dir=raw.get("ram_cache_dir", "/dev/shm/nvr_cache"),
        reconnect_base_delay=raw.get("reconnect_base_delay", 2.0),
        reconnect_max_delay=raw.get("reconnect_max_delay", 60.0),
        upload_queue_maxsize=raw.get("upload_queue_maxsize", 100),
        minio=minio_cfg,
        mongo=mongo_cfg,
    )


# ---------------------------------------------------------------------------
# FFmpeg helpers
# ---------------------------------------------------------------------------
def _build_ffmpeg_command(output_path: str, width: int, height: int, fps: int) -> list[str]:
    return [
        "ffmpeg", "-y",
        "-f", "rawvideo",
        "-vcodec", "rawvideo",
        "-pix_fmt", "bgr24",
        "-s", f"{width}x{height}",
        "-r", str(fps),
        "-i", "-",
        "-c:v", "libx264",
        "-pix_fmt", "yuv420p",
        "-preset", "ultrafast",
        "-tune", "zerolatency",
        output_path,
    ]


def create_ffmpeg_process(output_path: str, width: int, height: int, fps: int) -> subprocess.Popen:
    """Spawn an FFmpeg subprocess writing encoded H.264 to *output_path*."""
    cmd = _build_ffmpeg_command(output_path, width, height, fps)
    return subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)


def close_ffmpeg(proc: subprocess.Popen, timeout: float = 10.0) -> None:
    """Flush and close an FFmpeg process, with a hard-kill fallback."""
    try:
        if proc.stdin and not proc.stdin.closed:
            proc.stdin.close()
    except OSError:
        pass  # Pipe already broken — not a fatal error
    try:
        proc.wait(timeout=timeout)
    except subprocess.TimeoutExpired:
        logger.warning("FFmpeg did not exit cleanly; sending SIGKILL.")
        proc.kill()
        proc.wait()


# ---------------------------------------------------------------------------
# Health tracker — lightweight per-camera stats
# ---------------------------------------------------------------------------
@dataclass
class CameraHealth:
    camera_id: int
    frames_processed: int = 0
    segments_uploaded: int = 0
    segments_dropped: int = 0
    last_detection: Optional[datetime] = None
    last_error: Optional[str] = None
    reconnects: int = 0

    def log_summary(self) -> None:
        logger.info(
            "[Cam %d] Health — frames=%d, uploaded=%d, dropped=%d, "
            "reconnects=%d, last_detection=%s",
            self.camera_id,
            self.frames_processed,
            self.segments_uploaded,
            self.segments_dropped,
            self.reconnects,
            self.last_detection.isoformat() if self.last_detection else "never",
        )


# ---------------------------------------------------------------------------
# Uploader worker
# ---------------------------------------------------------------------------
class UploaderWorker(threading.Thread):
    """
    Consumes UploadTask objects from the queue, uploads to MinIO,
    indexes metadata in MongoDB, then removes the file from RAM.

    Runs as a daemon thread. Call `drain_and_stop()` for a graceful shutdown
    that processes all queued items before exiting.
    """

    def __init__(self, upload_queue: queue.Queue, config: PipelineConfig) -> None:
        super().__init__(name="UploaderWorker", daemon=True)
        self._queue = upload_queue
        self._config = config
        self._stop_event = threading.Event()
        self._log = logging.getLogger("nvr.uploader")

        self._mock_mode = not (_BOTO3_AVAILABLE and _PYMONGO_AVAILABLE and
                               config.minio and config.mongo)

        if self._mock_mode:
            # Temporary debug snippet inside UploaderWorker.__init__
            print(f"DEBUG: boto3={_BOTO3_AVAILABLE}, pymongo={_PYMONGO_AVAILABLE}, minio={config.minio is not None}, mongo={config.mongo is not None}")
            self._log.warning("Running in MOCK mode — no actual uploads or DB writes.")
            self._s3 = None
            self._collection = None
        else:
            self._s3 = self._init_s3(config.minio)
            self._collection = self._init_mongo(config.mongo)

    # ------------------------------------------------------------------
    def _init_s3(self, cfg: MinioConfig):
        client = boto3.client(
            "s3",
            endpoint_url=f"http://{cfg.endpoint}",
            aws_access_key_id=cfg.access_key,
            aws_secret_access_key=cfg.secret_key,
        )
        # Ensure bucket exists; create if missing
        try:
            client.head_bucket(Bucket=cfg.bucket)
        except ClientError as exc:
            error_code = exc.response["Error"]["Code"]
            if error_code == "404":
                self._log.info("Bucket '%s' not found — creating.", cfg.bucket)
                client.create_bucket(Bucket=cfg.bucket)
            else:
                raise
        return client

    def _init_mongo(self, cfg: MongoConfig):
        client = MongoClient(cfg.uri, serverSelectionTimeoutMS=5000)
        # Eagerly test connectivity at startup
        client.admin.command("ping")
        db = client[cfg.db]
        col = db[cfg.collection]

        # Explicitly passing the existing index name prevents the conflict error
        col.create_index([("camera_id", 1), ("timestamp", -1)], name="camera_timestamp_idx")

        self._log.info("MongoDB connected. Index ensured on collection '%s'.", cfg.collection)
        return col

    # ------------------------------------------------------------------
    def run(self) -> None:
        self._log.info("Uploader pipeline active.")
        while not self._stop_event.is_set() or not self._queue.empty():
            try:
                task: UploadTask = self._queue.get(timeout=1)
            except queue.Empty:
                continue
            try:
                self._process(task)
            except Exception:
                self._log.exception("Unhandled error processing task for cam %d.", task.camera_id)
            finally:
                self._queue.task_done()

        self._log.info("Uploader pipeline shut down cleanly.")

    def _process(self, task: UploadTask) -> None:
        if not Path(task.file_path).exists():
            self._log.warning("File vanished before upload: %s", task.file_path)
            return

        filename = Path(task.file_path).name
        s3_key = f"cam_{task.camera_id}/{filename}"

        if self._mock_mode:
            self._log.info("[MOCK] upload → s3://%s | metadata cam=%d obj=%s",
                           s3_key, task.camera_id, task.detected_object)
        else:
            self._upload_to_s3(task.file_path, s3_key)
            self._index_in_mongo(task, filename, s3_key)
            self._log.info("Uploaded and indexed: %s", s3_key)

        # Always remove from RAM regardless of mock/real mode
        try:
            os.remove(task.file_path)
        except OSError as exc:
            self._log.warning("Could not remove temp file %s: %s", task.file_path, exc)

    def _upload_to_s3(self, file_path: str, s3_key: str) -> None:
        cfg = self._config.minio
        for attempt in range(1, 4):
            try:
                self._s3.upload_file(file_path, cfg.bucket, s3_key)
                return
            except (BotoCoreError, ClientError) as exc:
                self._log.warning("S3 upload attempt %d/3 failed: %s", attempt, exc)
                if attempt < 3:
                    time.sleep(2 ** attempt)
        raise RuntimeError(f"S3 upload failed after 3 attempts: {s3_key}")

    def _index_in_mongo(self, task: UploadTask, filename: str, s3_key: str) -> None:
        doc = {
            "camera_id": task.camera_id,
            "filename": filename,
            "s3_path": s3_key,
            "timestamp": task.timestamp,
            "detected_object": task.detected_object,
            "uploaded_at": datetime.now(tz=timezone.utc),
        }
        for attempt in range(1, 4):
            try:
                self._collection.insert_one(doc)
                return
            except PyMongoError as exc:
                self._log.warning("MongoDB insert attempt %d/3 failed: %s", attempt, exc)
                if attempt < 3:
                    time.sleep(2 ** attempt)
        raise RuntimeError(f"MongoDB insert failed after 3 attempts: {filename}")

    def drain_and_stop(self) -> None:
        """Signal the worker to finish the queue then exit."""
        self._log.info("Drain requested — finishing %d queued items.", self._queue.qsize())
        self._stop_event.set()
        self.join()


# ---------------------------------------------------------------------------
# RTSP processor — one thread per camera
# ---------------------------------------------------------------------------
class RTSPProcessor(threading.Thread):
    """
    Captures an RTSP stream, encodes segments to RAM via FFmpeg,
    runs YOLO inference at a throttled FPS, and queues detected segments.
    """

    def __init__(
        self,
        stream_id: int,
        rtsp_url: str,
        config: PipelineConfig,
        upload_queue: queue.Queue,
    ) -> None:
        super().__init__(name=f"RTSPProcessor-{stream_id}", daemon=True)
        self.stream_id = stream_id
        self._rtsp_url = rtsp_url
        self._config = config
        self._upload_queue = upload_queue
        self._stop_event = threading.Event()
        self._health = CameraHealth(camera_id=stream_id)
        self._log = logging.getLogger(f"nvr.cam{stream_id}")

        output_dir = Path(config.ram_cache_dir) / f"cam_{stream_id}"
        output_dir.mkdir(parents=True, exist_ok=True)
        self._output_dir = output_dir

    # ------------------------------------------------------------------
    def run(self) -> None:
        self._log.info("Starting — loading YOLO model: %s", self._config.model_path)
        model = YOLO(self._config.model_path)
        self._log.info("YOLO model loaded.")

        while not self._stop_event.is_set():
            try:
                self._capture_loop(model)
            except Exception:
                self._log.exception("Unexpected error in capture loop — will reconnect.")
            if not self._stop_event.is_set():
                delay = self._reconnect_delay()
                self._log.info("Reconnecting in %.1fs…", delay)
                time.sleep(delay)

        self._log.info("Thread stopped.")

    def _reconnect_delay(self) -> float:
        """Exponential backoff capped at reconnect_max_delay."""
        base = self._config.reconnect_base_delay
        cap = self._config.reconnect_max_delay
        self._health.reconnects += 1
        return min(base * (2 ** (self._health.reconnects - 1)), cap)

    def _capture_loop(self, model: YOLO) -> None:
        """Inner loop: open camera → stream frames → write segments."""
        cap = cv2.VideoCapture(self._rtsp_url)
        if not cap.isOpened():
            self._health.last_error = f"Cannot open stream: {self._rtsp_url}"
            self._log.error(self._health.last_error)
            return

        # Reset backoff counter on successful connection
        self._health.reconnects = 0
        self._log.info("Stream connected.")

        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = int(cap.get(cv2.CAP_PROP_FPS)) or 30
        inference_interval = max(1, fps // self._config.target_yolo_fps)

        seg = self._new_segment(width, height, fps)

        try:
            while not self._stop_event.is_set():
                ret, frame = cap.read()
                if not ret:
                    self._log.warning("Frame read failed — stream may have dropped.")
                    break

                # Write raw frame bytes to FFmpeg stdin; guard against broken pipe
                try:
                    seg["proc"].stdin.write(frame.tobytes())
                except BrokenPipeError:
                    self._log.error("FFmpeg pipe broken — restarting segment.")
                    close_ffmpeg(seg["proc"])
                    # Discard unfinished segment; don't upload it
                    self._safe_remove(seg["path"])
                    seg = self._new_segment(width, height, fps)
                    continue

                # Throttled YOLO inference
                if seg["frame_count"] % inference_interval == 0:
                    if self._run_inference(model, frame):
                        seg["detected"] = True
                        self._health.last_detection = datetime.now(tz=timezone.utc)

                seg["frame_count"] += 1
                self._health.frames_processed += 1

                # Rotate segment when duration is reached
                if time.monotonic() - seg["start_mono"] >= self._config.segment_duration_seconds:
                    seg = self._rotate_segment(seg, width, height, fps)

        finally:
            # Best-effort cleanup of in-progress segment on loop exit
            close_ffmpeg(seg["proc"])
            if not seg["uploaded"]:
                self._safe_remove(seg["path"])
            cap.release()
            self._health.log_summary()

    # ------------------------------------------------------------------
    def _new_segment(self, width: int, height: int, fps: int) -> dict:
        """Create a fresh segment descriptor and start its FFmpeg process."""
        ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
        path = str(self._output_dir / f"clip_{ts}.mp4")
        return {
            "path": path,
            "proc": create_ffmpeg_process(path, width, height, fps),
            "start_mono": time.monotonic(),
            "frame_count": 0,
            "detected": False,
            "uploaded": False,
            # Keep a reference so the *next* segment can upload the pre-buffer
            "prev_path": None,
            "prev_uploaded": False,
        }

    def _rotate_segment(self, seg: dict, width: int, height: int, fps: int) -> dict:
        """
        Close the current segment, decide whether to upload it (and any
        pre-buffer from the previous segment), then return a fresh segment.
        """
        close_ffmpeg(seg["proc"])

        if seg["detected"]:
            self._log.info("Detection! Queuing segment for upload: %s", Path(seg["path"]).name)

            # Upload pre-buffer segment if it wasn't already sent
            if seg["prev_path"] and not seg["prev_uploaded"]:
                self._enqueue(seg["prev_path"])
                self._health.segments_uploaded += 1
            elif seg["prev_path"] and not Path(seg["prev_path"]).exists():
                pass  # Already cleaned up — that's fine

            self._enqueue(seg["path"])
            seg["uploaded"] = True
            self._health.segments_uploaded += 1
        else:
            # No detection — clean up previous segment to free RAM
            if seg["prev_path"] and not seg["prev_uploaded"]:
                self._safe_remove(seg["prev_path"])
                self._health.segments_dropped += 1

        new_seg = self._new_segment(width, height, fps)
        new_seg["prev_path"] = seg["path"]
        new_seg["prev_uploaded"] = seg["uploaded"]
        return new_seg

    def _run_inference(self, model: YOLO, frame) -> bool:
        """Return True if the target object is found in this frame."""
        try:
            results = model(frame, verbose=False)
            for result in results:
                for box in result.boxes:
                    if model.names[int(box.cls[0])] == self._config.target_object:
                        return True
        except Exception:
            self._log.exception("YOLO inference error — skipping frame.")
        return False

    def _enqueue(self, file_path: str) -> None:
        task = UploadTask(
            camera_id=self.stream_id,
            file_path=file_path,
            timestamp=datetime.now(tz=timezone.utc),
            detected_object=self._config.target_object,
        )
        try:
            self._upload_queue.put_nowait(task)
        except queue.Full:
            self._log.error(
                "Upload queue is full! Dropping segment: %s. "
                "Consider increasing upload_queue_maxsize or reducing segment count.",
                Path(file_path).name,
            )
            self._safe_remove(file_path)

    def _safe_remove(self, path: Optional[str]) -> None:
        if path and Path(path).exists():
            try:
                os.remove(path)
            except OSError as exc:
                self._log.warning("Could not remove file %s: %s", path, exc)

    def stop(self) -> None:
        self._stop_event.set()


# ---------------------------------------------------------------------------
# ConfigWatcher — live-reloads config.json and reconciles camera threads
# ---------------------------------------------------------------------------
class ConfigWatcher(threading.Thread):
    """
    Polls config.json for file-modification-time changes. On a change it:

      1. Re-parses and validates the new config.
      2. Diffs the old vs new video_sources list.
      3. Starts a new RTSPProcessor for every added URL.
      4. Gracefully stops RTSPProcessor threads for every removed URL.
      5. Logs a warning (but keeps running) if the file is transiently
         unreadable or contains invalid JSON — no camera is disrupted.

    Only video_sources is hot-reloaded.  Settings that affect shared
    infrastructure (upload_queue_maxsize, minio, mongo) require a full
    restart to avoid race conditions with the uploader worker.
    """

    # How often to stat the config file (seconds)
    POLL_INTERVAL: float = 5.0

    def __init__(
        self,
        config_path: str,
        upload_queue: "queue.Queue[UploadTask]",
        processors: dict[str, "RTSPProcessor"],   # url → thread
        processors_lock: threading.Lock,
        initial_config: PipelineConfig,
    ) -> None:
        super().__init__(name="ConfigWatcher", daemon=True)
        self._config_path = config_path
        self._upload_queue = upload_queue
        self._processors = processors          # shared, guarded by lock
        self._lock = processors_lock
        self._current_config = initial_config
        self._stop_event = threading.Event()
        self._last_mtime: float = self._safe_mtime()
        self._log = logging.getLogger("nvr.config_watcher")
        # Monotonically increasing ID so new cameras get unique stream_ids
        # even if a URL is removed and re-added later.
        self._next_stream_id: int = len(initial_config.video_sources)

    # ------------------------------------------------------------------
    def run(self) -> None:
        self._log.info("Watching '%s' for changes (poll every %.0fs).",
                       self._config_path, self.POLL_INTERVAL)
        while not self._stop_event.is_set():
            self._stop_event.wait(timeout=self.POLL_INTERVAL)
            if self._stop_event.is_set():
                break
            self._check()

    def stop(self) -> None:
        self._stop_event.set()

    # ------------------------------------------------------------------
    def _safe_mtime(self) -> float:
        try:
            return Path(self._config_path).stat().st_mtime
        except OSError:
            return 0.0

    def _check(self) -> None:
        current_mtime = self._safe_mtime()
        if current_mtime == self._last_mtime:
            return  # File unchanged

        self._log.info("Config file change detected — reloading.")
        try:
            new_config = load_config(self._config_path)
        except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
            self._log.warning(
                "Config reload failed (%s). Keeping existing configuration.", exc
            )
            # Update mtime even on failure so we don't spam warnings every
            # poll cycle for the same broken file.
            self._last_mtime = current_mtime
            return

        self._last_mtime = current_mtime
        self._reconcile(new_config)
        self._current_config = new_config

    def _reconcile(self, new_config: PipelineConfig) -> None:
        """Start/stop threads to match the new video_sources list."""
        with self._lock:
            old_urls = set(self._processors.keys())
            new_urls = set(new_config.video_sources)

            added = new_urls - old_urls
            removed = old_urls - new_urls
            unchanged = old_urls & new_urls

        if not added and not removed:
            self._log.info("Config reloaded — no camera changes detected.")
            return

        # ---- Stop removed cameras ----------------------------------------
        for url in removed:
            with self._lock:
                proc = self._processors.pop(url, None)
            if proc:
                self._log.info("Removing camera: %s", url)
                proc.stop()
                # Join in a background thread so watcher is never blocked
                threading.Thread(
                    target=proc.join,
                    kwargs={"timeout": 15},
                    daemon=True,
                    name=f"Joiner-{proc.stream_id}",
                ).start()

        # ---- Start added cameras -----------------------------------------
        for url in added:
            stream_id = self._next_stream_id
            self._next_stream_id += 1
            self._log.info("Adding camera stream_id=%d: %s", stream_id, url)
            proc = RTSPProcessor(
                stream_id=stream_id,
                rtsp_url=url,
                config=new_config,
                upload_queue=self._upload_queue,
            )
            proc.start()
            with self._lock:
                self._processors[url] = proc

        if unchanged:
            self._log.info(
                "Config reloaded — %d camera(s) unchanged, %d added, %d removed.",
                len(unchanged), len(added), len(removed),
            )


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
    import argparse

    parser = argparse.ArgumentParser(description="NVR AI Detection Pipeline")
    parser.add_argument(
        "--config", default="config.json",
        help="Path to JSON config file (default: config.json)",
    )
    args = parser.parse_args()

    config = load_config(args.config)

    logger.info("=== NVR Pipeline Starting — %d camera(s) ===", len(config.video_sources))

    upload_queue: queue.Queue[UploadTask] = queue.Queue(maxsize=config.upload_queue_maxsize)

    # 1. Start uploader background thread
    uploader = UploaderWorker(upload_queue, config)
    uploader.start()

    # 2. Start one capture/inference thread per camera.
    #    Keyed by URL so the watcher can diff old vs new lists.
    processors_lock = threading.Lock()
    processors: dict[str, RTSPProcessor] = {}
    for idx, url in enumerate(config.video_sources):
        proc = RTSPProcessor(
            stream_id=idx,
            rtsp_url=url,
            config=config,
            upload_queue=upload_queue,
        )
        proc.start()
        processors[url] = proc

    # 3. Start the config watcher thread
    watcher = ConfigWatcher(
        config_path=args.config,
        upload_queue=upload_queue,
        processors=processors,
        processors_lock=processors_lock,
        initial_config=config,
    )
    watcher.start()

    try:
        # Keep main thread alive; log aggregate health every 60 s
        while True:
            time.sleep(60)
            with processors_lock:
                active = list(processors.values())
            for proc in active:
                proc._health.log_summary()
    except KeyboardInterrupt:
        logger.info("Shutdown requested — stopping config watcher…")
        watcher.stop()

        logger.info("Stopping camera threads…")
        with processors_lock:
            active = list(processors.values())
        for proc in active:
            proc.stop()
        for proc in active:
            proc.join(timeout=15)

        logger.info("Camera threads stopped. Draining upload queue…")
        uploader.drain_and_stop()
        logger.info("Clean shutdown complete.")


if __name__ == "__main__":
    main()
