"""Per-shape settings for the Correct step's selected detection.

The Shapes step settles the whole set at once, which is the right default: a
run of buildings wants one Simplify and one Right angles. It leaves one gap.
A single odd building (a many-winged roof, a shape whose outline came back
with far more points than its neighbours) needs its own dial, and moving the
shared one to suit it wrecks the rest of the set.

So one object can carry its own values. They are stored per canonical index
(``_auto_objects`` rows are overwritten and appended, never deleted, so an
index is a stable key), merged over the shared params inside the ONE refine
chokepoint, and dropped on a new run. Points matters most here because it runs
BEFORE the squaring: lowering it leaves fewer points for Right angles to snap,
which is the way out of a staircased wall.
"""
from __future__ import annotations

from ..dock.manual_recap import format_ground_area

try:
    from ...core.i18n import tr
except ImportError:  # no QGIS bindings: the store logic is plain dict work,
    # and keeping it importable without them lets the tests cover the merge
    # rule everywhere.
    def tr(text: str) -> str:
        return text

# The per-shape keys: every control the Shapes step carries that acts on ONE
# outline. Confidence and the size filters stay shared (they decide what is on
# the map, not what it looks like), and so does Shared borders, which needs
# every neighbour at once and therefore cannot mean anything for one object.
_OVERRIDE_KEYS = ("points_pct", "simplify_px", "open_px", "expand_px",
                  "smooth", "fill_holes", "ortho")

# How each key is stored, so a widget value can never land as the wrong type in
# the params dict the refine reads.
_OVERRIDE_COERCE = {
    "points_pct": lambda v: max(1, min(100, int(v))),
    "simplify_px": lambda v: max(0.0, float(v)),
    "open_px": lambda v: max(0.0, float(v)),
    "expand_px": lambda v: int(v),
    "smooth": bool,
    "fill_holes": bool,
    "ortho": bool,
}

# A dial tick costs a full-run pass, so the apply waits for the gesture to
# settle, on the same interval as the shared Shapes controls. The values are
# STORED on every tick (plain dict work): only the pass is deferred.
_SHAPE_ONLY_APPLY_MS = 150


