# ============================================================
# stationary_killer.py
# Детектор прилипших треков на статичных объектах сцены.
# Защита от track hijacking на:
#   - снежных фичах / следах / кустах
#   - линии горизонта
#   - ЛЭП / деревьях / ветках
#   - отражениях от воды
#
# Работает как независимый "полицейский": следит за
# движением центра bbox'а confirmed трека и motion saliency
# score в его зоне. Если трек не движется И не даёт motion
# response — форсирует сброс target.
# ============================================================

from collections import deque
import numpy as np


# ─── Параметры детектора ─────────────────────────────────────
STAT_WINDOW_FRAMES     = 45        # окно истории (~0.75s при 60fps)
STAT_MAX_CENTER_SHIFT  = 12.0      # макс сдвиг центра bbox в пикселях
STAT_MAX_MOTION_SCORE  = 0.12      # порог motion saliency
STAT_MIN_CONFIRMED_F   = 20        # не трогать трек моложе этого
STAT_GRACE_PERIOD      = 15        # после kill — не убивать N кадров

# Для нижней половины кадра (parallax даёт false motion)
# пороги мягче — там движение фона более заметно
STAT_BOTTOM_FRAC       = 0.55
STAT_LOW_MAX_CENTER_SHIFT = 18.0
STAT_LOW_MAX_MOTION    = 0.20


class StationaryKiller:
    """
    Следит за движением confirmed трека и сигнализирует когда
    трек "прилип" к статичной фиче (снег, горизонт, дерево).

    Работает независимо от ByteTrack / KLT / Kalman — просто
    смотрит на историю locked_box_eff и motion_sal score.
    """

    def __init__(self):
        self.target_id = None
        self.history = deque(maxlen=STAT_WINDOW_FRAMES)
        # history items: (frame_id, cx, cy, motion_score, frame_h)
        self.confirmed_since = -1
        self.grace_until = -1
        self.kills_total = 0
        self.last_kill_reason = ""

    def reset(self):
        self.target_id = None
        self.history.clear()
        self.confirmed_since = -1

    def notify_kill_done(self, frame_id):
        """Вызывается main.py после force reset."""
        self.kills_total += 1
        self.grace_until = int(frame_id) + int(STAT_GRACE_PERIOD)
        self.reset()

    def update(self, frame_id, target_id, locked_box_eff,
               motion_sal, confirmed, frame_h):
        """Обновить историю трека. Вызывать каждый кадр."""
        if (not confirmed) or (target_id is None) or (locked_box_eff is None):
            if self.target_id is not None:
                self.reset()
            return

        if target_id != self.target_id:
            self.reset()
            self.target_id = target_id
            self.confirmed_since = int(frame_id)

        cx = 0.5 * (float(locked_box_eff[0]) + float(locked_box_eff[2]))
        cy = 0.5 * (float(locked_box_eff[1]) + float(locked_box_eff[3]))

        if motion_sal is not None and motion_sal.saliency is not None:
            ms_score = float(motion_sal.score_box(locked_box_eff))
        else:
            ms_score = 0.5    # нет saliency — не штрафуем

        self.history.append((int(frame_id), cx, cy, ms_score, float(frame_h)))

    def should_kill(self, frame_id):
        """Проверить нужно ли сбросить текущий target."""
        if int(frame_id) < int(self.grace_until):
            return False
        if self.target_id is None:
            return False
        if len(self.history) < int(STAT_WINDOW_FRAMES):
            return False
        if (int(frame_id) - int(self.confirmed_since)) < int(STAT_MIN_CONFIRMED_F):
            return False

        cx0 = self.history[0][1]
        cy0 = self.history[0][2]
        max_shift = 0.0
        motion_scores = []
        frame_h = self.history[-1][4]
        for _, cx, cy, ms, _fh in self.history:
            s = float(np.hypot(cx - cx0, cy - cy0))
            if s > max_shift:
                max_shift = s
            motion_scores.append(ms)

        avg_motion = float(np.mean(motion_scores)) if motion_scores else 1.0
        last_cy = self.history[-1][2]
        is_low_zone = last_cy >= float(frame_h) * float(STAT_BOTTOM_FRAC)

        if is_low_zone:
            shift_lim = float(STAT_LOW_MAX_CENTER_SHIFT)
            motion_lim = float(STAT_LOW_MAX_MOTION)
        else:
            shift_lim = float(STAT_MAX_CENTER_SHIFT)
            motion_lim = float(STAT_MAX_MOTION_SCORE)

        if max_shift <= shift_lim and avg_motion <= motion_lim:
            zone = "low" if is_low_zone else "normal"
            self.last_kill_reason = (
                f"stationary:{zone} shift={max_shift:.1f}/{shift_lim:.0f} "
                f"motion={avg_motion:.3f}/{motion_lim:.2f}"
            )
            return True
        return False

    def status_line(self):
        return (
            f"StationaryKiller: monitoring tid={self.target_id} "
            f"history={len(self.history)}/{STAT_WINDOW_FRAMES} "
            f"kills={self.kills_total}"
        )
