"""Camera/webcam capture engine using OpenCV.

Captures frames from USB/integrated webcams via ``cv2.VideoCapture``.
Each camera is exposed as a "display" through the standard
``CaptureEngine`` / ``CaptureStream`` architecture.

Prerequisites (optional dependency):
    pip install opencv-python-headless>=4.8.0
"""

import platform
import sys
import threading
import time
from typing import Any, Dict, List, Optional, Set


from wled_controller.core.capture_engines.base import (
    CaptureEngine,
    CaptureStream,
    DisplayInfo,
    ScreenCapture,
)
from wled_controller.utils import get_logger

logger = get_logger(__name__)

_MAX_CAMERA_INDEX = 10  # probe indices 0..9

# Process-wide registry of cv2 camera indices currently held open.
# Prevents _enumerate_cameras from probing an in-use camera (which can
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
# from opening the same physical camera concurrently.
_active_cv2_indices: Set[int] = set()
_camera_lock = threading.Lock()
_CV2_BACKENDS = {
    "auto": None,
    "dshow": 700,   # cv2.CAP_DSHOW
    "msmf": 1400,   # cv2.CAP_MSMF
    "v4l2": 200,    # cv2.CAP_V4L2
}


def _get_default_backend():
    """Return the best default backend for the current platform."""
    if sys.platform == "win32":
        return "dshow"  # faster open than MSMF on Windows
    return "auto"


def _cv2_backend_id(backend_name: str) -> Optional[int]:
    """Convert a backend name string to cv2 API preference constant."""
    return _CV2_BACKENDS.get(backend_name)


def _get_camera_friendly_names() -> Dict[int, str]:
    """Get friendly names for cameras from OS.

    On Windows, enumerates camera devices via the SetupAPI (pure ctypes,
    no third-party dependencies).  Uses the camera device class GUID
    ``{ca3e7ab9-b4c3-4ae6-8251-579ef933890f}``.

    Returns a dict mapping sequential index to friendly name.
    """
    if platform.system() != "Windows":
        return {}

    try:
        import ctypes
        from ctypes import wintypes

        # ── SetupAPI types ────────────────────────────────────────
        class GUID(ctypes.Structure):
            _fields_ = [
                ("Data1", wintypes.DWORD),
                ("Data2", wintypes.WORD),
                ("Data3", wintypes.WORD),
                ("Data4", ctypes.c_ubyte * 8),
            ]

        class SP_DEVINFO_DATA(ctypes.Structure):
            _fields_ = [
                ("cbSize", wintypes.DWORD),
                ("ClassGuid", GUID),
                ("DevInst", wintypes.DWORD),
                ("Reserved", ctypes.POINTER(ctypes.c_ulong)),
            ]

        setupapi = ctypes.windll.setupapi

        # Camera device class GUID: {ca3e7ab9-b4c3-4ae6-8251-579ef933890f}
        GUID_DEVCLASS_CAMERA = GUID(
            0xCA3E7AB9, 0xB4C3, 0x4AE6,
            (ctypes.c_ubyte * 8)(0x82, 0x51, 0x57, 0x9E, 0xF9, 0x33, 0x89, 0x0F),
        )

        DIGCF_PRESENT = 0x00000002
        SPDRP_FRIENDLYNAME = 0x0000000C
        SPDRP_DEVICEDESC = 0x00000000
        INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value

        # SetupDiGetClassDevsW → HDEVINFO
        setupapi.SetupDiGetClassDevsW.restype = ctypes.c_void_p
        setupapi.SetupDiGetClassDevsW.argtypes = [
            ctypes.POINTER(GUID), ctypes.c_wchar_p,
            ctypes.c_void_p, wintypes.DWORD,
        ]

        # SetupDiEnumDeviceInfo → BOOL
        setupapi.SetupDiEnumDeviceInfo.restype = wintypes.BOOL
        setupapi.SetupDiEnumDeviceInfo.argtypes = [
            ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(SP_DEVINFO_DATA),
        ]

        # SetupDiGetDeviceRegistryPropertyW → BOOL
        setupapi.SetupDiGetDeviceRegistryPropertyW.restype = wintypes.BOOL
        setupapi.SetupDiGetDeviceRegistryPropertyW.argtypes = [
            ctypes.c_void_p, ctypes.POINTER(SP_DEVINFO_DATA),
            wintypes.DWORD, ctypes.POINTER(wintypes.DWORD),
            ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD),
        ]

        # SetupDiDestroyDeviceInfoList → BOOL
        setupapi.SetupDiDestroyDeviceInfoList.restype = wintypes.BOOL
        setupapi.SetupDiDestroyDeviceInfoList.argtypes = [ctypes.c_void_p]

        # ── Enumerate cameras ─────────────────────────────────────
        hdevinfo = setupapi.SetupDiGetClassDevsW(
            ctypes.byref(GUID_DEVCLASS_CAMERA), None, None, DIGCF_PRESENT,
        )
        if hdevinfo == INVALID_HANDLE_VALUE:
            return {}

        cameras: Dict[int, str] = {}
        idx = 0

        try:
            while True:
                devinfo = SP_DEVINFO_DATA()
                devinfo.cbSize = ctypes.sizeof(SP_DEVINFO_DATA)

                if not setupapi.SetupDiEnumDeviceInfo(hdevinfo, idx, ctypes.byref(devinfo)):
                    break  # ERROR_NO_MORE_ITEMS

                # Try SPDRP_FRIENDLYNAME first, fall back to SPDRP_DEVICEDESC
                name = None
                buf = ctypes.create_unicode_buffer(256)
                buf_size = wintypes.DWORD(ctypes.sizeof(buf))

                for prop in (SPDRP_FRIENDLYNAME, SPDRP_DEVICEDESC):
                    if setupapi.SetupDiGetDeviceRegistryPropertyW(
                        hdevinfo, ctypes.byref(devinfo), prop,
                        None, buf, buf_size, None,
                    ):
                        name = buf.value.strip()
                        if name:
                            break

                cameras[idx] = name if name else f"Camera {idx}"
                idx += 1
        finally:
            setupapi.SetupDiDestroyDeviceInfoList(hdevinfo)

        return cameras

    except Exception as e:
        logger.debug(f"SetupAPI camera enumeration failed: {e}")
        return {}


