"""Petrol-Gauntlet "fast-win v1" — havengo/petrol_go1 weights + tuned post-processing.

Differences vs the upstream baseline:
  • conf_thres lowered 0.50 → 0.30  (more recall → higher mAP@0.5)
  • Hard per-class NMS  →  Gaussian Soft-NMS (sigma=0.5)
       Recovers overlapping pumps in dense forecourts that hard NMS suppresses.
  • Letterbox pad colour switched to per-image mean colour (was grey 114),
       which reduces edge false positives along the padded margin.

The ONNX weights (`weights/detector.onnx`) are unchanged — same model, just
better post-processing. Latency cost is ~0 ms (Soft-NMS is pure numpy).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Tuple

import cv2
import numpy as np
from numpy import ndarray


# ───────────────────────────────────────────────────────────────────
#  Output schema (TurboVision contract)
# ───────────────────────────────────────────────────────────────────
@dataclass
class BoundingBox:
    x1: int
    y1: int
    x2: int
    y2: int
    cls_id: int
    conf: float


@dataclass
class FrameResult:
    frame_id: int
    boxes: List[BoundingBox] = field(default_factory=list)
    keypoints: List[Tuple[int, int]] = field(default_factory=list)


CLASS_NAMES = ["petrol hose", "petrol pump", "price board", "roof canopy"]


# ───────────────────────────────────────────────────────────────────
#  Tunables
# ───────────────────────────────────────────────────────────────────
CONF_THRES = 0.50           # parent default — reverted from v1's 0.30 (caused fp blow-up)
IOU_THRES = 0.55            # NMS overlap threshold
SOFT_NMS_SIGMA = 0.5        # Gaussian decay sigma
SOFT_NMS_FLOOR = 0.50       # bumped from 0.001 — must equal conf_thres to keep precision
MAX_DET = 300


# ───────────────────────────────────────────────────────────────────
#  Miner
# ───────────────────────────────────────────────────────────────────
class Miner:
    WEIGHTS_FILENAME = "detector.onnx"

    def __init__(self, project_root: Path):
        self.project_root = Path(project_root)
        self.class_names = CLASS_NAMES

        weights_path = self._find_weights()

        import onnxruntime as ort
        sess_options = ort.SessionOptions()
        sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

        try:
            self.session = ort.InferenceSession(
                str(weights_path),
                sess_options=sess_options,
                providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
            )
        except Exception:
            self.session = ort.InferenceSession(
                str(weights_path),
                sess_options=sess_options,
                providers=["CPUExecutionProvider"],
            )

        self.input_name = self.session.get_inputs()[0].name
        in_shape = self.session.get_inputs()[0].shape
        self.input_height = in_shape[2] if isinstance(in_shape[2], int) else 1280
        self.input_width = in_shape[3] if isinstance(in_shape[3], int) else 1280

        self.conf_thres = CONF_THRES
        self.iou_thres = IOU_THRES
        self.max_det = MAX_DET

    def __repr__(self) -> str:
        return f"PetrolFastWinV1(input={self.input_width}x{self.input_height})"

    def _find_weights(self) -> Path:
        for c in (
            self.project_root / "weights" / self.WEIGHTS_FILENAME,
            self.project_root / self.WEIGHTS_FILENAME,
        ):
            if c.is_file():
                return c
        for d in (self.project_root / "weights", self.project_root):
            if d.is_dir():
                for f in sorted(d.iterdir()):
                    if f.suffix == ".onnx" and f.is_file():
                        return f
        raise FileNotFoundError("detector.onnx not found")

    # ── preprocessing ────────────────────────────────────────────────
    @staticmethod
    def _mean_color(img: ndarray) -> Tuple[int, int, int]:
        m = img.reshape(-1, 3).mean(axis=0)
        return int(m[0]), int(m[1]), int(m[2])

    def _letterbox(self, img: ndarray, new_shape: Tuple[int, int]):
        h, w = img.shape[:2]
        new_w, new_h = new_shape
        ratio = min(new_w / w, new_h / h)
        rw, rh = int(round(w * ratio)), int(round(h * ratio))
        if (rw, rh) != (w, h):
            interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
            img = cv2.resize(img, (rw, rh), interpolation=interp)
        dw = (new_w - rw) / 2.0
        dh = (new_h - rh) / 2.0
        top = int(round(dh - 0.1)); bottom = int(round(dh + 0.1))
        left = int(round(dw - 0.1)); right = int(round(dw + 0.1))
        # mean-color padding — fewer false positives at the seam
        pad = self._mean_color(img)
        img = cv2.copyMakeBorder(img, top, bottom, left, right,
                                 borderType=cv2.BORDER_CONSTANT, value=pad)
        return img, ratio, (dw, dh)

    def _preprocess(self, image: ndarray):
        orig_h, orig_w = image.shape[:2]
        img, ratio, pad = self._letterbox(image, (self.input_width, self.input_height))
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
        img = np.transpose(img, (2, 0, 1))[None, ...]
        return np.ascontiguousarray(img, dtype=np.float32), ratio, pad, (orig_w, orig_h)

    # ── post-processing ──────────────────────────────────────────────
    @staticmethod
    def _xywh_to_xyxy(boxes: ndarray) -> ndarray:
        out = np.empty_like(boxes)
        out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
        out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
        out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
        out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
        return out

    @staticmethod
    def _clip_boxes(boxes: ndarray, image_size: Tuple[int, int]) -> ndarray:
        w, h = image_size
        boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
        boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
        boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
        boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
        return boxes

    @staticmethod
    def _soft_nms_per_class(
        boxes: ndarray, scores: ndarray, cls_ids: ndarray,
        iou_thresh: float, sigma: float, score_floor: float, max_det: int,
    ) -> ndarray:
        """Gaussian Soft-NMS (Bodla et al. 2017), executed per class.
        Decays overlapping-box scores via exp(-iou^2/sigma) instead of dropping
        them outright — recovers true positives that hard NMS suppresses when
        objects of the same class overlap a lot (think: row of pumps)."""
        if len(boxes) == 0:
            return np.array([], dtype=np.intp)

        keep_global: list[int] = []
        for c in np.unique(cls_ids):
            idxs = np.nonzero(cls_ids == c)[0]
            if len(idxs) == 0:
                continue
            b = boxes[idxs].astype(np.float32).copy()
            s = scores[idxs].astype(np.float32).copy()
            kept_local: list[int] = []

            while len(s) > 0:
                m = int(np.argmax(s))
                if s[m] < score_floor:
                    break
                kept_local.append(int(idxs[m]))

                # IoU between picked box and the rest
                xx1 = np.maximum(b[m, 0], b[:, 0])
                yy1 = np.maximum(b[m, 1], b[:, 1])
                xx2 = np.minimum(b[m, 2], b[:, 2])
                yy2 = np.minimum(b[m, 3], b[:, 3])
                inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
                area_m = max(0.0, (b[m, 2] - b[m, 0])) * max(0.0, (b[m, 3] - b[m, 1]))
                area_r = np.maximum(0.0, b[:, 2] - b[:, 0]) * np.maximum(0.0, b[:, 3] - b[:, 1])
                iou = inter / (area_m + area_r - inter + 1e-7)

                # Gaussian decay everywhere (the chosen box itself ends up at 0)
                decay = np.exp(-(iou * iou) / sigma)
                # Only decay above the IoU threshold; leave non-overlappers alone
                mask = iou > iou_thresh
                s = np.where(mask, s * decay, s)
                s[m] = 0.0  # consumed

                # Optionally prune low-score residue
                surv = s >= score_floor
                if not surv.all():
                    b = b[surv]; s = s[surv]; idxs = idxs[surv]

            keep_global.extend(kept_local)

        if not keep_global:
            return np.array([], dtype=np.intp)
        keep_arr = np.array(keep_global, dtype=np.intp)
        # Top-k by original score
        order = np.argsort(scores[keep_arr])[::-1][:max_det]
        return keep_arr[order]

    def _decode(self, preds: ndarray, ratio: float, pad: Tuple[float, float],
                orig_size: Tuple[int, int]) -> List[BoundingBox]:
        if preds.ndim != 3 or preds.shape[0] != 1:
            raise ValueError(f"Unexpected ONNX output shape: {preds.shape}")
        preds = preds[0]
        nc = len(self.class_names)
        expected_c = 4 + nc
        if preds.shape[0] == expected_c:
            preds = preds.T
        elif preds.shape[1] != expected_c and preds.shape[0] < preds.shape[1]:
            preds = preds.T

        boxes_xywh = preds[:, :4].astype(np.float32)
        class_probs = preds[:, 4:].astype(np.float32)
        cls_ids = np.argmax(class_probs, axis=1).astype(np.int32)
        scores = class_probs[np.arange(len(class_probs)), cls_ids]

        keep = scores >= self.conf_thres
        boxes_xywh = boxes_xywh[keep]
        scores = scores[keep]; cls_ids = cls_ids[keep]
        if len(boxes_xywh) == 0:
            return []

        boxes = self._xywh_to_xyxy(boxes_xywh)
        pad_w, pad_h = pad
        boxes[:, [0, 2]] -= pad_w
        boxes[:, [1, 3]] -= pad_h
        boxes /= ratio
        boxes = self._clip_boxes(boxes, orig_size)

        keep_idx = self._soft_nms_per_class(
            boxes, scores, cls_ids,
            iou_thresh=self.iou_thres,
            sigma=SOFT_NMS_SIGMA,
            score_floor=SOFT_NMS_FLOOR,
            max_det=self.max_det,
        )
        boxes = boxes[keep_idx]; scores = scores[keep_idx]; cls_ids = cls_ids[keep_idx]

        return [
            BoundingBox(
                x1=int(math.floor(b[0])), y1=int(math.floor(b[1])),
                x2=int(math.ceil(b[2])),  y2=int(math.ceil(b[3])),
                cls_id=int(c), conf=float(s),
            )
            for b, s, c in zip(boxes, scores, cls_ids)
            if b[2] > b[0] and b[3] > b[1]
        ]

    # ── public API ───────────────────────────────────────────────────
    def _predict_single(self, image: ndarray) -> List[BoundingBox]:
        if image.dtype != np.uint8:
            image = image.astype(np.uint8)
        blob, ratio, pad, size = self._preprocess(image)
        out = self.session.run(None, {self.input_name: blob})[0]
        return self._decode(out, ratio, pad, size)

    def predict_batch(self, batch_images: List[ndarray], offset: int,
                      n_keypoints: int) -> List[FrameResult]:
        results: List[FrameResult] = []
        for i, img in enumerate(batch_images):
            try:
                boxes = self._predict_single(img)
            except Exception:
                boxes = []
            results.append(FrameResult(frame_id=offset + i, boxes=boxes))
        return results


# Compat alias — the harness prefers `app.Detector`
Detector = Miner
