"""Layout drift detector via perceptual hash comparison."""
from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Callable

import numpy as np

from .config import Config
from .vision import crop_roi, hamming_hex, phash

logger = logging.getLogger(__name__)


@dataclass
class CanaryResult:
    distance: int
    drifted: bool
    paused: bool  # True while module is paused (cleared only by resume())


class Canary:
    """Compare live canary ROI phash against a known-good baseline.

    ``check()`` is pure measurement — it never mutates ``_paused`` or fires
    side-effects. Callers decide whether to ``commit_pause()`` (real drift) or
    ``rebase()`` (legitimate layout change). This split exists so the tick loop
    can use a second signal (strip-count change) to disambiguate before pausing.
    """

    def __init__(
        self,
        cfg: Config,
        pause_flag_path: Path | None = None,
        on_pause_callback: Callable[[int], None] | None = None,
    ) -> None:
        self._cfg = cfg
        self._pause_flag_path = pause_flag_path
        self._paused = False
        # Single-shot callback invoked exactly once per not_paused→paused transition.
        # Wrapped in try/except at call site so a faulty notifier never breaks
        # the detection cycle.
        self._on_pause = on_pause_callback

    def check(self, frame_bgr: np.ndarray) -> CanaryResult:
        roi_img = crop_roi(frame_bgr, self._cfg.canary.roi)
        current_hash = phash(roi_img)
        distance = hamming_hex(current_hash, self._cfg.canary.baseline_phash)
        drifted = distance > self._cfg.canary.drift_threshold
        return CanaryResult(distance=distance, drifted=drifted, paused=self._paused)

    def commit_pause(self, distance: int) -> None:
        """Transition to paused state. Idempotent — no-op if already paused."""
        if self._paused:
            return
        self._paused = True
        if self._pause_flag_path is not None:
            self._pause_flag_path.write_text("paused", encoding="utf-8")
        if self._on_pause is not None:
            try:
                self._on_pause(distance)
            except Exception as exc:
                # Never let a notifier hiccup abort the detection cycle.
                logger.warning("canary on_pause_callback raised: %s", exc)

    def rebase(self, new_phash: str) -> None:
        """Replace baseline_phash in the live cfg (in-memory mirror).

        Caller is responsible for persisting to TOML separately. Does NOT touch
        ``_paused`` — used in the auto-rebase path where we never paused.
        """
        object.__setattr__(self._cfg.canary, "baseline_phash", new_phash)

    @property
    def is_paused(self) -> bool:
        return self._paused

    def resume(self) -> None:
        """Clear the paused flag and remove pause marker file if present."""
        self._paused = False
        if self._pause_flag_path is not None and self._pause_flag_path.exists():
            self._pause_flag_path.unlink()