_camera_cache: Optional[List[Dict[str, Any]]] = None
_camera_cache_time: float = 0
_CAMERA_CACHE_TTL = 30.0  # seconds


def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
    """Probe camera indices and return metadata for each available camera.

    Results are cached for 30 seconds to avoid repeated slow probes.
    Returns a list of dicts: {cv2_index, name, width, height, fps}.
    """
    global _camera_cache, _camera_cache_time

    now = time.monotonic()
    if _camera_cache is not None and (now - _camera_cache_time) < _CAMERA_CACHE_TTL:
        return _camera_cache

    try:
        import cv2
    except ImportError:
        _camera_cache = []
        _camera_cache_time = now
        return []

    backend_id = _cv2_backend_id(backend_name)
    friendly_names = _get_camera_friendly_names()

    # On Windows, WMI tells us how many cameras exist — skip probing
    # indices beyond that count to avoid slow timeouts.
    max_probe = _MAX_CAMERA_INDEX
    if friendly_names:
        max_probe = min(len(friendly_names) + 1, _MAX_CAMERA_INDEX)

    cameras: List[Dict[str, Any]] = []
    sequential_idx = 0

    with _camera_lock:
        active = set(_active_cv2_indices)

    for i in range(max_probe):
        if i in active:
            # Camera already held open — use cached metadata if available,
            # otherwise add a placeholder so display_index mapping stays stable.
            if _camera_cache is not None:
                prev = [c for c in _camera_cache if c["cv2_index"] == i]
                if prev:
                    cameras.append(prev[0])
                    sequential_idx += 1
                    continue
            cameras.append({
                "cv2_index": i,
                "name": friendly_names.get(sequential_idx, f"Camera {sequential_idx}"),
                "width": 0,
                "height": 0,
                "fps": 30.0,
            })
            sequential_idx += 1
            continue

        if backend_id is not None:
            cap = cv2.VideoCapture(i, backend_id)
        else:
            cap = cv2.VideoCapture(i)

        if not cap.isOpened():
            cap.release()
            continue

        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = cap.get(cv2.CAP_PROP_FPS) or 30.0

        name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
        cap.release()

        cameras.append({
            "cv2_index": i,
            "name": name,
            "width": width,
            "height": height,
            "fps": fps,
        })
        sequential_idx += 1

    _camera_cache = cameras
    _camera_cache_time = now
    logger.debug(f"Camera enumeration: found {len(cameras)} camera(s) (probed {max_probe} indices)")
    return cameras


# ---------------------------------------------------------------------------
# CaptureStream
# ---------------------------------------------------------------------------

