# ============================================================
# trackers_hybrid.py  (v2 — affine motion for maneuvering targets)
#
# Ключевые отличия от v1:
#   1. KLT путь оценивает ПОЛНУЮ аффинку (translation + rotation
#      + scale) через cv2.estimateAffinePartial2D, а не только
#      медиану сдвигов. Это необходимо когда цель поворачивается
#      в кадре — при rigid translation inlier-точки разлетаются
#      и квалити падает.
#   2. Применение аффинки к центру и к размеру bbox с clamp'ом
#      на изменение масштаба за кадр (защита от RANSAC-сбоев).
#   3. Более частая переинициализация features (8 вместо 15)
#      для быстрого подстраивания под вращающуюся цель.
#   4. Phase correlation теперь работает на НЕСКОЛЬКИХ масштабах
#      шаблона (0.9x, 1.0x, 1.1x) чтобы устойчиво ловить цель при
#      приближении/удалении.
# ============================================================

import cv2
import numpy as np

from config import *
from helpers import clamp


# ─── Параметры ───────────────────────────────────────────────
HYBRID_CLAHE_CLIP         = 2.5
HYBRID_CLAHE_TILE         = 4
HYBRID_KLT_QUALITY        = 0.005
HYBRID_KLT_QUALITY_RETRY  = 0.002
HYBRID_KLT_MIN_DIST       = 2
HYBRID_KLT_MAX_CORNERS    = 50
HYBRID_KLT_MIN_INLIERS    = 3
HYBRID_KLT_MIN_FOR_AFFINE = 6        # меньше — fallback на median translation
HYBRID_KLT_OUTLIER_THR    = 3.5
HYBRID_PAD_FACTOR         = 0.25
HYBRID_SEARCH_FACTOR      = 2.4      # чуть больше search window для манёвров

HYBRID_PHASE_MIN_RESP     = 0.22     # чуть ослаблен
HYBRID_TM_MIN_SCORE       = 0.42
HYBRID_TM_SCALES          = (0.9, 1.0, 1.1)

# Защита от RANSAC-сбоев (sanity check, размер bbox НЕ обновляется)
HYBRID_MAX_SCALE_PER_FRAME = 1.25    # допустимый диапазон нормы аффинки
HYBRID_MIN_SCALE_PER_FRAME = 0.80    # иначе считаем RANSAC сбоем
HYBRID_MAX_TRANSLATION_PX  = 80.0    # максимальный сдвиг центра за кадр

HYBRID_TEMPLATE_UPDATE_EMA = 0.80    # чуть быстрее обновлять (было 0.85)
HYBRID_REINIT_FEATURES_EVERY = 8     # было 15 — для динамичной цели


