"""`FeatureExtractor` — shared image → :class:`KeypointSet` helper (AZ-343 scope expansion).

L1 helper analogous to :mod:`gps_denied_onboard.helpers.lightglue_runtime`
and :mod:`gps_denied_onboard.helpers.ransac_filter`. Produces a
:class:`gps_denied_onboard._types.matching.KeypointSet` (the same
DTO that :class:`LightGlueRuntime.match` consumes) from a raw BGR
image.

Why a shared helper:

- C2.5 :class:`InlierCountReRanker` (AZ-343) consumes one
  :class:`FeatureExtractor` instance to extract features from each
  per-frame nav-camera image AND from each candidate tile's JPEG
  bytes. The same instance MUST produce comparable feature sets
  for both inputs — otherwise the LightGlue inlier count would
  collapse to noise.
- A future C3 backbone that wants to share keypoints with C2.5
  (rather than re-extracting them) can read the same handle from
  the composition root, mirroring the
  :class:`LightGlueRuntime` ownership pattern (R14 fix).

Concrete impls:

- :class:`OpenCvOrbExtractor`: CPU, deterministic, placeholder used
  by tests and by the airborne binary until the C7
  :class:`InferenceRuntime`-backed DISK / ALIKED extractor lands.
  ORB returns binary (``uint8``) descriptors of 32 bytes; we
  convert to ``float32`` per the
  :class:`gps_denied_onboard._types.matching.KeypointSet` contract.
- Future: TensorRT-backed DISK / ALIKED extractor; consumes
  :class:`InferenceRuntime` from C7.

This helper is intentionally L1 — it imports only ``numpy`` and
``cv2`` plus the L1 :class:`KeypointSet` DTO. Concrete strategies
that need GPU backbones live in their own modules and accept the
:class:`InferenceRuntime` via constructor injection.
"""

from __future__ import annotations

from typing import Protocol, runtime_checkable

import cv2
import numpy as np

from gps_denied_onboard._types.matching import KeypointSet

__all__ = [
    "FeatureExtractor",
    "FeatureExtractorError",
    "OpenCvOrbExtractor",
]


# ORB descriptors are 32 bytes (256 bits). LightGlue's KeypointSet
# requires float32 descriptors so we widen ORB's uint8 output. This is
# a placeholder choice; production will swap in DISK/ALIKED (128-d
# float32) via the C7 InferenceRuntime path.
_ORB_DESCRIPTOR_BYTES = 32
_ORB_FLOAT_DESCRIPTOR_DIM = _ORB_DESCRIPTOR_BYTES * 8  # 256-d float32


class FeatureExtractorError(RuntimeError):
    """Raised on extractor construction or per-image failure."""


@runtime_checkable
class FeatureExtractor(Protocol):
    """Image → :class:`KeypointSet` Protocol.

    Implementations are constructor-injected by the composition root
    and shared across consumers (e.g., C2.5 :class:`InlierCountReRanker`
    uses one instance for both query frames and tile pixels).

    Invariants:

    - :meth:`extract` returns a :class:`KeypointSet` whose
      ``descriptors.shape[1] == self.descriptor_dim()``.
    - ``keypoints`` is shape ``(N, 2)`` ``float32`` pixel coordinates.
    - ``descriptors`` is shape ``(N, descriptor_dim)`` ``float32``.
    - Empty inputs (zero keypoints detected) return an empty-but-shaped
      :class:`KeypointSet` (``N == 0``) rather than raising — the
      C2.5 strategy treats zero-feature candidates as drop events.
    - Deterministic for fixed inputs (no internal RNG state).
    """

    def extract(self, image_bgr: np.ndarray) -> KeypointSet:
        """Detect keypoints + compute descriptors on a single image."""
        ...

    def descriptor_dim(self) -> int:
        """Return the dim of every descriptor row produced by :meth:`extract`."""
        ...


class OpenCvOrbExtractor:
    """CPU :class:`FeatureExtractor` backed by ``cv2.ORB_create``.

    Placeholder implementation: ORB is fast (~5 ms / 480p image on a
    modern CPU) and stable enough to exercise the C2.5 strategy's
    orchestration logic, but its uint8 binary descriptors are NOT a
    drop-in for LightGlue-trained DISK/ALIKED features. Production
    deployments MUST replace this extractor with a deep-learning
    backbone before flight (tracked under the future C2.5
    backbone-extractor task).

    The ``nfeatures`` constructor arg caps the number of keypoints
    per image; default 1024 mirrors typical DISK / ALIKED budgets.
    """

    def __init__(self, *, nfeatures: int = 1024) -> None:
        if nfeatures < 1:
            raise FeatureExtractorError(
                f"OpenCvOrbExtractor.nfeatures must be >= 1; got {nfeatures}"
            )
        self._nfeatures: int = nfeatures
        # ORB itself is created lazily so test environments without
        # a working OpenCV install can still import this module.
        # Cached on first call to amortise the per-image cost.
        self._orb: cv2.ORB | None = None

    def descriptor_dim(self) -> int:
        return _ORB_FLOAT_DESCRIPTOR_DIM

    def _get_orb(self) -> cv2.ORB:
        if self._orb is None:
            self._orb = cv2.ORB_create(nfeatures=self._nfeatures)
        return self._orb

    def extract(self, image_bgr: np.ndarray) -> KeypointSet:
        if image_bgr.ndim == 3:
            gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        elif image_bgr.ndim == 2:
            gray = image_bgr
        else:
            raise FeatureExtractorError(
                "image_bgr must be 2-D (gray) or 3-D (BGR); "
                f"got ndim={image_bgr.ndim} shape={image_bgr.shape}"
            )
        if gray.dtype != np.uint8:
            gray = gray.astype(np.uint8)
        try:
            keypoints_cv, descriptors_uint8 = self._get_orb().detectAndCompute(
                gray, mask=None
            )
        except cv2.error as exc:
            raise FeatureExtractorError(f"cv2.ORB.detectAndCompute failed: {exc}") from exc
        if descriptors_uint8 is None or len(keypoints_cv) == 0:
            keypoints = np.zeros((0, 2), dtype=np.float32)
            descriptors = np.zeros((0, _ORB_FLOAT_DESCRIPTOR_DIM), dtype=np.float32)
            return KeypointSet(keypoints=keypoints, descriptors=descriptors)
        keypoints = np.array(
            [(kp.pt[0], kp.pt[1]) for kp in keypoints_cv], dtype=np.float32
        )
        # Expand each 32-byte ORB descriptor to a 256-d float32 vector
        # of bit indicators (0/1). Matches the contract that
        # ``KeypointSet.descriptors`` is float32.
        bits = np.unpackbits(descriptors_uint8, axis=1).astype(np.float32)
        return KeypointSet(keypoints=keypoints, descriptors=bits)