class CameraCaptureStream(CaptureStream):
    """OpenCV-based webcam capture stream.

    Synchronous capture like MSSEngine — ``capture_frame()`` grabs
    one frame per call. OpenCV's internal buffering keeps latency low.
    """

    def __init__(self, display_index: int, config: Dict[str, Any]):
        super().__init__(display_index, config)
        self._cap = None
        self._cv2_index: Optional[int] = None

    def initialize(self) -> None:
        if self._initialized:
            return

        import cv2

        backend_name = self.config.get("camera_backend", _get_default_backend())

        # Enumerate to resolve display_index → actual cv2 camera index
        cameras = _enumerate_cameras(backend_name)
        if not cameras:
            raise RuntimeError(
                "No cameras found. Ensure a webcam is connected."
            )
        if self.display_index >= len(cameras):
            raise RuntimeError(
                f"Camera index {self.display_index} out of range "
                f"(found {len(cameras)} camera(s))"
            )

        camera = cameras[self.display_index]
        cv2_index = camera["cv2_index"]

        # Prevent concurrent opens of the same physical camera (crashes DSHOW)
        with _camera_lock:
            if cv2_index in _active_cv2_indices:
                raise RuntimeError(
                    f"Camera {self.display_index} (cv2 index {cv2_index}) "
                    f"is already in use by another stream"
                )
            _active_cv2_indices.add(cv2_index)

        try:
            # Open the camera
            backend_id = _cv2_backend_id(backend_name)
            if backend_id is not None:
                self._cap = cv2.VideoCapture(cv2_index, backend_id)
            else:
                self._cap = cv2.VideoCapture(cv2_index)

            if not self._cap.isOpened():
                raise RuntimeError(
                    f"Failed to open camera {self.display_index} "
                    f"(cv2 index {cv2_index})"
                )
        except Exception:
            with _camera_lock:
                _active_cv2_indices.discard(cv2_index)
            raise

        self._cv2_index = cv2_index

        # Apply optional resolution override
        res_w = self.config.get("resolution_width", 0)
        res_h = self.config.get("resolution_height", 0)
        if res_w > 0 and res_h > 0:
            self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
            self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)

        # Test read
        ret, frame = self._cap.read()
        if not ret or frame is None:
            self._cap.release()
            self._cap = None
            with _camera_lock:
                _active_cv2_indices.discard(cv2_index)
            self._cv2_index = None
            raise RuntimeError(
                f"Camera {self.display_index} opened but test read failed"
            )

        h, w = frame.shape[:2]
        self._initialized = True
        logger.info(
            f"Camera capture stream initialized "
            f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})"
        )

    def capture_frame(self) -> Optional[ScreenCapture]:
        if not self._initialized:
            self.initialize()

        import cv2

        ret, frame = self._cap.read()
        if not ret or frame is None:
            return None

        # OpenCV captures BGR; convert to RGB
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        h, w = rgb.shape[:2]

        return ScreenCapture(
            image=rgb,
            width=w,
            height=h,
            display_index=self.display_index,
        )

    def cleanup(self) -> None:
        if self._cap is not None:
            self._cap.release()
            self._cap = None
        if self._cv2_index is not None:
            with _camera_lock:
                _active_cv2_indices.discard(self._cv2_index)
            self._cv2_index = None
        self._initialized = False
        logger.info(f"Camera capture stream cleaned up (display={self.display_index})")


# ---------------------------------------------------------------------------
# CaptureEngine
# ---------------------------------------------------------------------------

class CameraEngine(CaptureEngine):
    """Camera/webcam capture engine using OpenCV.

    Captures frames from USB or integrated webcams.
    Each connected camera appears as a selectable "display".

    Prerequisites:
        pip install opencv-python-headless>=4.8.0
    """

    ENGINE_TYPE = "camera"
    ENGINE_PRIORITY = 0
    HAS_OWN_DISPLAYS = True

    @classmethod
    def is_available(cls) -> bool:
        try:
            import cv2  # noqa: F401
            return True
        except ImportError as e:
            logger.debug("Camera engine unavailable (cv2 not installed): %s", e)
            return False

    @classmethod
    def get_default_config(cls) -> Dict[str, Any]:
        return {
            "camera_backend": _get_default_backend(),
            "resolution_width": 0,
            "resolution_height": 0,
        }

    @classmethod
    def get_available_displays(cls) -> List[DisplayInfo]:
        backend = _get_default_backend()
        cameras = _enumerate_cameras(backend)

        displays = []
        for idx, cam in enumerate(cameras):
            displays.append(DisplayInfo(
                index=idx,
                name=cam["name"],
                width=cam["width"],
                height=cam["height"],
                x=idx * 500,
                y=0,
                is_primary=(idx == 0),
                refresh_rate=int(cam["fps"]),
            ))

        logger.debug(f"Camera engine detected {len(displays)} camera(s)")
        return displays

    @classmethod
    def create_stream(
        cls, display_index: int, config: Dict[str, Any]
    ) -> CameraCaptureStream:
        return CameraCaptureStream(display_index, config)