class AutoShapeOverridesMixin:
    """Store, apply and publish the selected detection's own shape settings."""

    def _init_shape_override_state(self) -> None:
        """Fresh per-shape settings; called wherever a run's review state is
        reset, so a new run never inherits the last one's exceptions."""
        # canonical index -> {"points_pct": int, "simplify_px": float,
        # "ortho": bool}
        self._auto_shape_overrides: dict[int, dict] = {}
        # Drops the object whose dials moved and are waiting on the debounce.
        self._stop_shape_only_apply_timer()

    # ------------------------------------------------------------------
    # Store
    # ------------------------------------------------------------------

    def _shape_override_for(self, det_idx: int) -> dict | None:
        """This object's own settings, or None when it follows the set."""
        overrides = getattr(self, "_auto_shape_overrides", None)
        if not overrides:
            return None
        return overrides.get(int(det_idx))

    def _shape_params_for_object(self, det_idx: int, params: dict) -> dict:
        """``params`` with this object's own settings written over them.

        Returns the SAME dict when the object has no settings of its own, so
        the shared path allocates nothing.
        """
        override = self._shape_override_for(det_idx)
        if not override:
            return params
        merged = dict(params)
        for key in _OVERRIDE_KEYS:
            if key in override:
                merged[key] = override[key]
        return merged

    # ------------------------------------------------------------------
    # Dock handlers
    # ------------------------------------------------------------------

    def _on_shape_only_changed(self, values: dict) -> None:
        """A per-shape control moved: store the values for the selected object
        and arm the debounce that re-derives the visible set once the gesture
        settles. Storing is dict work; the pass behind it is not, so a drag
        that used to run one per tick now runs one per gesture.

        ``values`` is keyed by review param name (the dock's own map), so a
        control added on the dock side needs only a new key in _OVERRIDE_KEYS
        here. An unknown key is ignored rather than written blindly into the
        params the refine reads."""
        idx = getattr(self, "_correct_selected_idx", None)
        if idx is None or self._auto_review is None:
            return
        if idx < 0 or idx >= len(self._auto_objects):
            return
        overrides = getattr(self, "_auto_shape_overrides", None)
        if overrides is None:
            self._init_shape_override_state()
            overrides = self._auto_shape_overrides
        stored: dict = {}
        for key in _OVERRIDE_KEYS:
            if key not in values:
                continue
            try:
                stored[key] = _OVERRIDE_COERCE[key](values[key])
            except (TypeError, ValueError):
                continue
        if not stored:
            return
        overrides[int(idx)] = stored
        self._auto_shape_only_pending_idx = int(idx)
        self._arm_shape_only_apply_timer()

    def _arm_shape_only_apply_timer(self) -> None:
        """(Re)start the per-shape debounce, so a drag applies once. Applies
        straight away when there is no dock to own the timer."""
        dock = getattr(self, "dock_widget", None)
        if dock is None:
            self._apply_shape_only_pending()
            return
        try:
            from .shared import _debounce_timer
            _debounce_timer(self, "_shape_only_apply_timer", dock,
                            _SHAPE_ONLY_APPLY_MS,
                            self._apply_shape_only_pending)
        except (ImportError, RuntimeError, AttributeError, TypeError):
            # A timer left over from a dock that has been destroyed raises on
            # start. Drop it so the next gesture builds a live one, and apply
            # this one now rather than losing it.
            self._shape_only_apply_timer = None
            self._apply_shape_only_pending()

    def _stop_shape_only_apply_timer(self) -> None:
        """Drop a pending apply, so a reset or a new run never lands the last
        gesture on an object list that has moved under it."""
        self._auto_shape_only_pending_idx = None
        timer = getattr(self, "_shape_only_apply_timer", None)
        if timer is None:
            return
        try:
            timer.stop()
        except (RuntimeError, AttributeError):
            pass

    def _apply_shape_only_pending(self) -> None:
        """The dials settled: re-derive the one object they belong to.

        A live Manual session shows its own editable copy, not the review
        layer, so the override has to reach that copy or the dial changes
        nothing the user can see. The session path repaints itself; the review
        layer is rebuilt when the session folds back on Save.
        """
        idx = getattr(self, "_auto_shape_only_pending_idx", None)
        self._auto_shape_only_pending_idx = None
        if idx is None or self._auto_review is None:
            return
        if idx < 0 or idx >= len(self._auto_objects):
            return
        applied = False
        try:
            applied = bool(self._apply_shape_only_to_session(int(idx)))
        except (RuntimeError, AttributeError):
            applied = False
        if not applied:
            self._after_shape_edit(changed=(int(idx),))
        self._push_shape_only_state()

    def _on_shape_only_reset(self) -> None:
        """Back to shared settings: forget this object's exception."""
        idx = getattr(self, "_correct_selected_idx", None)
        overrides = getattr(self, "_auto_shape_overrides", None)
        self._stop_shape_only_apply_timer()
        if idx is None or not overrides or int(idx) not in overrides:
            return
        overrides.pop(int(idx), None)
        self._after_shape_edit(changed=(int(idx),))
        self._push_shape_only_state()

    # ------------------------------------------------------------------
    # Publish
    # ------------------------------------------------------------------

    def _push_shape_only_state(self) -> None:
        """Send the selected object's facts and settings to the dock.

        With no override the controls show the SHARED values, so opening the
        panel starts from what the object already looks like instead of zero.
        """
        dock = self.dock_widget
        if dock is None:
            return
        idx = getattr(self, "_correct_selected_idx", None)
        try:
            if idx is None:
                dock.set_correct_selection_info("")
                return
            shared = self._widget_review_params()
            override = self._shape_override_for(idx) or {}
            values = {key: override.get(key, shared.get(key))
                      for key in _OVERRIDE_KEYS
                      if override.get(key, shared.get(key)) is not None}
            dock.set_shape_only_values(values, bool(override))
            dock.set_correct_selection_info(self._selected_shape_facts(idx))
        except (RuntimeError, AttributeError):
            pass

    def _selected_shape_facts(self, det_idx: int) -> str:
        """"412 m2 - 38 points": the two numbers that decide what to do next.

        The point count is measured on the geometry as DRAWN, so it answers the
        question Points raises ("how many points is this thing carrying?")
        and moves as the control moves. Returns the area alone when the outline
        cannot be counted, the points alone when the area is not usable, and an
        empty string when neither is available.
        """
        try:
            area = float(self._auto_objects[det_idx][2])
        except (IndexError, TypeError, ValueError):
            return ""
        # The house area format: the unit follows the size, and the thousands
        # separator is a space, not the comma this line used to print.
        area_txt = format_ground_area(area)
        points = self._selected_shape_vertex_count(det_idx)
        if points is None:
            return area_txt
        points_txt = tr("{count} vertices").format(count=points)
        if not area_txt:
            return points_txt
        return f"{area_txt} · " + points_txt

    def _selected_shape_vertex_count(self, det_idx: int) -> int | None:
        """Points in the outline currently drawn for this object, or None."""
        geom = None
        try:
            # Same resolver the yellow highlight uses, so the count and the
            # outline the user is looking at can never disagree.
            resolve = getattr(self, "_drawn_geom_for_index", None)
            if resolve is not None:
                geom = resolve(det_idx)
            if geom is None:
                geom = (getattr(self, "_shape_hit_geoms", None) or {}).get(det_idx)
            if geom is None:
                geom = self._auto_objects[det_idx][0]
        except (IndexError, TypeError, AttributeError):
            return None
        if geom is None:
            return None
        try:
            if geom.isEmpty():
                return None
            # The count comes off the abstract geometry, which knows it: the
            # vertex iterator walked every point in Python for a number the
            # panel refreshes on each selection and each dial move.
            abstract = geom.constGet()
            if abstract is not None:
                return int(abstract.nCoordinates())
            return sum(1 for _v in geom.vertices())
        except (RuntimeError, AttributeError, TypeError):
            return None
