"""舌の検出（DeepTIAS 2.7 の `Detection_Normal.pb` を ONNX に変換したもの）と枠の扱い.

DeepTIAS 2.7 は TF1 の物体検出 API の frozen graph を使う．入力 `image_tensor`（`(1, H, W, 3)`
の uint8 画像），出力 `detection_boxes`（`(1, N, 4)`．正規化 `[ymin, xmin, ymax, xmax]`）と
`detection_scores`（`(1, N)`）．スコアが 0.5 を超える中で最高の 1 つを採用し，その枠で切り出す．
ONNX への変換は `scripts/convert_detection_to_onnx.py`（tf2onnx は出力名に `:0` を付けるため，
名前は `:0` の有無を問わずに探す）．

コピー元（TIASjudge Issue #13．ロジックは改変しない）: TIASJudgeModel リポジトリの
`src/tias_judge_model/segmentation/detection.py`
（コミット 95cd9a2e18f3dabfc06718a8577666fa9c6ca106．本ファイルの最終変更は
6d332cfa1e637a4a824aada9972f7c6812f72acb）．
内容はそのまま（本注記の追加のみ）．
引き渡し仕様は TIASJudgeModel の `docs/04_SPEC/SPEC_04_TIASjudge引き渡し仕様.md`．
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path

import cv2
import numpy as np
import onnxruntime as ort


@dataclass(frozen=True)
class Rect:
    """画像上の矩形（画素．`x1`・`y1` は含まない）.

    Attributes:
        x0: 左端．
        y0: 上端．
        x1: 右端 + 1．
        y1: 下端 + 1．
    """

    x0: int
    y0: int
    x1: int
    y1: int

    @property
    def width(self) -> int:
        """幅."""
        return self.x1 - self.x0

    @property
    def height(self) -> int:
        """高さ."""
        return self.y1 - self.y0


def full_rect(height: int, width: int) -> Rect:
    """画像全体の矩形を返す.

    Args:
        height: 画像の高さ．
        width: 画像の幅．

    Returns:
        画像全体の矩形．
    """
    return Rect(0, 0, width, height)


def mask_bounding_rect(mask: np.ndarray) -> Rect | None:
    """マスクの前景の外接矩形を返す.

    Args:
        mask: `(H, W)` の bool．

    Returns:
        外接矩形．前景が無ければ None．
    """
    ys, xs = np.nonzero(mask)
    if ys.size == 0:
        return None
    return Rect(int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1)


def normalized_box_to_rect(box: Sequence[float], height: int, width: int) -> Rect | None:
    """正規化 `[ymin, xmin, ymax, xmax]` の枠を画素の矩形にする（画像の内側に収める）.

    画素への変換は切り捨て（C# の `(int)` のキャストと同じ）．

    Args:
        box: 正規化座標の枠（値域 0〜1）．
        height: 画像の高さ．
        width: 画像の幅．

    Returns:
        矩形．幅または高さが 0 になる場合は None．
    """
    ymin, xmin, ymax, xmax = (float(v) for v in box)
    x0 = min(max(int(xmin * width), 0), width)
    y0 = min(max(int(ymin * height), 0), height)
    x1 = min(max(int(xmax * width), 0), width)
    y1 = min(max(int(ymax * height), 0), height)
    if x1 <= x0 or y1 <= y0:
        return None
    return Rect(x0, y0, x1, y1)


def select_best_box(
    boxes: np.ndarray, scores: np.ndarray, threshold: float
) -> tuple[np.ndarray, float] | None:
    """スコアが `threshold` を超える中で最高の枠を選ぶ.

    Args:
        boxes: `(N, 4)` の float（正規化 `[ymin, xmin, ymax, xmax]`）．
        scores: `(N,)` の float．
        threshold: スコアの下限（これを超えるもののみ）．

    Returns:
        （枠 `(4,)`, スコア）．該当が無ければ None．
    """
    scores = np.asarray(scores, dtype=np.float64).reshape(-1)
    boxes = np.asarray(boxes, dtype=np.float64).reshape(-1, 4)
    if scores.size == 0:
        return None
    best = int(np.argmax(scores))
    if not scores[best] > threshold:
        return None
    return boxes[best], float(scores[best])


def _resolve_name(available: Sequence[str], wanted: str, what: str) -> str:
    for name in (wanted, f"{wanted}:0"):
        if name in available:
            return name
    raise ValueError(f"検出モデルに{what} {wanted!r} が無い（あるもの: {', '.join(available)}）")


@dataclass(frozen=True)
class Detection:
    """検出の結果.

    Attributes:
        rect: 採用した枠（画素）．検出できなければ None．
        score: 最高のスコア（検出できなくても記録する．出力が空なら None）．
    """

    rect: Rect | None
    score: float | None


class OnnxDetector:
    """検出モデルの ONNX を onnxruntime（CPU）で推論する薄いラッパ.

    Args:
        path: 検出モデルの ONNX のパス．
        score_threshold: 採用するスコアの下限．
        input_color: モデルへ渡す画像の色の順（`rgb`・`bgr`）．
        input_name: 入力名（`:0` の有無は問わない）．
        boxes_name: 枠の出力名．
        scores_name: スコアの出力名．

    Raises:
        ValueError: ファイルが無い，または入出力名が見つからない場合．
    """

    def __init__(
        self,
        path: Path,
        *,
        score_threshold: float,
        input_color: str,
        input_name: str,
        boxes_name: str,
        scores_name: str,
    ) -> None:
        if not path.is_file():
            raise ValueError(f"検出モデルの ONNX が無い: {path}")
        self._session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
        inputs = [i.name for i in self._session.get_inputs()]
        outputs = [o.name for o in self._session.get_outputs()]
        self._input = _resolve_name(inputs, input_name, "入力")
        self._boxes = _resolve_name(outputs, boxes_name, "出力")
        self._scores = _resolve_name(outputs, scores_name, "出力")
        self._threshold = score_threshold
        self._rgb = input_color == "rgb"

    def detect(self, bgr: np.ndarray) -> Detection:
        """撮影画像から舌の枠を 1 つ検出する.

        Args:
            bgr: BGR 画像（`(H, W, 3)`, uint8．OpenCV で読んだまま）．

        Returns:
            検出の結果．
        """
        image = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) if self._rgb else bgr
        boxes, scores = self._session.run(
            [self._boxes, self._scores], {self._input: np.ascontiguousarray(image)[None]}
        )
        scores = np.asarray(scores).reshape(-1)
        top = float(scores.max()) if scores.size else None
        picked = select_best_box(boxes, scores, self._threshold)
        if picked is None:
            return Detection(rect=None, score=top)
        height, width = bgr.shape[:2]
        return Detection(rect=normalized_box_to_rect(picked[0], height, width), score=picked[1])