class HybridTracker:
    """
    Многоуровневый трекер для мелких манёвренных целей.

    API:
        trk = HybridTracker()
        trk.init(frame_bgr, box)
        new_box = trk.update(frame_bgr)   # None если потеряли
    Поля:
        self.box           — текущий bbox
        self.quality       — 0..1
        self.last_method   — "klt-affine" / "klt-translate" / "phasecorr" / "template" / "lost"
        self.good_count    — число inliers KLT на последнем кадре
    """

    def __init__(self):
        self._clahe = cv2.createCLAHE(
            clipLimit=float(HYBRID_CLAHE_CLIP),
            tileGridSize=(int(HYBRID_CLAHE_TILE), int(HYBRID_CLAHE_TILE)),
        )
        self.reset()

    # ─── Public API ──────────────────────────────────────────
    def reset(self):
        self.prev_gray = None
        self.prev_gray_eq = None
        self.pts = None
        self.box = None
        self.template = None
        self.good_count = 0
        self.quality = 0.0
        self.last_method = "none"
        self._frames_since_feature_init = 0
        self._success_streak = 0

    def init(self, frame_bgr, box):
        if frame_bgr is None or box is None:
            return
        gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
        gray_eq = self._clahe.apply(gray)

        self.box = np.array(box, dtype=np.float32)
        self.prev_gray = gray
        self.prev_gray_eq = gray_eq

        self._extract_features(gray_eq, self.box)
        self._update_template(gray_eq, self.box, reset=True)
        self._frames_since_feature_init = 0
        self._success_streak = 0
        self.last_method = "init"

    def update(self, frame_bgr):
        if self.box is None or self.prev_gray is None:
            return None

        gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
        gray_eq = self._clahe.apply(gray)

        # ── Попытка 1: KLT (affine или fallback translate) ──
        new_box_klt, q_klt, klt_method = self._try_klt(gray, gray_eq)

        # ── Попытка 2: Phase correlation ────────────────────
        new_box_pc, q_pc = (None, 0.0)
        if new_box_klt is None or q_klt < 0.25:
            new_box_pc, q_pc = self._try_phase_correlation(gray_eq)

        # ── Попытка 3: Template matching (multi-scale) ──────
        new_box_tm, q_tm = (None, 0.0)
        if new_box_klt is None and new_box_pc is None:
            new_box_tm, q_tm = self._try_template_match(gray_eq)

        # ── Выбор лучшего ─────────────────────────────────────
        candidates = []
        if new_box_klt is not None:
            candidates.append((klt_method, new_box_klt, q_klt))
        if new_box_pc is not None:
            candidates.append(("phasecorr", new_box_pc, q_pc))
        if new_box_tm is not None:
            candidates.append(("template", new_box_tm, q_tm))

        if not candidates:
            self.last_method = "lost"
            self.quality = 0.0
            self.prev_gray = gray
            self.prev_gray_eq = gray_eq
            return None

        candidates.sort(key=lambda c: c[2], reverse=True)
        method, new_box, q = candidates[0]

        self.box = new_box
        self.quality = float(q)
        self.last_method = method
        self._success_streak += 1
        self._frames_since_feature_init += 1

        # Переинициализация features и обновление template
        if self._frames_since_feature_init >= int(HYBRID_REINIT_FEATURES_EVERY):
            self._extract_features(gray_eq, self.box)
            self._update_template(gray_eq, self.box, reset=False)
            self._frames_since_feature_init = 0
        elif method in ("phasecorr", "template") and self._success_streak % 4 == 0:
            self._extract_features(gray_eq, self.box)
            self._update_template(gray_eq, self.box, reset=False)

        self.prev_gray = gray
        self.prev_gray_eq = gray_eq
        return self.box

    # ─── Level 1a: KLT with AFFINE motion ────────────────────
    def _try_klt(self, gray, gray_eq):
        """
        Returns (new_box, quality, method_name).
        method_name ∈ {"klt-affine", "klt-translate"} или (None, 0, "none").
        """
        if self.pts is None or len(self.pts) == 0:
            return None, 0.0, "none"

        next_pts, st, err = cv2.calcOpticalFlowPyrLK(
            self.prev_gray_eq, gray_eq, self.pts, None,
            winSize=(21, 21),
            maxLevel=3,
            criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01),
        )
        if next_pts is None or st is None:
            self.good_count = 0
            return None, 0.0, "none"

        st = st.reshape(-1).astype(bool)
        good_old = self.pts[st].reshape(-1, 2)
        good_new = next_pts[st].reshape(-1, 2)
        self.good_count = int(len(good_new))

        if self.good_count < int(HYBRID_KLT_MIN_INLIERS):
            return None, 0.0, "none"

        # ── Путь А: affine через estimateAffinePartial2D (RANSAC) ──
        if self.good_count >= int(HYBRID_KLT_MIN_FOR_AFFINE):
            M, inlier_mask = cv2.estimateAffinePartial2D(
                good_old, good_new,
                method=cv2.RANSAC,
                ransacReprojThreshold=float(HYBRID_KLT_OUTLIER_THR),
                maxIters=200,
                confidence=0.99,
            )
            if M is not None and inlier_mask is not None:
                inlier_cnt = int(inlier_mask.sum())
                if inlier_cnt >= int(HYBRID_KLT_MIN_INLIERS):
                    new_box = self._apply_affine_to_box(M, self.box)
                    if new_box is not None and self._is_sane_box_update(self.box, new_box):
                        # quality: fraction of inliers * абсолютное число
                        frac = float(inlier_cnt) / max(1, self.good_count)
                        quality = float(clamp(
                            frac * (inlier_cnt / max(6.0, HYBRID_KLT_MAX_CORNERS * 0.4)),
                            0.0, 1.0,
                        ))
                        # Обновляем pts только inliers для следующего кадра
                        mask_flat = inlier_mask.reshape(-1).astype(bool)
                        self.pts = good_new[mask_flat].reshape(-1, 1, 2).astype(np.float32)
                        return new_box, quality, "klt-affine"

        # ── Путь Б: fallback median translation ─────────────
        disp = good_new - good_old
        med = np.median(disp, axis=0)
        d = np.linalg.norm(disp - med[None, :], axis=1)
        inliers = d < float(HYBRID_KLT_OUTLIER_THR)
        in_cnt = int(np.count_nonzero(inliers))

        if in_cnt < int(HYBRID_KLT_MIN_INLIERS):
            return None, 0.0, "none"

        disp_in = disp[inliers]
        med = np.median(disp_in, axis=0)
        dx, dy = float(med[0]), float(med[1])

        if abs(dx) > HYBRID_MAX_TRANSLATION_PX or abs(dy) > HYBRID_MAX_TRANSLATION_PX:
            return None, 0.0, "none"

        x1, y1, x2, y2 = self.box
        new_box = np.array([x1 + dx, y1 + dy, x2 + dx, y2 + dy], dtype=np.float32)

        frac = float(in_cnt) / max(1, self.good_count)
        quality = float(clamp(
            frac * (in_cnt / max(6.0, HYBRID_KLT_MAX_CORNERS * 0.5)),
            0.0, 0.85,   # translate даёт меньший макс quality чем affine
        ))

        self.pts = good_new[inliers].reshape(-1, 1, 2).astype(np.float32)
        return new_box, quality, "klt-translate"

    def _apply_affine_to_box(self, M, box):
        """
        Применяет аффинку ТОЛЬКО к центру bbox (translation + rotation).
        РАЗМЕР bbox НЕ ОБНОВЛЯЕТСЯ — остаётся фиксированным.

        Причина: когда KLT-трекер масштабировал бокс своим scale, он
        постепенно расходился по размеру с тем, что детектирует YOLO,
        в результате ByteTrack не ассоциировал детекты с треком
        (hit_streak падал до 0). Размер бокса должен обновляться
        только от YOLO — это единственный надёжный источник истинного
        масштаба цели.

        scale от аффинки используется только для sanity check: если
        он слишком далёк от 1.0, значит RANSAC сбился, возвращаем None.
        """
        x1, y1, x2, y2 = [float(v) for v in box]
        cx = 0.5 * (x1 + x2)
        cy = 0.5 * (y1 + y2)
        w = x2 - x1
        h = y2 - y1

        # Центр через аффинку
        new_cx = M[0, 0] * cx + M[0, 1] * cy + M[0, 2]
        new_cy = M[1, 0] * cx + M[1, 1] * cy + M[1, 2]

        # Sanity check через норму столбца (должна быть ~1.0 для
        # кадра к кадру; большое отклонение = RANSAC сбой)
        scale = float(np.hypot(M[0, 0], M[1, 0]))
        if not (float(HYBRID_MIN_SCALE_PER_FRAME) <= scale <= float(HYBRID_MAX_SCALE_PER_FRAME)):
            return None

        # Размер НЕ меняем — используем старые w, h
        return np.array(
            [new_cx - w * 0.5, new_cy - h * 0.5,
             new_cx + w * 0.5, new_cy + h * 0.5],
            dtype=np.float32,
        )

    def _is_sane_box_update(self, old_box, new_box):
        """Защита от RANSAC-выбросов."""
        ox1, oy1, ox2, oy2 = [float(v) for v in old_box]
        nx1, ny1, nx2, ny2 = [float(v) for v in new_box]
        ocx = 0.5 * (ox1 + ox2)
        ocy = 0.5 * (oy1 + oy2)
        ncx = 0.5 * (nx1 + nx2)
        ncy = 0.5 * (ny1 + ny2)
        shift = float(np.hypot(ncx - ocx, ncy - ocy))
        if shift > float(HYBRID_MAX_TRANSLATION_PX):
            return False
        return True

    # ─── Level 2: Phase correlation ──────────────────────────
    def _try_phase_correlation(self, gray_eq):
        if self.template is None or self.template.size == 0:
            return None, 0.0

        search, (sx, sy) = self._crop_search_window(gray_eq, self.box)
        if search is None:
            return None, 0.0
        h_t, w_t = self.template.shape[:2]
        h_s, w_s = search.shape[:2]
        if h_s < h_t or w_s < w_t:
            return None, 0.0

        cy_s = h_s // 2
        cx_s = w_s // 2
        y0 = max(0, cy_s - h_t // 2)
        x0 = max(0, cx_s - w_t // 2)
        y1 = min(h_s, y0 + h_t)
        x1 = min(w_s, x0 + w_t)
        patch = search[y0:y1, x0:x1]
        if patch.shape != self.template.shape:
            patch = cv2.copyMakeBorder(
                patch, 0, h_t - patch.shape[0], 0, w_t - patch.shape[1],
                cv2.BORDER_REPLICATE,
            )

        try:
            t_f = self.template.astype(np.float32)
            p_f = patch.astype(np.float32)
            (shift_x, shift_y), response = cv2.phaseCorrelate(t_f, p_f)
        except cv2.error:
            return None, 0.0

        if response < float(HYBRID_PHASE_MIN_RESP):
            return None, 0.0
        if abs(shift_x) > HYBRID_MAX_TRANSLATION_PX or abs(shift_y) > HYBRID_MAX_TRANSLATION_PX:
            return None, 0.0

        x1b, y1b, x2b, y2b = self.box
        new_box = np.array(
            [x1b + shift_x, y1b + shift_y, x2b + shift_x, y2b + shift_y],
            dtype=np.float32,
        )
        quality = float(clamp(response * 0.75, 0.0, 0.75))
        return new_box, quality

    # ─── Level 3: Multi-scale template matching ──────────────
    def _try_template_match(self, gray_eq):
        if self.template is None or self.template.size == 0:
            return None, 0.0

        search, (sx, sy) = self._crop_search_window(gray_eq, self.box)
        if search is None:
            return None, 0.0

        best = None   # (score, new_box)

        for scale in HYBRID_TM_SCALES:
            if scale == 1.0:
                tmpl = self.template
            else:
                nh = max(4, int(self.template.shape[0] * scale))
                nw = max(4, int(self.template.shape[1] * scale))
                tmpl = cv2.resize(self.template, (nw, nh), interpolation=cv2.INTER_LINEAR)

            h_t, w_t = tmpl.shape[:2]
            if search.shape[0] < h_t or search.shape[1] < w_t:
                continue

            try:
                result = cv2.matchTemplate(search, tmpl, cv2.TM_CCOEFF_NORMED)
                min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
            except cv2.error:
                continue

            if max_val < float(HYBRID_TM_MIN_SCORE):
                continue

            mx, my = max_loc
            nx1 = sx + mx
            ny1 = sy + my
            new_box = np.array(
                [nx1, ny1, nx1 + w_t, ny1 + h_t], dtype=np.float32
            )
            if best is None or max_val > best[0]:
                best = (float(max_val), new_box)

        if best is None:
            return None, 0.0

        score, new_box = best
        quality = float(clamp(score * 0.72, 0.0, 0.72))
        return new_box, quality

    # ─── Helpers ─────────────────────────────────────────────
    def _extract_features(self, gray_eq, box):
        h, w = gray_eq.shape[:2]
        x1, y1, x2, y2 = box
        bw = max(1.0, x2 - x1)
        bh = max(1.0, y2 - y1)
        pad_x = bw * float(HYBRID_PAD_FACTOR)
        pad_y = bh * float(HYBRID_PAD_FACTOR)

        mx1 = int(clamp(x1 - pad_x, 0, w - 1))
        my1 = int(clamp(y1 - pad_y, 0, h - 1))
        mx2 = int(clamp(x2 + pad_x, 1, w))
        my2 = int(clamp(y2 + pad_y, 1, h))

        mask = np.zeros_like(gray_eq)
        mask[my1:my2, mx1:mx2] = 255

        pts = cv2.goodFeaturesToTrack(
            gray_eq,
            maxCorners=int(HYBRID_KLT_MAX_CORNERS),
            qualityLevel=float(HYBRID_KLT_QUALITY),
            minDistance=int(HYBRID_KLT_MIN_DIST),
            mask=mask,
        )
        if pts is None or len(pts) < 6:
            pts = cv2.goodFeaturesToTrack(
                gray_eq,
                maxCorners=int(HYBRID_KLT_MAX_CORNERS),
                qualityLevel=float(HYBRID_KLT_QUALITY_RETRY),
                minDistance=int(HYBRID_KLT_MIN_DIST),
                mask=mask,
            )
        self.pts = pts.astype(np.float32) if pts is not None else None

    def _update_template(self, gray_eq, box, reset=False):
        h, w = gray_eq.shape[:2]
        x1 = int(clamp(box[0], 0, w - 1))
        y1 = int(clamp(box[1], 0, h - 1))
        x2 = int(clamp(box[2], x1 + 2, w))
        y2 = int(clamp(box[3], y1 + 2, h))
        crop = gray_eq[y1:y2, x1:x2]
        if crop.size == 0:
            return

        if reset or self.template is None or self.template.shape != crop.shape:
            self.template = crop.copy()
        else:
            a = float(HYBRID_TEMPLATE_UPDATE_EMA)
            self.template = (a * self.template.astype(np.float32)
                             + (1.0 - a) * crop.astype(np.float32)).astype(np.uint8)

    def _crop_search_window(self, gray_eq, box):
        h, w = gray_eq.shape[:2]
        cx = 0.5 * (float(box[0]) + float(box[2]))
        cy = 0.5 * (float(box[1]) + float(box[3]))
        bw = max(8.0, float(box[2] - box[0]))
        bh = max(8.0, float(box[3] - box[1]))

        sw = bw * float(HYBRID_SEARCH_FACTOR)
        sh = bh * float(HYBRID_SEARCH_FACTOR)

        sx = int(clamp(cx - sw * 0.5, 0, w - 1))
        sy = int(clamp(cy - sh * 0.5, 0, h - 1))
        ex = int(clamp(cx + sw * 0.5, sx + 2, w))
        ey = int(clamp(cy + sh * 0.5, sy + 2, h))

        search = gray_eq[sy:ey, sx:ex]
        if search.size == 0:
            return None, (0, 0)
        return search, (sx, sy)
