"""Per-cycle dot detector with debounce and rolling window."""
from __future__ import annotations

from collections import deque
from dataclasses import dataclass
from typing import Callable

import numpy as np

from .config import Config, ROI
from .vision import (
    ColorMatch,
    classify_pixel,
    crop_roi,
    find_rightmost_dot,
    pixel_rgb,
)

ScreenCapture = Callable[[], "np.ndarray | None"]


@dataclass
class DetectionResult:
    ts: float
    window_found: bool
    dot_found: bool
    rgb: tuple[int, int, int] | None
    match: ColorMatch | None  # None if no dot
    accepted: bool  # post-debounce; True only when match repeats debounce_depth times
    color: str | None  # accepted color name (UNKNOWN excluded)
    dot_pos_abs: tuple[int, int] | None = None  # absolute (x, y) in frame; set when dot_found


class Detector:
    """Capture → crop → find dot → classify → debounce → emit."""

    def __init__(
        self,
        cfg: Config,
        capture: ScreenCapture,
        bg_rgb: tuple[int, int, int] | None = None,
        bg_tol: float | None = None,
        dot_roi_override: ROI | None = None,
    ) -> None:
        self._cfg = cfg
        self._capture = capture
        self._dot_roi = dot_roi_override if dot_roi_override is not None else cfg.dot_roi
        # Prefer config-defined background; fall back to dark-grey default.
        if "background" in cfg.colors:
            spec = cfg.colors["background"]
            self._bg_rgb = bg_rgb if bg_rgb is not None else spec.rgb
            self._bg_tol = bg_tol if bg_tol is not None else spec.tolerance
        else:
            self._bg_rgb = bg_rgb if bg_rgb is not None else (18, 18, 18)
            self._bg_tol = bg_tol if bg_tol is not None else 15.0
        # Palette excludes "background" key
        self._palette: dict[str, tuple[tuple[int, int, int], float]] = {
            name: (spec.rgb, spec.tolerance)
            for name, spec in cfg.colors.items()
            if name != "background"
        }
        # maxlen enforces "last N" automatically
        self._debounce: deque[str | None] = deque(maxlen=cfg.debounce_depth)
        self._rolling: deque[DetectionResult] = deque(maxlen=20)

    def step(self, ts: float, frame=None) -> DetectionResult:
        """Run one detection tick.

        frame: pre-captured BGR ndarray (from asyncio.to_thread capture). When
        None (default), calls self._capture() — preserving the sync-loop behaviour.
        """
        if frame is None:
            frame = self._capture()

        if frame is None:
            self._debounce.append(None)
            r = DetectionResult(
                ts=ts,
                window_found=False,
                dot_found=False,
                rgb=None,
                match=None,
                accepted=False,
                color=None,
            )
            self._rolling.append(r)
            return r

        roi_img = crop_roi(frame, self._dot_roi)
        dot_pos = find_rightmost_dot(roi_img, self._bg_rgb, self._bg_tol)

        if dot_pos is None:
            self._debounce.append(None)
            r = DetectionResult(
                ts=ts,
                window_found=True,
                dot_found=False,
                rgb=None,
                match=None,
                accepted=False,
                color=None,
            )
            self._rolling.append(r)
            return r

        x, y = dot_pos
        rgb = pixel_rgb(roi_img, x, y)
        match = classify_pixel(rgb, self._palette)
        self._debounce.append(match.name)

        accepted = False
        color: str | None = None
        if (
            len(self._debounce) == self._cfg.debounce_depth
            and all(m == match.name for m in self._debounce)
            and match.name != "UNKNOWN"
        ):
            accepted = True
            color = match.name

        r = DetectionResult(
            ts=ts,
            window_found=True,
            dot_found=True,
            rgb=rgb,
            match=match,
            accepted=accepted,
            color=color,
            dot_pos_abs=(self._dot_roi.x + x, self._dot_roi.y + y),
        )
        self._rolling.append(r)
        return r

    def update_dot_roi(self, roi: ROI) -> None:
        self._dot_roi = roi

    @property
    def rolling(self) -> list[DetectionResult]:
        return list(self._rolling)
