"""Petrol-Gauntlet "fast-win v3" — WBF ensemble of two complementary detectors.

Models (byte-distinct trainings, both 1280×1280 input):
  • havengo.onnx       — raw YOLOv8 head [1, 8, 33600];  high m50 (~0.901), lower fp
  • superbit.onnx      — NMS-baked head [1, 300, 6];     lower m50, higher fp (~0.944)

Pipeline:
  letterbox (single shared preprocess) → run both sessions → decode each →
  Weighted Boxes Fusion (Solovyev et al. 2021) per class → final boxes.
"""
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


# ───────────────────────────────────────────────────────────────────
@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"]


# ───────────────────────────────────────────────────────────────────
INPUT_SIZE = 1280
CONF_THRES = 0.50
WBF_IOU = 0.55
# Per-model weight: SuperBitDev has the higher fp pillar at top-of-board, so
# it gets a small bias to push the ensemble toward fewer false positives.
MODEL_WEIGHTS = {"havengo": 0.45, "superbit": 0.55}
MAX_DET = 300


class Miner:
    def __init__(self, project_root: Path):
        self.project_root = Path(project_root)
        self.class_names = CLASS_NAMES
        self.conf_thres = CONF_THRES
        self.max_det = MAX_DET

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

        weights_dir = self.project_root / "weights"
        self.sessions: dict[str, "ort.InferenceSession"] = {}
        for name in ("havengo", "superbit"):
            path = weights_dir / f"{name}.onnx"
            if not path.is_file():
                raise FileNotFoundError(path)
            try:
                s = ort.InferenceSession(
                    str(path), sess_options=sess_options,
                    providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
                )
            except Exception:
                s = ort.InferenceSession(
                    str(path), sess_options=sess_options,
                    providers=["CPUExecutionProvider"],
                )
            self.sessions[name] = s

        self.input_height = INPUT_SIZE
        self.input_width = INPUT_SIZE
        self.input_names = {n: s.get_inputs()[0].name for n, s in self.sessions.items()}
        # Detect output schema once
        self.output_kind: dict[str, str] = {}
        for name, s in self.sessions.items():
            sh = s.get_outputs()[0].shape
            # NMS-baked is shape [1, N, 6]
            self.output_kind[name] = "nms" if (
                isinstance(sh[-1], int) and sh[-1] == 6
            ) else "raw"

    def __repr__(self) -> str:
        return f"PetrolFastWinV3-WBF(models={list(self.sessions)}, weights={MODEL_WEIGHTS})"

    # ── shared preprocessing ─────────────────────────────────────────
    def _letterbox(self, img: ndarray) -> Tuple[ndarray, float, Tuple[float, float]]:
        h, w = img.shape[:2]
        nw = nh = INPUT_SIZE
        ratio = min(nw / w, nh / 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 = (nw - rw) / 2.0
        dh = (nh - 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))
        img = cv2.copyMakeBorder(img, top, bottom, left, right,
                                 cv2.BORDER_CONSTANT, value=(114, 114, 114))
        return img, ratio, (dw, dh)

    def _preprocess(self, image: ndarray):
        orig_h, orig_w = image.shape[:2]
        img, ratio, pad = self._letterbox(image)
        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)

    # ── per-model decoders ───────────────────────────────────────────
    def _decode_raw(self, preds: ndarray, ratio: float, pad, orig_size) -> Tuple[ndarray, ndarray, ndarray]:
        # Shape [1, 4+nc, N] or [1, N, 4+nc]
        preds = preds[0]
        nc = len(self.class_names)
        if preds.shape[0] == 4 + nc:
            preds = preds.T
        boxes_xywh = preds[:, :4].astype(np.float32)
        cls_probs = preds[:, 4:].astype(np.float32)
        cls_ids = np.argmax(cls_probs, axis=1).astype(np.int32)
        scores = cls_probs[np.arange(len(cls_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 np.empty((0, 4)), np.empty(0), np.empty(0, dtype=np.int32)
        boxes = np.empty_like(boxes_xywh)
        boxes[:, 0] = boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2.0
        boxes[:, 1] = boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2.0
        boxes[:, 2] = boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2.0
        boxes[:, 3] = boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2.0
        boxes = self._undo_letterbox(boxes, ratio, pad, orig_size)
        # Per-class hard NMS to get sane sets before WBF
        keep_idx = self._hard_nms_per_class(boxes, scores, cls_ids, 0.5)
        return boxes[keep_idx], scores[keep_idx], cls_ids[keep_idx]

    def _decode_nms(self, preds: ndarray, ratio: float, pad, orig_size) -> Tuple[ndarray, ndarray, ndarray]:
        # Shape [1, N, 6]: x1,y1,x2,y2,conf,cls — already NMS'd (in letterbox coords)
        det = preds[0]
        scores = det[:, 4].astype(np.float32)
        cls_ids = det[:, 5].astype(np.int32)
        boxes = det[:, :4].astype(np.float32)
        keep = scores >= self.conf_thres
        boxes = boxes[keep]; scores = scores[keep]; cls_ids = cls_ids[keep]
        if len(boxes) == 0:
            return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=np.int32)
        boxes = self._undo_letterbox(boxes, ratio, pad, orig_size)
        return boxes, scores, cls_ids

    @staticmethod
    def _undo_letterbox(boxes: ndarray, ratio: float, pad, orig_size) -> ndarray:
        pad_w, pad_h = pad
        orig_w, orig_h = orig_size
        b = boxes.copy()
        b[:, [0, 2]] -= pad_w
        b[:, [1, 3]] -= pad_h
        b /= ratio
        b[:, 0] = np.clip(b[:, 0], 0, orig_w - 1)
        b[:, 1] = np.clip(b[:, 1], 0, orig_h - 1)
        b[:, 2] = np.clip(b[:, 2], 0, orig_w - 1)
        b[:, 3] = np.clip(b[:, 3], 0, orig_h - 1)
        return b

    @staticmethod
    def _hard_nms_per_class(boxes, scores, cls_ids, iou_thresh):
        if len(boxes) == 0:
            return np.array([], dtype=np.intp)
        keep_all = []
        for c in np.unique(cls_ids):
            idxs = np.nonzero(cls_ids == c)[0]
            b = boxes[idxs]; s = scores[idxs]
            order = np.argsort(s)[::-1]
            kept = []
            x1, y1, x2, y2 = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
            areas = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
            while len(order) > 0:
                i = order[0]; kept.append(i)
                if len(order) == 1: break
                rest = order[1:]
                xx1 = np.maximum(x1[i], x1[rest]); yy1 = np.maximum(y1[i], y1[rest])
                xx2 = np.minimum(x2[i], x2[rest]); yy2 = np.minimum(y2[i], y2[rest])
                inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
                iou = inter / (areas[i] + areas[rest] - inter + 1e-7)
                order = rest[iou <= iou_thresh]
            keep_all.extend(idxs[k] for k in kept)
        return np.array(keep_all, dtype=np.intp)

    # ── WBF ──────────────────────────────────────────────────────────
    @staticmethod
    def _wbf_per_class(
        boxes_per_model: List[ndarray], scores_per_model: List[ndarray],
        cls_per_model: List[ndarray], model_weights: List[float],
        iou_thresh: float,
    ) -> Tuple[ndarray, ndarray, ndarray]:
        """Weighted Boxes Fusion — Solovyev et al. 2021.
        Pool boxes from all models tagged with their model id; cluster overlaps
        within a class; fuse coordinates as a confidence-weighted mean."""
        all_boxes, all_scores, all_cls, all_mod = [], [], [], []
        for mi, (b, s, c) in enumerate(zip(boxes_per_model, scores_per_model, cls_per_model)):
            if len(b) == 0:
                continue
            all_boxes.append(b); all_scores.append(s)
            all_cls.append(c); all_mod.append(np.full(len(b), mi, dtype=np.int32))
        if not all_boxes:
            return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=np.int32)
        all_boxes = np.concatenate(all_boxes, axis=0)
        all_scores = np.concatenate(all_scores, axis=0)
        all_cls = np.concatenate(all_cls, axis=0)
        all_mod = np.concatenate(all_mod, axis=0)
        # Apply per-model weights to scores for clustering / fusion
        weighted_scores = all_scores * np.array(model_weights, dtype=np.float32)[all_mod]

        fused_b, fused_s, fused_c = [], [], []
        for c in np.unique(all_cls):
            mask = all_cls == c
            B = all_boxes[mask]; S = weighted_scores[mask]; M = all_mod[mask]
            order = np.argsort(S)[::-1]
            B = B[order]; S = S[order]; M = M[order]
            used = np.zeros(len(B), dtype=bool)
            for i in range(len(B)):
                if used[i]:
                    continue
                cluster = [i]; used[i] = True
                xi1, yi1, xi2, yi2 = B[i]
                area_i = max(0.0, xi2 - xi1) * max(0.0, yi2 - yi1)
                for j in range(i + 1, len(B)):
                    if used[j]:
                        continue
                    xj1, yj1, xj2, yj2 = B[j]
                    xx1, yy1 = max(xi1, xj1), max(yi1, yj1)
                    xx2, yy2 = min(xi2, xj2), min(yi2, yj2)
                    inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
                    area_j = max(0.0, xj2 - xj1) * max(0.0, yj2 - yj1)
                    iou = inter / (area_i + area_j - inter + 1e-9)
                    if iou >= iou_thresh:
                        cluster.append(j); used[j] = True
                # Fuse cluster
                cb = B[cluster]; cs = S[cluster]
                w = cs / cs.sum()
                fb = (cb * w[:, None]).sum(axis=0)
                # Fused confidence: mean score scaled by cluster coverage
                num_models = len(np.unique(M[cluster]))
                total_models = len(model_weights)
                fs = float(cs.mean() * num_models / total_models)
                fused_b.append(fb); fused_s.append(fs); fused_c.append(int(c))

        if not fused_b:
            return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=np.int32)
        return np.array(fused_b), np.array(fused_s), np.array(fused_c, dtype=np.int32)

    # ── inference ────────────────────────────────────────────────────
    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)

        boxes_per_model, scores_per_model, cls_per_model = [], [], []
        for name, sess in self.sessions.items():
            out = sess.run(None, {self.input_names[name]: blob})[0]
            if self.output_kind[name] == "nms":
                b, s, c = self._decode_nms(out, ratio, pad, size)
            else:
                b, s, c = self._decode_raw(out, ratio, pad, size)
            boxes_per_model.append(b); scores_per_model.append(s); cls_per_model.append(c)

        weights = [MODEL_WEIGHTS[n] for n in self.sessions.keys()]
        b, s, c = self._wbf_per_class(
            boxes_per_model, scores_per_model, cls_per_model,
            model_weights=weights, iou_thresh=WBF_IOU,
        )
        if len(b) > self.max_det:
            top = np.argsort(s)[::-1][:self.max_det]
            b, s, c = b[top], s[top], c[top]

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

    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


Detector = Miner
