"""Tools for determining if an object is stationary."""

import logging
from dataclasses import dataclass, field
from typing import Any, cast

import cv2
import numpy as np
from scipy.ndimage import gaussian_filter

logger = logging.getLogger(__name__)


@dataclass
class StationaryThresholds:
    """IOU thresholds and history parameters for stationary object classification.

    This allows different sensitivity settings for different object types.
    """

    # Objects to apply these thresholds to
    # If None, apply to all objects
    objects: list[str] = field(default_factory=list)

    # Threshold of IoU that causes the object to immediately be considered active
    # Below this threshold, assume object is active
    known_active_iou: float = 0.2

    # IOU threshold for checking if stationary object has moved
    # If mean and median IOU drops below this, assume object is no longer stationary
    stationary_check_iou: float = 0.6

    # IOU threshold for checking if active object has changed position
    # Higher threshold makes it more difficult for the object to be considered stationary
    active_check_iou: float = 0.9

    # Maximum number of bounding boxes to keep in stationary history
    max_stationary_history: int = 10

    # Whether to use the motion classifier
    motion_classifier_enabled: bool = False


# Thresholds for objects that are expected to be stationary
STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
    objects=["bbq_grill", "package", "waste_bin"],
    known_active_iou=0.0,
    motion_classifier_enabled=True,
)

# Thresholds for objects that are active but can be stationary for longer periods of time
DYNAMIC_OBJECT_THRESHOLDS = StationaryThresholds(
    objects=["bicycle", "boat", "car", "motorcycle", "tractor", "truck"],
    active_check_iou=0.75,
    motion_classifier_enabled=True,
)

# Thresholds for objects that are not expected to be stationary
NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
    objects=["license_plate"],
    known_active_iou=0.9,
    stationary_check_iou=0.9,
    max_stationary_history=4,
)

# Default thresholds for any other object label
DEFAULT_OBJECT_THRESHOLDS = StationaryThresholds()


def get_stationary_threshold(label: str) -> StationaryThresholds:
    """Get the stationary thresholds for a given object label."""

    if label in STATIONARY_OBJECT_THRESHOLDS.objects:
        return STATIONARY_OBJECT_THRESHOLDS

    if label in DYNAMIC_OBJECT_THRESHOLDS.objects:
        return DYNAMIC_OBJECT_THRESHOLDS

    if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
        return NON_STATIONARY_OBJECT_THRESHOLDS

    return DEFAULT_OBJECT_THRESHOLDS


