"""Petrol-Gauntlet "fast-win v7" — single SuperBitDev/petrol2 + TTA-flip + agreement gate.

Same agreement-gate idea that drove v5's +12% composite, but the "second model" is
the same model run on a horizontally-flipped image. Boxes that survive in BOTH the
original and the flipped pass (within IoU 0.55 after un-flipping) are kept; singletons
are dropped — exactly like v5, just with self-augmentation instead of cross-model.

Repo size: one 19.4 MB ONNX → fits the 30 MB element rule (havengo+SuperBit at 42 MB doesn't).
"""
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
MAX_DET = 300


class Miner:
    WEIGHTS_FILENAME = "detector.onnx"

    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

        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
        sh = self.session.get_outputs()[0].shape
        self.output_kind = "nms" if (isinstance(sh[-1], int) and sh[-1] == 6) else "raw"

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

    def __repr__(self) -> str:
        return f"PetrolFastWinV7(model=SuperBitDev/petrol2, TTA=hflip, agreement-gate)"

    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 ────────────────────────────────────────────────
    def _letterbox(self, img: ndarray):
        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, flip: bool):
        orig_h, orig_w = image.shape[:2]
        img = cv2.flip(image, 1) if flip else image
        img, ratio, pad = self._letterbox(img)
        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)

    # ── decoders ─────────────────────────────────────────────────────
    def _decode_raw(self, preds, ratio, pad, orig_size):
        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)
        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, ratio, pad, orig_size):
        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, ratio, pad, orig_size):
        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 _unflip_boxes(boxes: ndarray, orig_w: int) -> ndarray:
        """Undo horizontal flip — swap x coords across image width."""
        if len(boxes) == 0:
            return boxes
        b = boxes.copy()
        x1 = orig_w - 1 - b[:, 2]
        x2 = orig_w - 1 - b[:, 0]
        b[:, 0] = x1; b[:, 2] = x2
        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 with agreement gate (same as v5) ─────────────────────────
    @staticmethod
    def _wbf_agreement(
        boxes_a: ndarray, scores_a: ndarray, cls_a: ndarray,
        boxes_b: ndarray, scores_b: ndarray, cls_b: ndarray,
        iou_thresh: float,
    ) -> Tuple[ndarray, ndarray, ndarray]:
        """Per-class WBF over two passes; emit only where both passes agree."""
        all_b = np.concatenate([boxes_a, boxes_b], axis=0) if len(boxes_a) or len(boxes_b) else np.empty((0, 4))
        all_s = np.concatenate([scores_a, scores_b]) if len(boxes_a) or len(boxes_b) else np.empty(0)
        all_c = np.concatenate([cls_a, cls_b]) if len(boxes_a) or len(boxes_b) else np.empty(0, dtype=np.int32)
        all_p = np.concatenate([np.zeros(len(boxes_a), dtype=np.int32),
                                np.ones(len(boxes_b),  dtype=np.int32)]) if len(boxes_a) or len(boxes_b) else np.empty(0, dtype=np.int32)
        if len(all_b) == 0:
            return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=np.int32)

        fused_b, fused_s, fused_c = [], [], []
        for c in np.unique(all_c):
            mask = all_c == c
            B = all_b[mask]; S = all_s[mask]; P = all_p[mask]
            order = np.argsort(S)[::-1]
            B = B[order]; S = S[order]; P = P[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
                # Agreement gate: BOTH passes must have contributed
                num_passes = len(np.unique(P[cluster]))
                if num_passes < 2:
                    continue
                cb = B[cluster]; cs = S[cluster]
                w = cs / cs.sum()
                fb = (cb * w[:, None]).sum(axis=0)
                fs = float(cs.mean())
                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)
        orig_w = image.shape[1]

        # Pass 1 — original
        blob, ratio, pad, size = self._preprocess(image, flip=False)
        out = self.session.run(None, {self.input_name: blob})[0]
        if self.output_kind == "nms":
            b1, s1, c1 = self._decode_nms(out, ratio, pad, size)
        else:
            b1, s1, c1 = self._decode_raw(out, ratio, pad, size)

        # Pass 2 — horizontally flipped
        blob, ratio, pad, size = self._preprocess(image, flip=True)
        out = self.session.run(None, {self.input_name: blob})[0]
        if self.output_kind == "nms":
            b2, s2, c2 = self._decode_nms(out, ratio, pad, size)
        else:
            b2, s2, c2 = self._decode_raw(out, ratio, pad, size)
        b2 = self._unflip_boxes(b2, orig_w)

        b, s, c = self._wbf_agreement(b1, s1, c1, b2, s2, c2, 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
