"""Petrol-Gauntlet "trained-v1" — fine-tuned YOLOv8s, drop-in inference.

Weights: trained from COCO YOLOv8s checkpoint on 357 petrol images
(yolo_high_confidence + filtered candidate_gt) for 120 epochs at
1280x1280 with strong mosaic+mixup augmentation. Best val mAP@0.5
during training: 0.782; final val mAP@0.5: 0.7525.

Architecture: YOLOv8s, FP16 ONNX, 22.7 MB (≤ 30 MB rule).
"""
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.30
IOU_THRES = 0.50
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.iou_thres = IOU_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
        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"PetrolTrainedV1(input={self.input_width}x{self.input_height}, conf={self.conf_thres})"

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

    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)

    @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 _hard_nms_per_class(boxes, scores, cls_ids, iou_thresh, max_det):
        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)
        keep_all = np.array(keep_all, dtype=np.intp)
        if len(keep_all) > max_det:
            order = np.argsort(scores[keep_all])[::-1][:max_det]
            keep_all = keep_all[order]
        return keep_all

    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)
        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 []

        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._hard_nms_per_class(boxes, scores, cls_ids, self.iou_thres, 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]
        ]

    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


Detector = Miner