class StationaryMotionClassifier:
    """Fallback classifier to prevent false flips from stationary to active.

    Uses appearance consistency on a fixed spatial region (historical median box)
    to detect actual movement, ignoring bounding box detection variations.
    """

    CROP_SIZE = 96
    NCC_KEEP_THRESHOLD = 0.90  # High correlation = keep stationary
    NCC_ACTIVE_THRESHOLD = 0.85  # Low correlation = consider active
    SHIFT_KEEP_THRESHOLD = 0.02  # Small shift = keep stationary
    SHIFT_ACTIVE_THRESHOLD = 0.04  # Large shift = consider active
    DRIFT_ACTIVE_THRESHOLD = 0.12  # Cumulative drift over 5 frames
    CHANGED_FRAMES_TO_FLIP = 2

    def __init__(self) -> None:
        self.anchor_crops: dict[str, np.ndarray] = {}
        self.anchor_boxes: dict[str, tuple[int, int, int, int]] = {}
        self.changed_counts: dict[str, int] = {}
        self.shift_histories: dict[str, list[float]] = {}

        # Pre-compute Hanning window for phase correlation
        hann = np.hanning(self.CROP_SIZE).astype(np.float64)
        self._hann2d = np.outer(hann, hann)

    def reset(self, id: str) -> None:
        logger.debug("StationaryMotionClassifier.reset: id=%s", id)
        if id in self.anchor_crops:
            del self.anchor_crops[id]
        if id in self.anchor_boxes:
            del self.anchor_boxes[id]
        self.changed_counts[id] = 0
        self.shift_histories[id] = []

    def _extract_y_crop(
        self, yuv_frame: np.ndarray, box: tuple[int, int, int, int]
    ) -> np.ndarray:
        """Extract and normalize Y-plane crop from bounding box."""
        y_height = yuv_frame.shape[0] // 3 * 2
        width = yuv_frame.shape[1]
        x1 = max(0, min(width - 1, box[0]))
        y1 = max(0, min(y_height - 1, box[1]))
        x2 = max(0, min(width - 1, box[2]))
        y2 = max(0, min(y_height - 1, box[3]))

        if x2 <= x1:
            x2 = min(width - 1, x1 + 1)
        if y2 <= y1:
            y2 = min(y_height - 1, y1 + 1)

        # Extract Y-plane crop, resize, and blur
        y_plane = yuv_frame[0:y_height, 0:width]
        crop = y_plane[y1:y2, x1:x2]
        crop_resized = cv2.resize(
            crop, (self.CROP_SIZE, self.CROP_SIZE), interpolation=cv2.INTER_AREA
        )
        result = cast(np.ndarray[Any, Any], gaussian_filter(crop_resized, sigma=0.5))
        logger.debug(
            "_extract_y_crop: box=%s clamped=(%d,%d,%d,%d) crop_shape=%s",
            box,
            x1,
            y1,
            x2,
            y2,
            crop.shape if "crop" in locals() else None,
        )
        return result

    def ensure_anchor(
        self, id: str, yuv_frame: np.ndarray, median_box: tuple[int, int, int, int]
    ) -> None:
        """Initialize anchor crop from stable median box when object becomes stationary."""
        if id not in self.anchor_crops:
            self.anchor_boxes[id] = median_box
            self.anchor_crops[id] = self._extract_y_crop(yuv_frame, median_box)
            self.changed_counts[id] = 0
            self.shift_histories[id] = []
            logger.debug(
                "ensure_anchor: initialized id=%s median_box=%s crop_shape=%s",
                id,
                median_box,
                self.anchor_crops[id].shape,
            )

    def on_active(self, id: str) -> None:
        """Reset state when object becomes active to allow re-anchoring."""
        logger.debug("on_active: id=%s became active; resetting state", id)
        self.reset(id)

    def evaluate(
        self, id: str, yuv_frame: np.ndarray, current_box: tuple[int, int, int, int]
    ) -> bool:
        """Return True to keep stationary, False to flip to active.

        Compares the same spatial region (historical median box) across frames
        to detect actual movement, ignoring bounding box variations.
        """

        if id not in self.anchor_crops or id not in self.anchor_boxes:
            logger.debug("evaluate: id=%s has no anchor; default keep stationary", id)
            return True

        # Compare same spatial region across frames
        anchor_box = self.anchor_boxes[id]
        anchor_crop = self.anchor_crops[id]
        curr_crop = self._extract_y_crop(yuv_frame, anchor_box)

        # Compute appearance and motion metrics
        ncc = cv2.matchTemplate(curr_crop, anchor_crop, cv2.TM_CCOEFF_NORMED)[0, 0]
        a64 = anchor_crop.astype(np.float64) * self._hann2d
        c64 = curr_crop.astype(np.float64) * self._hann2d
        (shift_x, shift_y), _ = cv2.phaseCorrelate(a64, c64)
        shift_norm = float(np.hypot(shift_x, shift_y)) / float(self.CROP_SIZE)

        logger.debug(
            "evaluate: id=%s metrics ncc=%.4f shift_norm=%.4f (shift_x=%.3f, shift_y=%.3f)",
            id,
            float(ncc),
            shift_norm,
            float(shift_x),
            float(shift_y),
        )

        # Update rolling shift history
        history = self.shift_histories.get(id, [])
        history.append(shift_norm)
        if len(history) > 5:
            history = history[-5:]
        self.shift_histories[id] = history
        drift_sum = float(sum(history))

        logger.debug(
            "evaluate: id=%s history_len=%d last_shift=%.4f drift_sum=%.4f",
            id,
            len(history),
            history[-1] if history else -1.0,
            drift_sum,
        )

        # Early exit for clear stationary case
        if ncc >= self.NCC_KEEP_THRESHOLD and shift_norm < self.SHIFT_KEEP_THRESHOLD:
            self.changed_counts[id] = 0
            logger.debug(
                "evaluate: id=%s early-stationary keep=True (ncc>=%.2f and shift<%.2f)",
                id,
                self.NCC_KEEP_THRESHOLD,
                self.SHIFT_KEEP_THRESHOLD,
            )
            return True

        # Check for movement indicators
        movement_detected = (
            ncc < self.NCC_ACTIVE_THRESHOLD
            or shift_norm >= self.SHIFT_ACTIVE_THRESHOLD
            or drift_sum >= self.DRIFT_ACTIVE_THRESHOLD
        )

        if movement_detected:
            cnt = self.changed_counts.get(id, 0) + 1
            self.changed_counts[id] = cnt
            if (
                cnt >= self.CHANGED_FRAMES_TO_FLIP
                or drift_sum >= self.DRIFT_ACTIVE_THRESHOLD
            ):
                logger.debug(
                    "evaluate: id=%s flip_to_active=True cnt=%d drift_sum=%.4f thresholds(changed>=%d drift>=%.2f)",
                    id,
                    cnt,
                    drift_sum,
                    self.CHANGED_FRAMES_TO_FLIP,
                    self.DRIFT_ACTIVE_THRESHOLD,
                )
                return False
            logger.debug(
                "evaluate: id=%s movement_detected cnt=%d keep_until_cnt>=%d",
                id,
                cnt,
                self.CHANGED_FRAMES_TO_FLIP,
            )
        else:
            self.changed_counts[id] = 0
            logger.debug("evaluate: id=%s no_movement keep=True", id)

        return True
