"""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 os
import platform
import sys
import threading
import time
from typing import Any, Dict, List, Set

# OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when
# hardware MFTs are enabled. Disabling them is the documented mitigation.
# Set before any cv2 import so the MSMF backend picks it up on first use.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")


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

logger = get_logger(__name__)

_MAX_CAMERA_INDEX = 10  # probe indices 0..9

# Sentinel used to ask DShow/MSMF/V4L2 for the highest mode the device supports.
# OpenCV will clamp the requested width/height down to the nearest supported mode.
_PROBE_MAX_DIM = 9999

# Resolution presets shown in the UI. "auto" means: open at the camera's max
# (probed via _PROBE_MAX_DIM); the other entries are explicit overrides.
_RESOLUTION_CHOICES: List[str] = [
    "auto",
    "640x480",
    "1280x720",
    "1920x1080",
    "2560x1440",
    "3840x2160",
]


def _parse_resolution(value: Any) -> tuple[int, int] | None:
    """Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
    if not isinstance(value, str):
        return None
    s = value.strip().lower()
    if s in ("", "auto"):
        return None
    parts = s.replace("×", "x").split("x")
    if len(parts) != 2:
        return None
    try:
        w, h = int(parts[0]), int(parts[1])
    except ValueError:
        return None
    if w <= 0 or h <= 0:
        return None
    return w, h


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


# Maps our backend ids to the label cv2.getBuildInformation() prints in the
# Video I/O section. Entries missing or marked "NO" mean the installed
# opencv wheel was compiled without that backend — even if cv2's registry
# still lists it, attempts to open will fail with isOpened()==False.
_BUILDINFO_LABELS: Dict[str, str] = {
    "dshow": "DirectShow",
    "msmf": "Media Foundation",
    "v4l2": "v4l/v4l2",
    "avfoundation": "AVFoundation",
}

_compiled_backends_cache: Set[str] | None = None


def _get_compiled_backends() -> Set[str]:
    """Return the set of backend ids the installed cv2 was compiled with.

    Parses ``cv2.getBuildInformation()`` because cv2's videoio registry can
    advertise backends that aren't actually functional (e.g. wheels that
    omit Media Foundation still list MSMF in the registry).
    """
    global _compiled_backends_cache
    if _compiled_backends_cache is not None:
        return _compiled_backends_cache

    try:
        import cv2
    except ImportError:
        _compiled_backends_cache = set()
        return _compiled_backends_cache

    info = cv2.getBuildInformation()
    # Restrict the search to the "Video I/O" section so labels like
    # "Media Foundation" don't pick up unrelated mentions elsewhere.
    start = info.find("Video I/O:")
    section = info[start:] if start != -1 else info
    end_markers = ("Parallel framework", "Trace:", "Other third-party libraries")
    for marker in end_markers:
        idx = section.find(marker)
        if idx != -1:
            section = section[:idx]
            break

    found: Set[str] = set()
    for backend, label in _BUILDINFO_LABELS.items():
        # Match "<label>: <whitespace>YES" anywhere in the section.
        needle = label + ":"
        pos = section.find(needle)
        if pos == -1:
            continue
        line_end = section.find("\n", pos)
        line = section[pos : line_end if line_end != -1 else len(section)]
        if "YES" in line.upper():
            found.add(backend)

    _compiled_backends_cache = found
    return found


def _get_supported_backends() -> List[str]:
    """Return the list of cv2 backends that make sense on this platform.

    Only advertises backends that are both (a) appropriate for the host OS
    and (b) actually compiled into the installed opencv wheel. ``auto`` is
    always offered as a safe default.
    """
    if sys.platform == "win32":
        candidates = ["dshow", "msmf"]
    elif sys.platform.startswith("linux"):
        candidates = ["v4l2"]
    elif sys.platform == "darwin":
        candidates = ["avfoundation"]
    else:
        candidates = []

    compiled = _get_compiled_backends()
    return ["auto", *(b for b in candidates if b in compiled)]


def _cv2_backend_id(backend_name: str) -> int | None:
    """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: List[Dict[str, Any]] | None = 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

        # Probe the camera's max supported mode by asking for an absurdly large
        # frame size — DShow/MSMF/V4L2 clamp down to the highest available mode.
        # If the probe is rejected (rare driver issue), fall back to the default
        # mode that the camera reports immediately after open.
        default_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        default_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        try:
            cap.set(cv2.CAP_PROP_FRAME_WIDTH, _PROBE_MAX_DIM)
            cap.set(cv2.CAP_PROP_FRAME_HEIGHT, _PROBE_MAX_DIM)
            width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or default_width
            height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or default_height
        except Exception as e:
            logger.debug(f"Camera {i} max-resolution probe failed: {e}")
            width, height = default_width, default_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: int | None = 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. MSMF's first open after a DShow session (or its
            # very first cold open in the process) is timing-sensitive on
            # Windows, so retry briefly before giving up.
            backend_id = _cv2_backend_id(backend_name)
            attempts = 3 if backend_name == "msmf" else 1
            self._cap = None
            for attempt in range(attempts):
                if backend_id is not None:
                    cap = cv2.VideoCapture(cv2_index, backend_id)
                else:
                    cap = cv2.VideoCapture(cv2_index)
                if cap.isOpened():
                    self._cap = cap
                    break
                cap.release()
                if attempt + 1 < attempts:
                    time.sleep(0.5)

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

        self._cv2_index = cv2_index

        # Resolve effective resolution.
        # Priority: legacy `resolution_width`/`resolution_height` (if both > 0)
        #         → new `resolution` enum string (e.g. "1920x1080" or "auto")
        #         → "auto" (open at the camera's max).
        # On Windows DShow/MSMF the default opening mode is typically 640x480
        # regardless of the camera's hardware ceiling, so when no explicit
        # override is given we ask for the highest mode the device supports
        # by setting an absurdly large frame size — drivers clamp down to the
        # nearest supported mode.
        legacy_w = self.config.get("resolution_width", 0) or 0
        legacy_h = self.config.get("resolution_height", 0) or 0
        if legacy_w > 0 and legacy_h > 0:
            target_w, target_h = legacy_w, legacy_h
        else:
            parsed = _parse_resolution(self.config.get("resolution", "auto"))
            if parsed is not None:
                target_w, target_h = parsed
            else:
                target_w, target_h = _PROBE_MAX_DIM, _PROBE_MAX_DIM

        self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
        self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_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) -> ScreenCapture | None:
        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]:
        # `resolution` is the user-facing control. Legacy numeric overrides
        # `resolution_width`/`resolution_height` are still honored if present
        # in stored configs (see CameraCaptureStream.initialize), but are no
        # longer surfaced in the default config — the dropdown replaces them.
        return {
            "camera_backend": _get_default_backend(),
            "resolution": "auto",
        }

    @classmethod
    def get_config_choices(cls) -> Dict[str, List[str]]:
        return {
            "camera_backend": _get_supported_backends(),
            "resolution": list(_RESOLUTION_CHOICES),
        }

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