"""Zone drawing, tile grid overlay, canvas badges, project-cleared handling.

Part of AISegmentationPlugin (see ai_segmentation_plugin.py); split out
so agents and humans can work on one concern per file. Methods here are
plain mixin members: state lives on the plugin instance (self).
"""
from __future__ import annotations

from qgis.core import (
    Qgis,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsGeometry,
    QgsMessageLog,
    QgsPointXY,
    QgsProject,
    QgsRasterLayer,
    QgsRectangle,
)
from qgis.gui import QgsRubberBand
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor

from ...core.i18n import tr
from ...core.qt_compat import (
    PolygonGeometry,
    geometry_op_succeeded,
    safe_disconnect,
)
from ...core.shape_edits import KIND_MERGE
from ..canvas_palette import GRID_LINE, ZONE_FILL, ZONE_STROKE
from ..shortcut_filter import ShortcutFilter
from .shared import (
    free_zone_cap_km2,
    max_tiles_per_run_cap,
    zone_too_large_message,
)

# How many already-clipped grid geometries to keep. Enough to cover a Detail
# drag in both directions without holding every zone of the session.
_ZONE_GRID_CACHE_SIZE = 8


class AutoZoneMixin:
    """Zone drawing, tile grid overlay, canvas badges, project-cleared handling."""

    # ---- Pro Auto mode: zone selection, tile grid overlay -------------------

    def _get_active_raster_layer(self) -> QgsRasterLayer | None:
        """Return the raster layer selected in the dock combo of the CURRENT
        mode. Interactive and Automatic each have their own combo; reading
        the wrong one silently runs detection on a stale layer."""
        if not self.dock_widget:
            return None
        try:
            from ..ai_segmentation_dockwidget import Mode
            if self.dock_widget._mode == Mode.AUTOMATIC:
                layer = self.dock_widget.auto_layer_combo.currentLayer()
            else:
                layer = self.dock_widget.layer_combo.currentLayer()
        except (RuntimeError, AttributeError):
            return None
        if layer is None:
            return None
        # isinstance is the version-stable raster check: the layer-type enum
        # spelling moved across QGIS releases, and QgsRasterLayer.LayerType is
        # the raster RENDER type enum, which has no RasterLayer member.
        if not isinstance(layer, QgsRasterLayer):
            return None
        return layer

    def _setup_auto_mode(self) -> None:
        """Create the zone selection tool, tile manager, and tile grid overlay.

        Idempotent: returns immediately if already set up.
        """
        if self._zone_selection_tool is not None:
            return

        from ...core.tile_manager import OVERLAP_FRACTION, TILE_SIZE, TileManager
        from ..polygon_zone_maptool import PolygonZoneMapTool

        canvas = self.iface.mapCanvas()
        self._tile_manager = TileManager(
            tile_size=TILE_SIZE,
            overlap_fraction=OVERLAP_FRACTION,
            max_tiles=max_tiles_per_run_cap(),
        )
        # Point-by-point polygon tool so the user outlines the exact area to
        # scan; tiles outside the polygon are never rendered/billed.
        self._zone_selection_tool = PolygonZoneMapTool(canvas)
        self._zone_selection_tool.zone_selected.connect(self._on_zone_polygon_drawn)
        self._zone_selection_tool.zone_cleared.connect(self._on_zone_cleared)
        self._zone_selection_tool.vertices_changed.connect(self._on_zone_vertices_changed)
        # Ctrl+Z / Backspace on the empty draw canvas leaves the draw step.
        self._zone_selection_tool.back_requested.connect(self._on_auto_exit_clicked)
        # The user picking another QGIS tool mid-draw takes the dots with it.
        self._zone_selection_tool.tool_deactivated.connect(
            self._on_zone_tool_deactivated)

        # Hide overlay when project is replaced or cleared
        QgsProject.instance().cleared.connect(self._on_project_cleared_auto)
        QgsProject.instance().readProject.connect(self._on_project_cleared_auto)

    def _teardown_auto_mode(self) -> None:
        """Clean up zone selection tool, rubber bands, and overlay layers.

        Called from unload() and whenever the Pro auto mode is deactivated.
        """
        if self._zone_selection_tool is not None:
            # Restore the tool the user had before our zone draw (pan by
            # default) instead of leaving a bare cursor once the plugin is
            # gone; only acts when our zone tool is still the active tool.
            self._restore_maptool_after_zone()
            try:
                self.iface.mapCanvas().unsetMapTool(self._zone_selection_tool)
            except (RuntimeError, AttributeError):
                pass
            # One guard per signal: batched, a tool whose C++ half was already
            # deleted made the first disconnect raise and left the other three
            # handlers live on it.
            tool = self._zone_selection_tool
            safe_disconnect(tool, "zone_selected", self._on_zone_polygon_drawn)
            safe_disconnect(tool, "zone_cleared", self._on_zone_cleared)
            safe_disconnect(tool, "vertices_changed", self._on_zone_vertices_changed)
            safe_disconnect(tool, "back_requested", self._on_auto_exit_clicked)
            safe_disconnect(tool, "tool_deactivated", self._on_zone_tool_deactivated)
            # reset() empties a rubber band but leaves the item on the scene, so
            # the tool's three bands go with the tool.
            try:
                tool.remove_bands_from_canvas()
            except (RuntimeError, AttributeError):
                pass
            self._zone_selection_tool = None

        self._remove_zone_shortcut_filter()
        self._clear_auto_canvas()

        # The stall watchdog is parented to the dock, so it dies with it. Drop
        # our reference too, or the next run arms a timer whose C++ half is
        # gone and every call on it raises.
        self._stop_auto_stall_watchdog()
        self._auto_stall_timer = None

        # Was one try catching TypeError only, so a RuntimeError from a project
        # torn down before us escaped _teardown_auto_mode entirely and skipped
        # everything below.
        proj = QgsProject.instance()
        safe_disconnect(proj, "cleared", self._on_project_cleared_auto)
        safe_disconnect(proj, "readProject", self._on_project_cleared_auto)

        # The zone clear below drops the last run's context along with its
        # clip artefacts. The autosave further down stamps the saved review
        # with that context, so it is held across the clear and let go only
        # once the save has read it.
        pending_run_ctx = self._auto_run_ctx
        self._store_auto_zone(None)
        self._auto_zone_polygon = None
        self._tile_manager = None
        # Drop the last-run signature so the identical-re-run note never fires
        # against a run from a torn-down flow.
        self._auto_last_run_sig = None
        self._rerun_guard_emitted_sig = None

        # Stop any running detection worker and remove the live selection layer.
        self._stop_auto_detection()
        # Save a still-pending review before tearing down (unload / mode reset):
        # a billed detection must not be lost just because the user quit QGIS or
        # switched away without clicking Finish.
        self._auto_run_ctx = pending_run_ctx
        self._autosave_pending_auto_review(exit_path="unload")
        # Cleared only now: the autosave above reads the run context to stamp
        # the saved review, and nulling it earlier saved the run without it.
        self._auto_run_ctx = None
        self._auto_review = None  # discard review without UI update (widget may be gone)
        self._remove_auto_selection_layer()

    def _activate_zone_drawing(self) -> None:
        """Activate the zone selection map tool on the canvas."""
        # The zone belongs to a run in flight or a pending (billed) review:
        # arming a redraw now would invalidate work mid-use, so no code path
        # (button, badge, stray re-entry) may start a draw here.
        if self._auto_worker is not None or self._auto_review is not None:
            return
        if self._zone_selection_tool is None:
            self._setup_auto_mode()

        layer = self._get_active_raster_layer()
        if layer is not None:
            try:
                raster_w = layer.width()
                raster_h = layer.height()
                ext = layer.extent()
                if raster_w > 0 and raster_h > 0 and ext.width() > 0:
                    self._zone_selection_tool.set_snap_context(
                        tile_manager=self._tile_manager,
                        pixel_size_x=ext.width() / raster_w,
                        pixel_size_y=ext.height() / raster_h,
                    )
            except (RuntimeError, AttributeError):
                pass

        self._clear_auto_canvas()
        try:
            canvas = self.iface.mapCanvas()
            # Remember the tool active before arming (the pan tool by default) so
            # exit/finish/zone-drawn can restore it instead of leaving a bare
            # cursor. Do not capture the zone tool itself on a re-arm.
            current = canvas.mapTool()
            if current is not self._zone_selection_tool:
                self._maptool_before_zone = current
            canvas.setMapTool(self._zone_selection_tool)
            # Focus the canvas so the polygon tool receives key events (Ctrl+Z to
            # undo a point / go back, Backspace, Enter) without a first click.
            canvas.setFocus()
        except (RuntimeError, AttributeError):
            pass
        # Space-pan needs the shortcut filter; installing the same filter
        # object twice is a Qt no-op, so this is safe alongside the
        # interactive-mode install.
        if self._shortcut_filter is None:
            self._shortcut_filter = ShortcutFilter(self)
        self.iface.mainWindow().installEventFilter(self._shortcut_filter)
        canvas = self.iface.mapCanvas()
        canvas.viewport().installEventFilter(self._shortcut_filter)
        canvas.installEventFilter(self._shortcut_filter)
        if self.dock_widget:
            self.dock_widget.set_auto_zone_state("drawing")
            self.dock_widget.set_zone_draw_progress(0)

    def _remove_zone_shortcut_filter(self) -> None:
        """Take the shortcut filter off the three targets the zone draw armed.

        The object is kept, not dropped: Manual re-installs the same one when
        it arms its own tool. Only unload owns the filter's life.

        One guard per target: a window torn down before us must not leave the
        filter sitting on the canvas, where it would outlive the flow.
        """
        if self._shortcut_filter is None:
            return
        try:
            self.iface.mainWindow().removeEventFilter(self._shortcut_filter)
        except (RuntimeError, AttributeError):
            pass
        try:
            self.iface.mapCanvas().viewport().removeEventFilter(
                self._shortcut_filter)
        except (RuntimeError, AttributeError):
            pass
        try:
            self.iface.mapCanvas().removeEventFilter(self._shortcut_filter)
        except (RuntimeError, AttributeError):
            pass

    def _on_zone_tool_deactivated(self) -> None:
        """The canvas dropped the zone tool because the user picked another
        QGIS tool. The in-progress points went with it, so the draw hint must
        stop asking for the first point to close a shape that is gone.

        Silent once a zone, a run or a review exists: those paths disarm the
        tool themselves and own the dock state.
        """
        if (self._auto_zone is not None or self._auto_worker is not None
                or self._auto_review is not None):
            return
        if not self.dock_widget:
            return
        try:
            self.dock_widget.set_zone_draw_progress(0)
        except (RuntimeError, AttributeError):
            pass

    # ---- Re-run from the Recent detection history (library) -----------------

    def _on_history_rerun_requested(self, entry: dict) -> None:
        """Recent card "Run again here": rebuild the stored run's exact zone
        and prompt, land on step 2 ready to Detect (never auto-launch).

        Deferred a tick so the library modal has fully closed before the flow
        switch runs (the signal fires while the dialog loop is still
        unwinding)."""
        from ...core.qt_compat import safe_single_shot
        payload = dict(entry) if isinstance(entry, dict) else {}
        safe_single_shot(0, self.dock_widget or self.iface.mainWindow(),
                         lambda: self._history_rerun_here(payload))

    def _on_history_reuse_prompt_requested(self, prompt: str) -> None:
        """Recent card "Same object, new zone": start the Automatic flow on the
        draw-zone step with the prompt prefilled for step 2."""
        from ...core.qt_compat import safe_single_shot
        text = str(prompt or "")
        safe_single_shot(0, self.dock_widget or self.iface.mainWindow(),
                         lambda: self._history_reuse_prompt(text))

    def _history_rerun_here(self, entry: dict) -> None:
        """Same zone + same object. The zone is rebuilt from the shape the run
        was drawn with when the entry kept it, and from the stored extent
        otherwise, transformed from its CRS to the canvas CRS, then fed through
        the normal draw path so the wrong-layer guard, the free-trial cap, the
        badge, grid and credit estimate all apply for free."""
        if self._auto_worker is not None or self._auto_review is not None:
            self._history_rerun_busy_notice()
            return
        prompt = (entry.get("prompt") or "").strip()
        authid = str(entry.get("crs") or "")
        geom = self._zone_geom_from_wkt(entry.get("zone_wkt"), authid)
        if geom is None:
            geom = self._zone_geom_from_extent(entry.get("extent"), authid)
        self._enter_auto_flow_for_history(prompt)
        if geom is None:
            # No usable stored extent: degrade to the new-zone path (the flow is
            # already armed on the draw step, so the user just draws).
            self._track_history_rerun("new_zone")
            return
        # Feed the rebuilt zone through the normal commit path (wrong-layer +
        # free-cap guards, badge, grid, credit estimate, and the jump to step 2).
        self._on_zone_polygon_drawn(geom)
        self._match_stored_tile_count(entry.get("tiles"))
        self._track_history_rerun("same_zone")

    def _match_stored_tile_count(self, wanted) -> None:
        """Move the detail slider to whatever reproduces the old run's tile
        count, so re-running a zone bills and looks like it did before.

        The slider holds a detail level, not a tile count, and the mapping
        depends on the zone and the layer under it. So the levels are walked
        and the closest one wins. Silent on any failure: the seeded default is
        already a usable run.

        Only the levels the slider currently offers are walked, so a stored run
        made outside this object's useful band is NOT reproduced: it lands on
        the nearest level inside it and bills differently. That is deliberate.
        A run that fragmented its objects is not one to reproduce faithfully.
        """
        try:
            target = int(wanted or 0)
        except (TypeError, ValueError):
            return
        if target <= 0:
            return
        dock = self.dock_widget
        layer = self._get_active_raster_layer()
        if dock is None or layer is None or self._auto_zone is None:
            return
        try:
            slider = dock.auto_detail_slider
            zone_in_layer = self._reproject_zone_to_run_crs(self._auto_zone, layer)
            best, best_gap = None, None
            for level in range(slider.minimum(), slider.maximum() + 1):
                grid = self._grid_for_detail(layer, zone_in_layer, level)
                if not grid or grid[3] <= 0:
                    continue
                gap = abs(grid[3] - target)
                if best_gap is None or gap < best_gap:
                    best, best_gap = level, gap
                if gap == 0:
                    break
            if best is None or best == slider.value():
                return
            dock.set_auto_detail_value(best)
            # The user did not touch the slider, but this is still a deliberate
            # level rather than the seed, so the per-prompt re-seed must not
            # overwrite it on the way to step 2.
            self._auto_detail_user_locked = True
            self._auto_detail_lock_prompt = (
                self._resolved_auto_object_class() or "").lower()
            self._update_credit_estimate()
        except (RuntimeError, AttributeError):
            return

    def _history_reuse_prompt(self, prompt: str) -> None:
        """Same object, fresh zone: land on the draw-zone step with the prompt
        prefilled for step 2."""
        if self._auto_worker is not None or self._auto_review is not None:
            self._history_rerun_busy_notice()
            return
        self._enter_auto_flow_for_history((prompt or "").strip())
        self._track_history_rerun("new_zone")

    def _enter_auto_flow_for_history(self, prompt: str) -> None:
        """Bring the Automatic flow to a clean started state (step 1, draw tool
        armed) with ``prompt`` prefilled, reusing the dock's Start path so no
        grid/credit logic is duplicated. Both re-run actions share this base."""
        dock = self.dock_widget
        if dock is None:
            return
        from ..ai_segmentation_dockwidget import Mode
        if dock._mode != Mode.AUTOMATIC:
            try:
                dock._on_mode_selected(Mode.AUTOMATIC)
            except (RuntimeError, AttributeError):
                pass
        if self._tile_manager is None:
            self._setup_auto_mode()
        try:
            self._refresh_auto_credits()
        except (RuntimeError, AttributeError):
            pass
        # Clean slate: drop any zone/canvas left from a mid-flow library open so
        # both actions start from the same predictable base (idempotent; a fresh
        # mode switch has already reset the flow).
        self._reset_auto_flow_to_start(exit_path="new_run")
        try:
            dock._on_auto_start_clicked()  # locks the selected raster, opens step 1
        except (RuntimeError, AttributeError):
            pass
        # Prefill after Start: the start/reset path clears the prompt box, so the
        # object must be set last for it to stick.
        if prompt:
            try:
                dock.set_prompt_text(prompt)
            except (RuntimeError, AttributeError):
                pass

    def _zone_geom_from_wkt(self, wkt, authid: str) -> QgsGeometry | None:
        """The polygon a past run was drawn with, in the canvas CRS, or None.

        None on everything the caller then answers with the bounding box: an
        entry from before the outline was stored, an unreadable shape, an
        unresolvable CRS, a transform the projection refuses. The rebuilt
        polygon goes through the same commit path as a fresh draw, so every
        guard still runs on it.

        The WKT and the authid come off the same record, so the shape is read
        in the CRS its own entry names. Nothing is assumed when the entry
        names none: a zone rebuilt on the wrong ground is a zone the user then
        pays to run.
        """
        if not wkt or not isinstance(wkt, str) or not authid:
            return None
        geom = QgsGeometry.fromWkt(wkt)
        if geom is None or geom.isEmpty():
            return None
        src = QgsCoordinateReferenceSystem(authid)
        if not src.isValid():
            return None
        try:
            canvas_crs = self.iface.mapCanvas().mapSettings().destinationCrs()
        except (RuntimeError, AttributeError):
            return None
        if canvas_crs.isValid() and src != canvas_crs:
            try:
                xform = QgsCoordinateTransform(
                    src, canvas_crs, QgsProject.instance())
                # Anything but success leaves part of the ring moved and
                # part of it where it was, which draws a zone over ground
                # nobody picked.
                if not geometry_op_succeeded(geom.transform(xform)):
                    return None
            except Exception:  # nosec B110 -- antimeridian, invalid CRS, etc.
                return None
        return None if geom.isEmpty() else geom

    def _zone_geom_from_extent(self, extent, authid: str) -> QgsGeometry | None:
        """Rectangle geometry in the canvas CRS from a stored run's extent +
        CRS authid, or None when the entry has no usable zone (old format,
        empty rectangle, or an unresolvable CRS/transform)."""
        if not extent or len(extent) != 4 or not authid:
            return None
        try:
            xmin, ymin, xmax, ymax = (float(v) for v in extent)
        except (TypeError, ValueError):
            return None
        rect = QgsRectangle(xmin, ymin, xmax, ymax)
        if rect.isEmpty() or rect.width() <= 0 or rect.height() <= 0:
            return None
        geom = QgsGeometry.fromRect(rect)
        src = QgsCoordinateReferenceSystem(authid)
        if not src.isValid():
            return None
        try:
            canvas_crs = self.iface.mapCanvas().mapSettings().destinationCrs()
        except (RuntimeError, AttributeError):
            return None
        if canvas_crs.isValid() and src != canvas_crs:
            try:
                xform = QgsCoordinateTransform(
                    src, canvas_crs, QgsProject.instance())
                # A refused transform leaves a half-moved rectangle,
                # which is ground the user never picked.
                if not geometry_op_succeeded(geom.transform(xform)):
                    return None
            except Exception:  # nosec B110 -- antimeridian, invalid CRS, etc.
                return None
            if geom.isEmpty():
                return None
        return geom

    def _history_rerun_busy_notice(self) -> None:
        """Discreet no-op message when a re-run is asked while a detection or
        review is still in flight (mirrors the exemplar-draw guards)."""
        try:
            self.iface.messageBar().pushInfo(
                "AI Segmentation",
                tr("Finish or cancel the current detection before "
                   "re-running a past one."),
            )
        except (RuntimeError, AttributeError):
            pass

    def _track_history_rerun(self, kind: str) -> None:
        """Additive telemetry: kind = 'same_zone' | 'new_zone'. No coordinates."""
        try:
            from ...core import telemetry_session_events
            telemetry_session_events.track_history_rerun(kind)
        except Exception:
            pass  # nosec B110

    def _on_zone_polygon_drawn(self, geom: QgsGeometry) -> None:
        """Store the drawn polygon zone (+ its bounding box) and show the
        persistent outline and tile grid.

        The bounding box becomes ``_auto_zone`` so the whole bbox-based grid and
        render pipeline is unchanged; the polygon is kept separately to cull
        tiles that fall outside the drawn shape (no render, no credits, no
        false positives on empty ground).
        """
        # Repair the raw drawn polygon before any measuring or storage: a
        # self-intersecting or bowtie outline (easy to draw by hand) breaks area
        # math and tile culling. makeValid + keep only polygonal parts; fail-open
        # to the raw polygon when the repair yields nothing usable.
        from ...core.layer_conventions import repair_polygon
        repaired = repair_polygon(QgsGeometry(geom))
        if repaired is not None and not repaired.isEmpty():
            geom = repaired
        # Wrong-layer guard: a zone with no raster data under it would bill
        # credits for blank tiles, so the commit is checked BEFORE any state
        # is stored (interactive path only; the MCP path sets _auto_zone
        # directly and never comes through here).
        overlap = self._zone_layer_overlap_verdict(geom)
        if overlap == "outside":
            self._reject_zone_outside_layer()
            return
        # Free-trial zone cap: a free-tier zone above FREE_TRIAL_MAX_ZONE_KM2
        # is refused BEFORE any state is stored (interactive path only; the
        # MCP/headless paths run their own guard). Subscribers never hit this.
        cap_area = self._free_zone_cap_exceeded_km2(geom)
        if cap_area is not None:
            self._reject_zone_over_free_cap(cap_area)
            return
        self._auto_zone_polygon = QgsGeometry(geom)
        rect = QgsRectangle(geom.boundingBox())
        # Both are canvas CRS numbers from this moment, so the zone is stored
        # with the CRS it was drawn in: the project can change CRS before
        # Detect is pressed.
        self._store_auto_zone(rect)
        # Zone set: the user is now picking prompt/settings, so this is a second
        # natural moment to ensure the backend is warm before Detect (debounced).
        self._maybe_warmup_auto()
        self._show_zone_polygon_band(geom)
        # Seed a good detail level for this zone BEFORE estimating, so the user
        # lands on a resolution that detects well instead of a coarse 1x1.
        self._apply_default_detail(rect)
        self._update_credit_estimate()
        # Zone drawn: disarm the polygon tool and restore the pan tool so the
        # user can move around the map (the QGIS default) instead of being left
        # with a bare cursor while they pick a prompt.
        self._restore_maptool_after_zone()
        if self.dock_widget:
            self.dock_widget.set_auto_zone_state("zone_set")
        try:
            from ...core import telemetry_run_events
            try:
                vtx = int(geom.constGet().vertexCount())
            except Exception:
                vtx = 0
            telemetry_run_events.track_zone_drawn(
                vertices=vtx,
                area_km2=self._zone_geodesic_area_km2(geom),
            )
        except Exception:
            pass  # nosec B110
        if overlap == "partial":
            layer = self._get_active_raster_layer()
            if layer is not None:
                self.iface.messageBar().pushInfo(
                    "AI Segmentation",
                    tr(
                        'Part of your zone is outside "{layer}" - only the '
                        "overlapping area will return objects."
                    ).format(layer=layer.name()),
                )

    # More than half the zone outside the raster: worth telling the user.
    _ZONE_OUTSIDE_INFO_FRACTION = 0.5

    def _zone_layer_overlap_verdict(self, geom: QgsGeometry) -> str:
        """Classify a freshly drawn zone against the selected raster's data
        extent, so a wrong-layer pick is caught at draw time instead of after
        the run bills blank tiles.

        Returns "outside" (no intersection at all: block the commit),
        "partial" (more than half of the zone lies outside the extent:
        proceed with an info message) or "ok". Online XYZ/WMS providers
        report a whole-world extent so the guard naturally never fires for
        them (intended: the footgun is local rasters). Any transform or
        geometry failure skips the check ("ok") rather than blocking the
        user on a false negative.
        """
        layer = self._get_active_raster_layer()
        if layer is None:
            return "ok"
        try:
            if self._needs_canvas_render(layer):
                return "ok"
            extent = layer.extent()
            if extent.isEmpty() or extent.width() <= 0 or extent.height() <= 0:
                return "ok"
            zone = QgsGeometry(geom)  # canvas CRS copy: transform() mutates
            canvas_crs = self.iface.mapCanvas().mapSettings().destinationCrs()
            layer_crs = layer.crs()
            if canvas_crs != layer_crs:
                if not canvas_crs.isValid() or not layer_crs.isValid():
                    return "ok"
                xform = QgsCoordinateTransform(
                    canvas_crs, layer_crs, QgsProject.instance())
                # A half-moved zone would be compared against the raster's
                # extent as if it were on the ground, and the answer would be a
                # warning about the wrong place: skip the check.
                if not geometry_op_succeeded(zone.transform(xform)):
                    return "ok"
                if zone.isEmpty():
                    return "ok"
            extent_geom = QgsGeometry.fromRect(extent)
            if not zone.intersects(extent_geom):
                return "outside"
            zone_area = zone.area()
            if zone_area <= 0:
                return "ok"
            inside_fraction = zone.intersection(extent_geom).area() / zone_area
            if inside_fraction < self._ZONE_OUTSIDE_INFO_FRACTION:
                return "partial"
        except Exception:  # nosec B110 -- guard rail only, never block on failure
            return "ok"
        return "ok"

    def _reject_zone_outside_layer(self) -> None:
        """Block a zone that misses the raster entirely (wrong layer in the
        combo, e.g. drawn over a basemap while a local raster is selected).

        Clears the sketch the same way the delete badge path does and keeps
        the user on the draw step, armed, so they can redraw or Exit to pick
        another layer."""
        layer = self._get_active_raster_layer()
        name = layer.name() if layer is not None else ""
        # Nothing was committed yet and the draw tool is still active, so the
        # badge-style zone_cleared reset lands the dock back on "drawing".
        self._on_zone_cleared()
        self.iface.messageBar().pushWarning(
            "AI Segmentation",
            tr(
                'Your zone is outside "{layer}". Pick the right layer '
                "or draw inside it."
            ).format(layer=name),
        )

    def _zone_geodesic_area_km2(self, geom, crs=None) -> float:
        """Geodesic area (km2, WGS84 ellipsoid) of a zone expressed in ``crs``
        (default: the canvas CRS). Returns 0.0 on any failure so callers fail
        open (a zone that cannot be measured is never blocked).

        ``geom`` may be a QgsGeometry or a QgsRectangle: the zone is stored as
        a rectangle and drawn as a polygon, and handing measureArea a rectangle
        raised a TypeError that a wide except turned into 0.0, which read as a
        zone under every cap.

        The except is deliberately narrow now, so a type error can never hide
        in here again.
        """
        try:
            # make_area_measurer handles the "NONE" project-ellipsoid trap
            # (planar degrees^2 on a geographic CRS); one owner for all
            # geodesic-area measurement setup.
            from ...core.layer_conventions import make_area_measurer
            if crs is None:
                crs = self.iface.mapCanvas().mapSettings().destinationCrs()
            if isinstance(geom, QgsRectangle):
                geom = QgsGeometry.fromRect(geom)
            da = make_area_measurer(crs)
            return max(0.0, da.measureArea(geom) / 1_000_000.0)
        except (TypeError, ValueError, RuntimeError, AttributeError):
            return 0.0

    def _free_zone_cap_exceeded_km2(self, geom, crs=None) -> float | None:
        """Free-trial zone cap check: return the zone's geodesic area (km2)
        when a free-tier user's zone exceeds FREE_TRIAL_MAX_ZONE_KM2, else
        None (zone allowed). Subscribers are never capped. Usage never
        fetched (startup fetch still in flight, or it failed) means the tier
        is UNKNOWN, not free: capping then would reject a paying subscriber's
        valid zone with a free-trial upsell, so the check fails open and the
        server enforces the real quota. ``crs`` is the CRS the geometry is
        expressed in (default: canvas CRS).

        The shape is confined to the raster's own extent before it is measured,
        because that is the surface the run is billed on. Measuring the raw
        drawn zone refused a coastline draw at "2.0 km2, free runs stop at 1.5"
        for a run the server would have billed 1.1."""
        try:
            usage_known = bool(self._last_usage)
            _credits, is_free = self._auto_credit_snapshot()
        except (RuntimeError, AttributeError):
            usage_known, is_free = False, True
        if not usage_known or not is_free:
            return None
        area = self._zone_geodesic_area_km2(
            self._zone_billable_shape(geom, crs), crs)
        if area > free_zone_cap_km2():
            return area
        return None

    def _zone_billable_shape(self, geom, crs=None):
        """``geom`` as the polygon the run is billed on: a QgsGeometry, clipped
        to the raster's extent. Returns the input untouched when the CRS cannot
        be resolved, so the cap can only ever measure less, never more."""
        if isinstance(geom, QgsRectangle):
            geom = QgsGeometry.fromRect(geom)
        if crs is None:
            try:
                crs = self.iface.mapCanvas().mapSettings().destinationCrs()
            except (RuntimeError, AttributeError):
                return geom
        if crs is None or not crs.isValid():
            return geom
        return self._zone_clipped_to_data(geom, crs)

    def _reject_zone_over_free_cap(self, area_km2: float) -> None:
        """Refuse a free-tier zone above FREE_TRIAL_MAX_ZONE_KM2 (contextual
        upsell touchpoint 1): the sketch is cleared the badge way, the user
        stays on the draw step, armed, and the step-1 hero shows the
        subscribe message until a valid zone lands or the flow is exited."""
        self._on_zone_cleared()
        if self.dock_widget:
            try:
                self.dock_widget.set_auto_zone_rejected(area_km2)
            except (RuntimeError, AttributeError):
                pass
        try:
            from ...core import telemetry_run_events
            telemetry_run_events.track_auto_zone_too_large(area_km2=area_km2)
        except Exception:
            pass  # nosec B110

    def _on_zone_vertices_changed(self, count: int) -> None:
        """Live point count from the polygon tool: refresh the draw-zone hint so
        the user always sees what to do next and how to finish."""
        if self.dock_widget:
            try:
                self.dock_widget.set_zone_draw_progress(count)
            except (RuntimeError, AttributeError):
                pass

    def _on_zone_cleared(self) -> None:
        """Clear the zone state and hide all auto mode visuals.

        When the drawing tool is still active (Escape pressed mid-draw), the
        user stays in drawing mode: the next drag starts a fresh zone.
        """
        self._store_auto_zone(None)
        self._auto_zone_polygon = None
        # Exemplars are positioned inside the old zone; a new zone invalidates
        # them, so every zone-bound canvas artifact goes with the zone.
        self._clear_auto_canvas()
        self._update_credit_estimate()
        still_drawing = False
        try:
            still_drawing = self._zone_selection_tool is not None
            still_drawing = still_drawing and self.iface.mapCanvas().mapTool() == self._zone_selection_tool
        except (RuntimeError, AttributeError):
            pass
        if self.dock_widget:
            self.dock_widget.set_auto_zone_state(
                "drawing" if still_drawing else "idle")

    def _tile_grid_allowed(self) -> bool:
        """True only on the pre-Detect setup screen.

        The tile grid is a planning aid: once Detect is clicked it must stay
        off the canvas for the whole run AND the review that follows (the
        review's debug toggle is the one way back to it, and it asks
        explicitly).

        `_auto_grid_suppressed` is the authority here, because every other
        signal below is transient. A run's terminal drops the worker reference
        and the dock's in-run layout BEFORE the review exists, and the finalize
        in between is cooperative: it yields to the event loop for as long as
        the sweep and the object build take. An async reply landing in that gap
        (the post-run credit refresh above all) saw four idle flags, redrew the
        grid, and it then sat over the reviewed detections with nothing left to
        clear it. The latch is armed at the Detect click and released by the two
        owners that mean the flow is done with the canvas:
        `_restore_tile_grid_after_run` (back on the prompt step with the zone
        kept) and `_clear_auto_canvas` (every full exit of the flow).

        The run and review references plus the dock layout flags stay as a
        belt: they still refuse the grid to a live run or review whose latch a
        teardown dropped, and they cover the gap at the START of a run, where
        the dock flips to the in-run layout before the worker exists.
        """
        if getattr(self, "_auto_grid_suppressed", False):
            return False
        if (getattr(self, "_auto_worker", None) is not None or getattr(self, "_auto_review", None) is not None):
            return False
        dock = self.dock_widget
        if dock is None:
            return True
        return not (getattr(dock, "_auto_run_active", False) or getattr(dock, "_auto_review_active", False))

    def _tile_grid_revealed(self) -> bool:
        """True once the user has opened Advanced settings.

        The grid is a control someone asks for, not a thing that appears. It
        is the heaviest overlay this flow draws on the user's imagery, and a
        user who has not asked how their zone is split reads it as damage. So
        it travels with the panel that explains it: shut, no grid; open, the
        slider and the picture of what it does arrive together.

        A dock-less plugin (the MCP surface, a headless run) has no fold to
        open, so it keeps the old behaviour and draws.
        """
        dock = self.dock_widget
        if dock is None:
            return True
        return bool(getattr(dock, "_auto_advanced_open", False))

    def _on_auto_advanced_toggled(self, opened: bool) -> None:
        """The Advanced settings fold flipped: put the grid on the canvas or
        take it off, now, without waiting for the next zone or slider move."""
        if opened:
            self._update_credit_estimate()
        else:
            self._clear_zone_tile_grid()

    def _restore_tile_grid_after_run(self) -> None:
        """The flow is back on the pre-Detect setup screen with the zone kept:
        let the grid draw again, and redraw it with the cost label.

        The single owner of that release. Every path that lands back on the
        prompt step calls it (a failed run, a run that found nothing, an
        aborted finalize, Re-run the whole zone) instead of calling
        `_update_credit_estimate` directly, which the latch would refuse. The
        paths that go back to Start instead drop the zone itself, so they
        release the latch in `_clear_auto_canvas`.
        """
        self._auto_grid_suppressed = False
        self._update_credit_estimate()

    def _update_credit_estimate(self) -> None:
        """Compute credit estimate for current zone + layer and update the grid preview."""
        if self._tile_manager is None:
            return
        # The tile ceiling follows the zone drawn, and the grid refuses on its
        # own copy of it. Arm it before anything computes a grid, or the first
        # estimate after a zone change is judged on the previous zone's
        # ceiling.
        self._auto_zone_tile_cap()
        # The tile-grid preview + cost belong to the pre-Detect state only; the
        # run and the review own the canvas.
        if not self._tile_grid_allowed():
            return
        layer = self._get_active_raster_layer()
        if layer is None:
            self._clear_zone_tile_grid()
            if self.dock_widget:
                self.dock_widget.set_auto_detail_visible(False)
                self._hide_auto_cost_label()
            return

        # The free per-zone cap is checked again here, not only at draw time.
        # The draw-time check fails open while the usage fetch is in flight, so
        # a zone drawn in the first seconds of a session was never measured
        # against the cap; this chokepoint runs on every detail move, layer
        # change and zone edit, by which time the tier is known. The re-entry
        # flag stops the rejection from looping: _reject_zone_over_free_cap
        # clears the zone, which calls straight back into this method.
        _rejecting = getattr(self, "_auto_zone_cap_rejecting", False)
        if self._auto_zone is not None and not _rejecting:
            # A geometry, in the CRS the zone was drawn in. Handing the stored
            # rectangle straight to the measurer raised a TypeError inside sip,
            # which came back as 0.0 km2, so this late cap never once fired and
            # the cold-start hole it exists to close stayed open.
            zone_shape = (self._auto_zone_polygon
                          if self._auto_zone_polygon is not None
                          else QgsGeometry.fromRect(self._auto_zone))
            over_cap = self._free_zone_cap_exceeded_km2(
                QgsGeometry(zone_shape), self._zone_source_crs(self._auto_zone))
            if over_cap is not None:
                self._auto_zone_cap_rejecting = True
                try:
                    self._reject_zone_over_free_cap(over_cap)
                finally:
                    self._auto_zone_cap_rejecting = False
                return

        # Tiling is a user choice for every layer type now; the slider only
        # needs a zone to be meaningful.
        if self.dock_widget:
            self.dock_widget.set_auto_detail_visible(self._auto_zone is not None)
            # Give the slider the band that is worth offering for the object
            # the user named, so the cursor never moves in the void and never
            # into the range where a bigger object comes back in fragments.
            # Done before the grid compute so the estimate below reflects the
            # (possibly) clamped detail value. The free-run cap NEVER shortens
            # the Pro travel: a free user keeps the full fine end and the dock
            # gates Detect instead when the estimate exceeds the cap (see
            # set_auto_credit_estimate), so the locked range is seen, not
            # hidden.
            if self._auto_zone is not None:
                zone_in_layer = self._reproject_zone_to_run_crs(self._auto_zone, layer)
                machine_max = self._max_useful_detail(layer, zone_in_layer)
                low, high = self._detail_window_for_object(
                    layer, zone_in_layer, self._resolved_auto_object_class())
                self.dock_widget.set_auto_detail_range(
                    low, high, object_bound=high < machine_max)
                # The band can move the value the seed just set. Follow it, or
                # a clamp the user never made would read as a user override.
                if getattr(self, "_auto_detail_seeded", None) is not None:
                    self._auto_detail_seeded = max(
                        low, min(high, self._auto_detail_seeded))

        grid = self._compute_auto_grid(layer)
        if grid is None:
            # Online layer with no zone yet (or unresolvable layer): hide the preview quietly.
            self._clear_zone_tile_grid()
            self._hide_auto_cost_label()
            return

        pixel_w = grid["pixel_w"]
        pixel_h = grid["pixel_h"]

        # Compute the grid once: the credit count AND the actual cols x rows
        # both come from it. The grid is NOT always square (a non-square zone
        # gives n x m), so the detail label must show the real shape, not the
        # slider value squared, or it contradicts the credit count.
        # Build the grid WITHOUT the per-run cap, cull it, then cap. The grid
        # covers the zone's bounding box and the run sends only the tiles the
        # drawn polygon touches, so capping first refused zones whose real run
        # was a fraction of the ceiling: the drawn polygon keeps only part of
        # its bounding-box grid, and least of all on a long thin zone.
        tiles_list = self._tile_manager.compute_grid(
            pixel_w, pixel_h, apply_cap=False)
        if tiles_list is not None:
            # Count only tiles inside the drawn polygon and over the raster's
            # own extent, so the estimate matches what the run actually sends.
            tiles_list = self._tiles_in_polygon(
                tiles_list, grid["bbox"], pixel_w, pixel_h, layer,
                grid.get("crs"))
            if len(tiles_list) > self._auto_zone_tile_cap():
                tiles_list = None
        credit_count = len(tiles_list) if tiles_list is not None else -1
        # credit_count == -1 means > MAX_TILES
        self._auto_est_tiles = credit_count  # cached for detail_changed telemetry

        QgsMessageLog.logMessage(
            f"Credit estimate: {pixel_w}x{pixel_h}px -> {credit_count} tile(s)",
            "AI Segmentation",
            level=Qgis.MessageLevel.Info,
        )

        # Only reflect the estimate in the dock while a zone is set; the
        # zone-less path estimates the full raster for the overlay only.
        if self.dock_widget and self._auto_zone is not None:
            # What the run is billed on: the surface of the drawn zone, not the
            # grid. The same function the server is sent (clip polygon in the
            # run CRS, else the zone rectangle in its own CRS), so the card and
            # the bill never disagree. Pushed before the tile estimate, so the
            # MAX_TILES refusal still gets the last word on the same label.
            self.dock_widget.set_auto_zone_surface(self._auto_zone_area_km2())
            self.dock_widget.set_auto_credit_estimate(credit_count)
            # Detail, layer, zone and post-zero returns all pass here, so this
            # is the single chokepoint that re-evaluates the identical-re-run
            # note against the current prompt/detail/example count.
            self._refresh_rerun_guard()
            # GSD guard: warn when the chosen detail leaves the imagery coarser
            # than the detection quality threshold (0.5 m/px ground resolution).
            try:
                zone_in_layer = self._reproject_zone_to_run_crs(self._auto_zone, layer)
                sized = self._grid_for_detail(
                    layer, zone_in_layer, self._get_auto_detail_level())
                if sized is not None:
                    ground_mupp = self._mupp_to_meters(layer, zone_in_layer, sized[2])
                    # Warning cutoff is a server dial (seed.gsd_warn_max_mupp,
                    # cache-only read); the constant is the generic fallback.
                    # An object the server gave a tile-ground floor is read
                    # through a fixed wide window, so while its tile clears
                    # that floor the coarse view is the setting it asks for and
                    # "raise the precision" would be advice to break the run.
                    from ...core.detection_policy import (
                        gsd_warn_max_mupp,
                        object_tile_ceiling_m,
                    )
                    from ...core.tile_manager import TILE_SIZE
                    object_class = self._resolved_auto_object_class()
                    floor_m = self._detail_window_profile(object_class)[1]
                    tile_ground_m = TILE_SIZE * ground_mupp
                    wide_view = floor_m > 0 and tile_ground_m >= floor_m
                    # When the server names the largest tile this object is
                    # known to work at, that is the honest line to warn on, and
                    # it beats one m/px cutoff shared by every object. The
                    # cutoff cannot be both right for a 2 m solar panel and for
                    # a 150 m parcel; the ceiling is per object because the
                    # measurement was. Without one, the generic cutoff stands,
                    # which is the behaviour before this existed.
                    ceiling_m = (object_tile_ceiling_m(object_class)
                                 if object_class else 0.0)
                    too_coarse = (tile_ground_m > ceiling_m if ceiling_m > 0
                                  else ground_mupp >= gsd_warn_max_mupp(0.5))
                    self.dock_widget.set_auto_detail_gsd_warning(
                        too_coarse and not wide_view,
                        can_improve=self._detail_max_clears_coarse(
                            layer, zone_in_layer, ceiling_m),
                    )
                    # Object-aware slider guidance: same debounced chokepoint,
                    # so it tracks drags, prompt commits and zone redraws.
                    self._push_detail_feedback(layer, zone_in_layer, ground_mupp)
            except (RuntimeError, AttributeError):
                pass
            # Same chokepoint re-checks the example too-small warning: raising
            # the slider renders every stored example at more pixels, which
            # can clear a warning shown at draw time (see ExemplarsMixin.
            # _refresh_exemplar_size_warning). Guarded on its own so an
            # exemplar-refresh hiccup never breaks the credit estimate this
            # method exists to compute.
            try:
                self._refresh_exemplar_size_warning()
            except (RuntimeError, AttributeError, TypeError, ValueError):
                pass

        if credit_count == -1:
            # Zone exceeds tile cap: hide the preview, show warning
            self._clear_zone_tile_grid()
            QgsMessageLog.logMessage(
                zone_too_large_message(self._tile_manager.max_tiles),
                "AI Segmentation",
                level=Qgis.MessageLevel.Warning,
            )
            return

        if credit_count > 0 and self._auto_zone is not None:
            self._show_zone_tile_grid(layer, grid)

    def _detail_max_clears_coarse(
        self, layer, zone_in_layer, ceiling_m: float
    ) -> bool:
        """Whether the finest precision on offer would clear the coarse warning.

        The amber line has two fixes and they contradict each other: raise the
        precision, or draw a smaller zone. Which one is true depends on the ground
        the TOP of the slider's own travel reads, so the sentence asks that
        question instead of reading the cursor's position, which told a user to
        raise a precision whose top end is just as coarse.

        False on any failure: that is the sentence that never sends someone to a
        control which cannot help them.
        """
        try:
            from ...core.detection_policy import gsd_warn_max_mupp
            from ...core.tile_manager import TILE_SIZE

            top = int(self.dock_widget.auto_detail_slider.maximum())
            sized = self._grid_for_detail(layer, zone_in_layer, top)
            if sized is None:
                return False
            mupp = self._mupp_to_meters(layer, zone_in_layer, sized[2])
            if mupp <= 0:
                return False
            if ceiling_m > 0:
                return TILE_SIZE * mupp <= ceiling_m
            return mupp < gsd_warn_max_mupp(0.5)
        except (RuntimeError, AttributeError, ValueError, ZeroDivisionError):
            return False

    def _hide_auto_cost_label(self) -> None:
        """Blank the zone-surface label when there is nothing to estimate
        (layer removed, zone dropped); it used to go stale. The monthly wall
        reads the same zone, so it goes with it (set_auto_zone_surface)."""
        if not self.dock_widget:
            return
        try:
            self.dock_widget.set_auto_zone_surface(None)
            self.dock_widget.auto_credit_cost_label.setVisible(False)
        except (RuntimeError, AttributeError):
            pass

    def _show_zone_tile_grid(self, layer, grid: dict, force: bool = False) -> None:
        """Draw the tile grid inside the drawn zone as a canvas rubber band.

        Follows the detail slider live, so the user sees how the zone will
        be split. It is not a price: Automatic bills the surface drawn, so
        a finer grid changes what the run reads and never what it costs.
        Display simplification: the real
        tiles overlap by OVERLAP_FRACTION, which reads on screen as a
        blurry double grid. The preview shows clean equal cells instead:
        same row and column counts, honest cost, readable layout. A rubber
        band, not a memory layer: nothing lands in the project's layer tree.

        Two refusals sit in front of it: the run and review latch
        (_tile_grid_allowed) and the Advanced settings fold
        (_tile_grid_revealed). force=True is the review's debug toggle asking
        for the grid on top of a finished result, and it clears both. The
        latch refusal does NOT clear what is already drawn, so a stray
        recompute cannot wipe the debug overlay the user just switched on.
        """
        if not force and not self._tile_grid_allowed():
            return
        self._clear_zone_tile_grid()
        # After the clear, never before it: a recompute that lands while the
        # fold is shut (a zone edit, a layer swap) must also take away a grid
        # left over from when it was open.
        if not force and not self._tile_grid_revealed():
            return
        if self._tile_manager is None or self._auto_zone is None:
            return
        # Uncapped, like the run and like the estimate above it. The grid
        # covers the zone's BOUNDING BOX and the run sends only the tiles the
        # drawn polygon touches, so a capped build returned None on any zone
        # whose box passes the ceiling while its real run sits well under it.
        # The preview then vanished with no message on exactly the large,
        # slanted zones that most need to show how they are split. The cap
        # still refuses the run itself, upstream, on the culled count.
        tiles = self._tile_manager.compute_grid(
            grid["pixel_w"], grid["pixel_h"], apply_cap=False)
        if not tiles or len(tiles) <= 1:
            return  # a single tile needs no inner grid
        minx, miny, maxx, maxy = grid["bbox"]
        try:
            cols = len({tx for tx, _ty, _tw, _th in tiles})
            rows = len({ty for _tx, ty, _tw, _th in tiles})
            if cols < 1 or rows < 1:
                return

            # The cells below are built from grid["bbox"], so the preview and
            # the clip have to speak the CRS that bbox is in.
            grid_crs = QgsCoordinateReferenceSystem(grid.get("crs") or "") \
                if grid.get("crs") else layer.crs()
            poly = self._polygon_in_run_crs(layer)  # None for rectangle/MCP path
            # Same zone at the same grid gives the same picture. The Detail
            # slider is quantised, so a drag crosses several positions that map
            # to one grid, and every settle used to re-clip every cell against
            # the polygon before drawing what was already on screen.
            # The lookup is an optimisation, never a reason not to draw: any
            # failure building the key falls through to the full path.
            cache = getattr(self, "_zone_grid_geom_cache", None)
            cache_key = None
            try:
                if cache is not None:
                    zone_id = bytes(poly.asWkb()) if poly is not None else b"rect"
                    cache_key = (zone_id, cols, rows, minx, miny, maxx, maxy)
            except (RuntimeError, AttributeError, TypeError):
                cache, cache_key = None, None
            if cache_key is not None:
                cached = cache.get(cache_key)
                if cached is not None:
                    self._zone_grid_rubber_band = self._new_zone_grid_band(
                        cached, grid_crs)
                    return
            # Rectangle zones (or the MCP path with no poly) never need a
            # per-cell clip: the grid already sits flush inside the bbox, so
            # skip the GEOS intersection call entirely for every cell.
            is_rect_zone = poly is None or poly.isGeosEqual(
                QgsGeometry.fromRect(poly.boundingBox()))
            # For a real polygon, prepare a geometry engine once so most
            # cells (the ones fully inside the shape) can be drawn raw via a
            # cheap `contains` check instead of a GEOS intersection; only
            # boundary cells still pay for the intersection.
            engine = None
            if not is_rect_zone:
                engine = QgsGeometry.createGeometryEngine(poly.constGet())
                engine.prepareGeometry()
            # On an axis under one tile the grid bbox is deliberately WIDER
            # than the drawn zone (see _grid_for_detail: the run reads that
            # axis through a full tile of real ground instead of a band the
            # service would stretch). The preview draws the drawn zone, not the
            # render window, so a rectangle zone clips to its own rectangle
            # whenever the grid spills past it. A polygon zone already clips to
            # its shape below, which is inside that rectangle.
            zone_rect = None
            if is_rect_zone:
                try:
                    zr = self._reproject_zone_to_run_crs(self._auto_zone, layer)
                    if zr is not None and (zr.xMaximum() < maxx or zr.yMinimum() > miny):
                        zone_rect = QgsGeometry.fromRect(zr)
                except (RuntimeError, AttributeError, TypeError):
                    zone_rect = None
            step_x = (maxx - minx) / cols
            step_y = (maxy - miny) / rows
            cells = []
            for i in range(cols):
                for j in range(rows):
                    cx0 = minx + i * step_x
                    cy0 = miny + j * step_y
                    cell = QgsGeometry.fromRect(
                        QgsRectangle(cx0, cy0, cx0 + step_x, cy0 + step_y))
                    if not is_rect_zone:
                        if not engine.contains(cell.constGet()):
                            cell = cell.intersection(poly)  # clip to the shape
                            if cell.isEmpty():
                                continue
                    elif zone_rect is not None:
                        cell = cell.intersection(zone_rect)
                        if cell.isEmpty():
                            continue
                    cells.append(cell)
            if not cells:
                return
            # ONE collected geometry + ONE setToGeometry: every addGeometry
            # call recomputes the band's bounding rect and schedules a canvas
            # update, so per-cell adds cost up to MAX_TILES synchronous updates
            # per slider tick. setToGeometry(geom, layer) still reprojects
            # layer CRS -> canvas CRS exactly like the per-cell adds did.
            # Flatten first: a boundary cell clipped by the zone polygon can be
            # a MultiPolygon, and collectGeometry must only see single parts to
            # build one clean MultiPolygon on every QGIS 3.22-4.x build.
            parts = []
            for cell in cells:
                if cell.isMultipart():
                    parts.extend(cell.asGeometryCollection())
                else:
                    parts.append(cell)
            collected = QgsGeometry.collectGeometry(parts)
            if cache is not None and cache_key is not None:
                # A handful of entries covers a slider drag both ways. Drop the
                # oldest rather than grow: a zone redraw changes the key, so an
                # unbounded dict would keep every zone of the session alive.
                if len(cache) >= _ZONE_GRID_CACHE_SIZE:
                    cache.pop(next(iter(cache)))
                cache[cache_key] = collected
            self._zone_grid_rubber_band = self._new_zone_grid_band(collected, grid_crs)
        except (RuntimeError, AttributeError, ZeroDivisionError):
            pass

    def _new_zone_grid_band(self, geom, crs):
        """A styled rubber band showing one already-clipped grid geometry.

        ``crs`` is the CRS ``geom`` is expressed in, which is the run CRS."""
        rb = QgsRubberBand(self.iface.mapCanvas(), PolygonGeometry)
        # Lines only, no cell fill: the grid must NOT tint the zone interior
        # (a coloured fill doubled the blue and read as a background change).
        # DASHED + low opacity, no dark casing: a solid cased grid read as
        # heavy furniture over the imagery; a faint dashed line shows the
        # split without competing with the detections. Same brand blue as the
        # zone outline so the grid stays in one colour family. Each cell is
        # clipped to the polygon so the grid stays inside the drawn shape.
        rb.setColor(QColor(0, 0, 0, 0))  # transparent fill: no interior tint
        # Clearly visible while adjusting detail (the old alpha-110 width-1
        # line was nearly invisible over imagery), but still a DASHED line in
        # the same brand-blue family as the zone outline so it stays coherent
        # and never reads as the heavy solid cased grid we had before.
        rb.setStrokeColor(GRID_LINE)  # brand blue, clearly visible
        rb.setSecondaryStrokeColor(QColor(0, 0, 0, 0))  # no casing: lighter footprint
        rb.setLineStyle(Qt.PenStyle.DashLine)  # dashed: a guide, not furniture
        rb.setWidth(2)
        # CRS overload, NOT the layer: setToGeometry has no QgsMapLayer
        # overload (unlike addGeometry), so passing the raster layer is a
        # TypeError on every QGIS build. The CRS reprojects run -> canvas
        # exactly like the per-cell addGeometry(cell, layer) did.
        rb.setToGeometry(geom, crs)
        return rb

    def _clear_zone_tile_grid(self) -> None:
        """Remove the tile grid preview rubber band from the canvas."""
        if self._zone_grid_rubber_band is not None:
            self._safe_remove_rubber_band(self._zone_grid_rubber_band)
            self._zone_grid_rubber_band = None

    def _store_auto_zone(self, zone: QgsRectangle | None, crs=None) -> None:
        """Set the Automatic zone, always beside the CRS it is expressed in.

        The one way to set a zone, drawn or headless. A zone stored without its
        CRS lets a later project CRS change read its numbers as new ones, and
        the run would then tile, bill and clip ground nobody asked for.

        ``crs`` names the CRS the numbers are in. The canvas answers when it is
        left out, which is right for a draw; a caller that converted the zone
        itself passes the CRS its conversion actually reached, because a failed
        conversion leaves numbers the canvas cannot speak for.

        It also drops the last run's clip artefacts, because they describe the
        zone that RAN and this call replaces it. They outlive their run on
        purpose (the review reads them), and the surface card prefers the clip
        polygon over the drawn one (_auto_billable_zone_geometry), so leaving
        them here made a fresh small zone quote the old run's km2: the monthly
        wall stayed up and Detect stayed grey over a zone that fitted easily.
        The last run's inputs go the same way and for the same reason: the
        estimate card resolves the billable raster through them, so a run on
        raster A followed by a fresh zone over raster B was quoted against A's
        extent while the claim was built against B's.
        Every path that sets or clears a zone comes through here, which is why
        the drop belongs here and not in each of them.
        """
        self._auto_clip_polygon = None
        self._auto_clip_engine = None
        self._auto_run_ctx = None
        if zone is None:
            self._auto_zone = None
            self._forget_zone_crs()
            return
        self._auto_zone = zone
        self._record_zone_crs(zone, crs)

    def _record_zone_crs(self, zone: QgsRectangle, crs=None) -> None:
        """Keep the CRS the zone was drawn in, beside the zone itself.

        The stored coordinates are canvas CRS numbers from the moment of the
        draw. A user can change the project CRS before pressing Detect, and
        reading the canvas CRS then would take those numbers for new ones and
        tile, bill and clip ground nobody drew.
        """
        self._forget_zone_crs()
        try:
            if crs is None:
                crs = self.iface.mapCanvas().mapSettings().destinationCrs()
        except (RuntimeError, AttributeError):
            return
        if crs is None or not crs.isValid():
            return
        self._auto_zone_crs = QgsCoordinateReferenceSystem(crs)
        self._auto_zone_crs_rect = self._zone_rect_key(zone)

    def _forget_zone_crs(self) -> None:
        """Drop the recorded draw CRS, so the canvas answers again."""
        self._auto_zone_crs = None
        self._auto_zone_crs_rect = None

    @staticmethod
    def _zone_rect_key(rect: QgsRectangle) -> tuple[float, float, float, float]:
        """The four numbers that tell one rectangle from another."""
        return (rect.xMinimum(), rect.yMinimum(),
                rect.xMaximum(), rect.yMaximum())

    def _zone_source_crs(self, zone: QgsRectangle | None):
        """The CRS ``zone`` is expressed in. None when unreadable.

        While a zone is live, its recorded draw CRS answers for EVERY rectangle
        these helpers are handed, not for the zone rectangle alone. One zone
        session holds all its canvas drawings in one CRS: the zone, its polygon
        and every example or exclude box, because the exemplar path converts a
        draw made after a project CRS change back into the zone's CRS before it
        stores it. Reading the live canvas instead would take those older
        numbers for new ones and put a paid run's references on the wrong
        ground, which is the same failure the zone itself is protected from.

        With no zone, or once the stored zone stops being the one the CRS was
        recorded for, the live canvas answers again.
        """
        try:
            live = self.iface.mapCanvas().mapSettings().destinationCrs()
        except (RuntimeError, AttributeError):
            return None
        recorded = getattr(self, "_auto_zone_crs", None)
        rect = getattr(self, "_auto_zone_crs_rect", None)
        stored = self._auto_zone
        if (recorded is None or not recorded.isValid() or rect is None
                or stored is None or zone is None):
            return live
        try:
            return recorded if self._zone_rect_key(stored) == rect else live
        except (RuntimeError, AttributeError):
            return live

    def _zone_in_layer_crs(
        self, zone: QgsRectangle, layer: QgsRasterLayer
    ) -> QgsRectangle:
        """Reproject a rectangle from its own CRS to the layer CRS.

        The recorded draw CRS covers the stored zone only; anything else is
        read in the live canvas CRS.

        Returns the original zone if CRS are identical or either is invalid.
        Returns the original zone (graceful fallback) if the transform fails.
        """
        try:
            zone_crs = self._zone_source_crs(zone)
            layer_crs = layer.crs()
        except (RuntimeError, AttributeError):
            return zone

        if zone_crs is None or zone_crs == layer_crs:
            return zone
        if not zone_crs.isValid() or not layer_crs.isValid():
            return zone

        try:
            xform = QgsCoordinateTransform(zone_crs, layer_crs, QgsProject.instance())
            result = xform.transformBoundingBox(zone)
        except Exception:  # nosec B110 -- antimeridian, invalid CRS, etc.
            return zone

        if result.width() <= 0 or result.height() <= 0:
            return zone

        return result

    def _run_crs_for_layer(self, layer: QgsRasterLayer, zone_in_layer: QgsRectangle):
        """The CRS this run renders, tiles and measures in, over ``zone_in_layer``.

        Usually the layer's own CRS. It differs only where the layer's two axes
        cover different ground distances, which is a geographic CRS: there the
        run moves to ground metres so a rendered pixel is square on the ground
        and every length the run compares means the same thing on both axes.

        One entry of cache, keyed on the layer and the exact zone, because the
        detail slider asks for the same zone once per level in a tight loop.
        """
        from ...core.layer_conventions import pick_run_crs

        try:
            layer_crs = layer.crs()
            key = self._run_crs_memo_key(layer_crs, zone_in_layer)
        except (RuntimeError, AttributeError):
            return None
        cached = getattr(self, "_auto_run_crs_memo", None)
        if cached is not None and cached[0] == key:
            return cached[1]
        try:
            run_crs = pick_run_crs(layer_crs, zone_in_layer)
            # Prove the move before committing to it. Everything downstream
            # reads the CRS back rather than the rectangle that produced it, so
            # a CRS accepted here and a transform that fails later would leave
            # the run naming one CRS while its extents sit in another, and every
            # tile of it would render the wrong ground and still be billed.
            if run_crs is not None and run_crs != layer_crs:
                moved = QgsCoordinateTransform(
                    layer_crs, run_crs, QgsProject.instance()
                ).transformBoundingBox(zone_in_layer)
                if moved.width() <= 0 or moved.height() <= 0:
                    run_crs = layer_crs
        except (RuntimeError, AttributeError, TypeError, ValueError):
            run_crs = layer_crs
        except Exception:  # noqa: BLE001 -- antimeridian, unusable CRS  # nosec B110
            run_crs = layer_crs
        self._auto_run_crs_memo = (key, run_crs)
        return run_crs

    @staticmethod
    def _run_crs_memo_key(layer_crs, zone_in_layer):
        """Everything the run CRS depends on, so a change to any of it re-asks.

        The project CRS is in here because a metric project CRS is preferred
        over a zone's own UTM zone, so changing it between drawing and running
        would otherwise keep the answer from before the change."""
        try:
            project_authid = QgsProject.instance().crs().authid()
        except (RuntimeError, AttributeError):
            project_authid = ""
        return (layer_crs.authid(), project_authid,
                zone_in_layer.xMinimum(), zone_in_layer.yMinimum(),
                zone_in_layer.xMaximum(), zone_in_layer.yMaximum())

    def _run_crs_now(self, layer: QgsRasterLayer):
        """The run CRS resolved by the most recent zone reprojection for ``layer``.

        Every grid computation reprojects its zone first, so the answer is that
        zone's. Falls back to the layer CRS when no zone has been reprojected
        yet or the memo belongs to another layer, which is what the grid math
        assumed before there was a run CRS at all.
        """
        try:
            layer_crs = layer.crs()
            memo = getattr(self, "_auto_run_crs_memo", None)
            if memo is None:
                return layer_crs
            project_authid = QgsProject.instance().crs().authid()
            if memo[0][0] == layer_crs.authid() and memo[0][1] == project_authid:
                return memo[1]
            return layer_crs
        except (RuntimeError, AttributeError, IndexError, TypeError):
            return None

    def _reproject_zone_to_run_crs(
        self, zone: QgsRectangle, layer: QgsRasterLayer
    ) -> QgsRectangle:
        """Any canvas-CRS rectangle into the run CRS.

        Identical to the layer CRS for every layer whose axes already agree on
        the ground, so those runs are unchanged.

        The TARGET CRS is resolved from the DRAWN ZONE, never from ``zone``, so
        an exemplar box near the edge of a wide zone cannot land in a different
        projected zone from the run it belongs to, nor answer for the run when
        something later asks which CRS it is in.

        The SOURCE CRS is per rectangle: the stored zone reads the CRS it was
        drawn in, every other rectangle reads the live canvas.

        On any failure the run falls back to the layer CRS, and the resolved
        CRS falls back with it: half a run in one CRS and half in another is the
        one outcome nothing downstream would notice.
        """
        anchor = self._auto_zone if self._auto_zone is not None else zone
        anchor_in_layer = self._zone_in_layer_crs(anchor, layer)
        zone_in_layer = (anchor_in_layer if anchor is zone
                         else self._zone_in_layer_crs(zone, layer))
        run_crs = self._run_crs_for_layer(layer, anchor_in_layer)
        try:
            if run_crs is None or run_crs == layer.crs():
                return zone_in_layer
            zone_crs = self._zone_source_crs(zone)
            drawn_in = zone_crs is not None and zone_crs.isValid()
            source_crs = zone_crs if drawn_in else layer.crs()
            xform = QgsCoordinateTransform(source_crs, run_crs, QgsProject.instance())
            source_zone = zone if drawn_in else zone_in_layer
            result = xform.transformBoundingBox(source_zone)
            if result.width() <= 0 or result.height() <= 0:
                raise ValueError("degenerate rectangle in the run CRS")
        except Exception:  # noqa: BLE001 -- antimeridian, invalid CRS, etc.
            self._forget_run_crs(layer, anchor_in_layer)
            return zone_in_layer
        return result

    def _forget_run_crs(self, layer: QgsRasterLayer, zone_in_layer: QgsRectangle) -> None:
        """Pin the run back to the layer's own CRS after a failed reprojection.

        Whoever asks next reads the layer CRS, which is the CRS the rectangle
        just handed back is in, so the run stays in one piece."""
        try:
            layer_crs = layer.crs()
            self._auto_run_crs_memo = (
                self._run_crs_memo_key(layer_crs, zone_in_layer), layer_crs)
        except (RuntimeError, AttributeError):
            self._auto_run_crs_memo = None

    @staticmethod
    def _prepare_clip_engine(clip_geom):
        """Build a prepared GEOS engine for the zone clip polygon.

        Every detection in the pump is tested against this single fixed polygon,
        so preparing it once (GEOS builds an edge index) makes the per-detection
        contains() check cheap. Returns None when there is no clip (rectangle/MCP
        path) or on any failure, so the caller falls back to plain
        geom.intersection(clip)."""
        if clip_geom is None or clip_geom.isEmpty():
            return None
        try:
            engine = QgsGeometry.createGeometryEngine(clip_geom.constGet())
            engine.prepareGeometry()
            return engine
        except Exception:  # noqa: BLE001 - optimisation only; fall back on failure
            return None

    def _polygon_in_run_crs(self, layer):
        """The drawn polygon zone reprojected to the run CRS, or None when no
        polygon was drawn (e.g. the MCP/headless path sets only the bbox).

        Every detection is clipped against this polygon, and the detections are
        in the run CRS, so the polygon has to be too: a clip tested in a CRS the
        detections do not use throws every one of them away."""
        if self._auto_zone_polygon is None:
            return None
        geom = QgsGeometry(self._auto_zone_polygon)  # copy, in the drawn CRS
        try:
            # The polygon and the zone rectangle are stored together, in the
            # same CRS, so the rectangle answers for both. Reading it rather
            # than the polygon's own box keeps the clip in the CRS the run
            # resolves from that same rectangle.
            zone_rect = self._auto_zone
            if zone_rect is None:
                zone_rect = geom.boundingBox()
            zone_crs = self._zone_source_crs(zone_rect)
            target_crs = self._run_crs_for_layer(
                layer, self._zone_in_layer_crs(zone_rect, layer))
            if target_crs is None:
                target_crs = layer.crs()
        except (RuntimeError, AttributeError):
            return None
        if zone_crs is None or zone_crs == target_crs:
            return geom
        if not zone_crs.isValid() or not target_crs.isValid():
            return geom
        try:
            xform = QgsCoordinateTransform(zone_crs, target_crs, QgsProject.instance())
            # A half-moved clip polygon is worse than none: every detection
            # is tested against it, so the run would come back empty with
            # nothing to say why.
            if not geometry_op_succeeded(geom.transform(xform)):
                return None
        except Exception:  # nosec B110 -- antimeridian, invalid CRS, etc.
            return None
        return geom

    def _tiles_in_polygon(self, tiles, bbox, pixel_w, pixel_h, layer,
                          crs_authid=None):
        """Keep only tiles whose geographic footprint touches the drawn polygon
        AND the raster's own extent, so ground outside the shape and ground the
        image has no data for are never rendered or billed.

        tiles: list of (tx, ty, tw, th) pixel rects in the rendered image space.
        bbox:  (minx, miny, maxx, maxy) of that image in the RUN CRS (image row
               0 maps to maxy; pixel Y grows downward).
        crs_authid: authid of that run CRS, needed to bring the layer extent
               into the same units; the extent test is skipped without it.
        Returns tiles unchanged when neither test applies (no polygon drawn
        and no usable extent, the rectangle/MCP path on an online source) or on
        any error. An EMPTY list is a real answer: the zone covers no ground on
        this layer. Handing the uncut grid back instead billed every tile of it
        for a run whose every detection is then clipped away."""
        poly = self._polygon_in_run_crs(layer)
        if poly is not None and poly.isEmpty():
            poly = None
        # The raster's extent in the run CRS. An online source answers with a
        # world-sized or null extent, so this is a no-op there.
        data_bb = None
        if layer is not None and crs_authid:
            data_bb = self._layer_extent_in_run_crs(layer, crs_authid)
            if data_bb is not None and data_bb.isEmpty():
                data_bb = None
        if (poly is None and data_bb is None) or not tiles:
            return tiles
        if pixel_w <= 0 or pixel_h <= 0:
            return tiles
        minx, _miny, maxx, maxy = bbox
        span_x = maxx - minx
        span_y = maxy - bbox[1]
        # Prepare the (fixed) clip polygon once so the per-tile test uses GEOS's
        # edge index instead of an unprepared intersects each call; this runs at
        # run start AND on every credit-estimate refresh, so it is worth it. Also
        # bbox-cull cheaply first: tiles in the image corners outside the drawn
        # shape's extent skip the GEOS call entirely.
        engine = self._prepare_clip_engine(poly) if poly is not None else None
        pbb = poly.boundingBox() if poly is not None else None
        kept = []
        for tile in tiles:
            tx, ty, tw, th = tile
            gx0 = minx + (tx / pixel_w) * span_x
            gx1 = minx + ((tx + tw) / pixel_w) * span_x
            gy1 = maxy - (ty / pixel_h) * span_y          # top edge (row ty)
            gy0 = maxy - ((ty + th) / pixel_h) * span_y   # bottom edge
            if data_bb is not None and (
                    gx1 < data_bb.xMinimum() or gx0 > data_bb.xMaximum()
                    or gy1 < data_bb.yMinimum() or gy0 > data_bb.yMaximum()):
                continue  # tile sits off the image; no data to detect on
            if pbb is None:
                kept.append(tile)
                continue
            if gx1 < pbb.xMinimum() or gx0 > pbb.xMaximum() or gy1 < pbb.yMinimum() or gy0 > pbb.yMaximum():
                continue  # tile extent does not even overlap the polygon bbox
            cell = QgsGeometry.fromRect(QgsRectangle(gx0, gy0, gx1, gy1))
            if engine is not None:
                if engine.intersects(cell.constGet()):
                    kept.append(tile)
            elif cell.intersects(poly):
                kept.append(tile)
        return kept

    def _show_zone_polygon_band(self, geom: QgsGeometry) -> None:
        """Persistent branded outline of the drawn polygon zone + delete badge.

        Follows the polygon shape (no rectangle special-case). The geometry is
        already in canvas CRS, so setToGeometry is called with layer=None (no
        reprojection)."""
        self._clear_zone_rubber_band()
        try:
            canvas = self.iface.mapCanvas()
            rb = QgsRubberBand(canvas, PolygonGeometry)
            # Very light fill so the imagery underneath stays readable; the
            # zone reads from its outline, not from a heavy tint.
            rb.setColor(ZONE_FILL)         # blue, ~7% opacity
            rb.setStrokeColor(ZONE_STROKE)  # blue outline, kept prominent
            rb.setWidth(2)
            rb.setToGeometry(geom, None)
            self._zone_rubber_band = rb
            # Anchor the x on the shape's topmost vertex: the bbox top-right
            # corner often lands in empty space outside an irregular polygon.
            self._show_zone_delete_badge(canvas, anchor=self._polygon_badge_anchor(geom))
        except (RuntimeError, AttributeError):
            pass

    def _polygon_badge_anchor(self, geom: QgsGeometry) -> QgsPointXY:
        """Anchor point for the x badge on a polygon zone: the topmost vertex,
        tie-broken to the right. Keeps the handle attached to the drawn shape
        instead of floating at the bounding-box corner (often empty space)."""
        try:
            ring = geom.asPolygon()[0]
        except (IndexError, TypeError):
            bb = geom.boundingBox()
            return QgsPointXY(bb.xMaximum(), bb.yMaximum())
        best = ring[0]
        for p in ring[1:]:
            if p.y() > best.y() or (p.y() == best.y() and p.x() > best.x()):
                best = p
        return QgsPointXY(best)

    def _show_zone_delete_badge(self, canvas, rect: QgsRectangle = None,
                                anchor: QgsPointXY = None) -> None:
        """Anchor a x badge to the zone (top-right corner for a rectangle, or an
        explicit anchor point for a polygon). Mirrors AI Edit."""
        from ..zone_selection_maptool import ZoneBadgeClickFilter, ZoneDeleteBadge, ZoneEscapeFilter
        self._remove_zone_delete_badge()
        if anchor is None:
            anchor = QgsPointXY(rect.xMaximum(), rect.yMaximum())
        badge = ZoneDeleteBadge(canvas)
        badge.set_anchor(anchor)
        self._zone_delete_badge = badge
        # The active map tool eats canvas mouse events before scene items,
        # so badge clicks are caught at the viewport level instead.
        self._zone_badge_filter = ZoneBadgeClickFilter(
            badge, self._on_zone_badge_clicked, parent=canvas)
        canvas.viewport().installEventFilter(self._zone_badge_filter)
        self._zone_escape_filter = ZoneEscapeFilter(
            self._on_zone_escape, parent=canvas)
        canvas.installEventFilter(self._zone_escape_filter)

    def _remove_zone_delete_badge(self) -> None:
        # Both filters are parented to the canvas and hold a bound method of the
        # plugin controller, so dropping the Python reference alone leaves them
        # alive as canvas children for the rest of the session, with the whole
        # controller behind them. deleteLater ends them for good.
        if self._zone_escape_filter is not None:
            try:
                self.iface.mapCanvas().removeEventFilter(self._zone_escape_filter)
            except (RuntimeError, AttributeError):
                pass
            try:
                self._zone_escape_filter.deleteLater()
            except (RuntimeError, AttributeError):
                pass
            self._zone_escape_filter = None
        if self._zone_badge_filter is not None:
            try:
                self.iface.mapCanvas().viewport().removeEventFilter(
                    self._zone_badge_filter)
            except (RuntimeError, AttributeError):
                pass
            try:
                self._zone_badge_filter.deleteLater()
            except (RuntimeError, AttributeError):
                pass
            self._zone_badge_filter = None
        if self._zone_delete_badge is not None:
            try:
                scene = self.iface.mapCanvas().scene()
                scene.removeItem(self._zone_delete_badge)
            except (RuntimeError, AttributeError):
                pass
            self._zone_delete_badge = None

    def _on_zone_badge_clicked(self) -> None:
        """Canvas x badge: drop the zone and send the dock back to step 2."""
        if self._auto_worker is not None:
            return  # the zone belongs to the running job; cancel that first
        # A pending review is billed work: never silently autosave it here.
        # Route through the Save/Discard/Cancel dialog instead.
        if self._auto_review is not None:
            self._on_auto_review_exit_clicked()
            return
        self._discard_auto_review()
        self._on_zone_cleared()
        if self.dock_widget:
            self.dock_widget.on_zone_deleted_from_canvas()

    def _route_escape(self) -> bool:
        """Single decision point for Escape in the Automatic flow (K2). Every
        Escape channel (dock shortcut, canvas filter) delegates here; no
        sibling escape decision logic may exist elsewhere.

        Precedence:
          - an armed example-box draw: disarm it (cancel the draw);
          - a run in flight: soft Cancel (partials salvaged into the review);
          - a pending review: the Save/Discard/Cancel exit dialog, so a billed
            result is never silently dropped nor silently autosaved;
          - a zone draw with points placed: clear the points, stay drawing
            (first Escape stage);
          - otherwise (empty draw canvas or the prompt step): exit the flow
            (second Escape stage, same as the Exit button).
        Returns True when consumed.
        """
        if self._exemplar_maptool is not None:
            # Exemplar draw stays SINGLE-stage: the sketch is throwaway, so
            # Escape disarms immediately regardless of points placed.
            self._restore_maptool_after_exemplar()
            return True
        if self._auto_worker is not None:
            self._on_auto_cancel_clicked()  # running: Escape = soft Cancel
            return True
        if getattr(self, "_auto_finalize_state", None) is not None:
            # Between the last tile and the review, with the "building the
            # shapes" card up. The worker is gone and the review is not built
            # yet, so Escape used to fall all the way through to the draw-step
            # exit: that discarded the whole run, and its rescue export found
            # no review to save, so a run the user had already paid for went
            # with it. Swallow the key instead. This window is short, it is
            # already announced on screen, and Cancel is not on offer in it.
            return True
        if self._auto_review is not None:
            # An armed Merge pick owns Escape first, which is what the shortcut
            # list promises. Without this branch Escape cleared the selection
            # and left the merge armed with its picks.
            if getattr(self, "_shape_edit_mode", None) == KIND_MERGE:
                self._on_shape_draw_cancelled()
                return True
            # A manual edit session owns Escape before anything else: it drops
            # the line being traced, or ends the session. Without this branch
            # Escape fell through to the review exit and asked to save the whole
            # run mid-edit.
            if getattr(self, "_qgis_bridge_active", False):
                return self._route_escape_qgis_bridge()
            # The AI-assisted Add lane owns Escape first: cancel the in-progress
            # outline, or leave Add (folding what was kept) when nothing is drawn.
            if getattr(self, "_refine_add_mode_active", False):
                return self._route_escape_add_mode()
            # A live AI fix session ends on Escape, folding its edits (the Done
            # equivalent) and landing back on the resting Correct step.
            if getattr(self, "_refine_handoff_active", False):
                self._on_reshape_done()
                return True
            # In Correct, Escape first clears the current shape selection. It
            # mirrors the resting pick tool and prevents a reflexive Escape
            # from opening the review-exit dialog while an object is selected.
            on_correct = getattr(self, "_auto_review_step", 0) == 1
            if on_correct and getattr(self, "_correct_selected_idx", None) is not None:
                self._set_correct_selection(None)
                return True
            self._on_auto_review_exit_clicked()
            return True
        # Two-stage Escape on the draw step. Single-fire by construction: per
        # press exactly ONE channel runs (the dock QShortcut consumes the key
        # before delivery, the canvas ZoneEscapeFilter consumes the KeyPress
        # before the tool, and the tool's own keyPressEvent runs only when
        # neither did), and the maptool mirrors this same two-stage decision.
        tool = self._zone_selection_tool
        try:
            mid_draw = tool is not None and self.iface.mapCanvas().mapTool() is tool and tool.has_points()
        except (RuntimeError, AttributeError):
            mid_draw = False
        if mid_draw:
            tool.clear_selection()  # emits zone_cleared; the user stays drawing
            return True
        self._on_auto_exit_clicked()
        return True

    def _route_enter(self) -> bool:
        """Single decision point for Enter in the Automatic flow (K2): review
        -> Export the visible polygons; prompt step -> Detect; no-op while a
        run is in flight. Returns True when consumed."""
        dock = self.dock_widget
        if dock is None or self._auto_worker is not None:
            return False
        if self._auto_review is not None:
            try:
                if dock.auto_export_btn.isVisible() and dock.auto_export_btn.isEnabled():
                    self._on_auto_export_clicked()
                    return True
            except (RuntimeError, AttributeError):
                pass
            return False
        try:
            if dock.auto_detect_btn.isVisible() and dock.auto_detect_btn.isEnabled():
                self._on_auto_detect_requested()
                return True
        except (RuntimeError, AttributeError):
            pass
        return False

    def _on_zone_escape(self) -> bool:
        """Escape from the canvas filter: delegate to the single dispatcher.

        Only the armed example draw is special-cased: the draw map tool
        cancels its own draw, so the event must fall through (return False)
        instead of being consumed here.
        """
        if self._exemplar_maptool is not None:
            # The example draw tool handles Escape itself (disarm via its
            # zone_cleared / back_requested signals).
            return False
        return self._route_escape()

    def _on_auto_escape_shortcut(self) -> None:
        """Dock-level Escape (works when the canvas is not focused / on step 1).

        Pure delegate: the decision lives in _route_escape.
        """
        self._route_escape()

    def _set_zone_badge_enabled(self, enabled: bool) -> None:
        """Grey the canvas x badge out while a detection run is in flight."""
        if self._zone_delete_badge is not None:
            try:
                self._zone_delete_badge.set_enabled(enabled)
            except (RuntimeError, AttributeError):
                pass

    def _set_zone_band_fill_visible(self, visible: bool) -> None:
        """Toggle the zone polygon's light blue fill. Hidden during the post-run
        review so the detections read cleanly with no blue wash over them; the
        blue outline stays, so the zone boundary is still clear. The fill is
        restored to its light tint whenever a new zone is drawn (the band is
        rebuilt then), so this only needs to turn it off."""
        rb = self._zone_rubber_band
        if rb is None:
            return
        try:
            from qgis.PyQt.QtGui import QColor
            rb.setFillColor(
                ZONE_FILL if visible else QColor(0, 0, 0, 0))
            rb.update()
        except (RuntimeError, AttributeError):
            pass

    def _clear_zone_rubber_band(self) -> None:
        """Remove the persistent zone rubber band from the canvas scene."""
        self._remove_zone_delete_badge()
        self._clear_zone_tile_grid()
        if self._zone_rubber_band is not None:
            self._safe_remove_rubber_band(self._zone_rubber_band)
            self._zone_rubber_band = None

    def _set_auto_zone_overlays_visible(self, visible: bool) -> None:
        """Show/hide the tile grid and the zone's delete badge WITHOUT removing
        them (their state survives). A fix session or an AI add hides them: the
        grid is noise over the one shape being worked on, and the badge would
        delete the zone from under a live edit. Back to review restores both.

        The zone OUTLINE is deliberately NOT in this list. It is how the user
        knows which ground they are correcting, and taking it down left them
        adding polygons on a canvas with no boundary at all.
        """
        for attr in ("_zone_grid_rubber_band", "_zone_delete_badge"):
            item = getattr(self, attr, None)
            if item is None:
                continue
            try:
                item.setVisible(visible)
            except (RuntimeError, AttributeError):
                pass

    def _clear_auto_canvas(self) -> None:
        """Single owner for removing every Automatic-flow canvas artifact
        (invariant I6): exemplar boxes + their bands, the zone outline band
        (with its x badge and tile grid), and the live selection layer.

        Each removal is guarded and idempotent, so EVERY exit of the flow
        (Exit, Finish, mode switch, project clear, teardown, zone redraw)
        shares this one cleanup instead of repeating the list inline.
        """
        self._clear_exemplars()
        self._clear_zone_rubber_band()  # also removes the x badge + tile grid
        self._remove_auto_selection_layer()
        # The run's hold on the grid dies with the artifacts it protected, so
        # the next zone drawn gets its preview and cost back.
        self._auto_grid_suppressed = False

    def _on_project_cleared_auto(self) -> None:
        """Full teardown when the project is cleared or replaced (T14).

        Order matters: fold live handoff edits into the held review (hand
        work), then drop the review WITHOUT autosave (the target project is
        gone, so an export would write into the void), stop the worker, clear
        the handoff flags on both sides, end any manual session, and remove
        every canvas artifact so nothing strays into the fresh project.
        """
        try:
            if getattr(self, "_refine_handoff_active", False) and self.saved_polygons:
                self._collect_manual_refine_into_review()
        except Exception as exc:  # noqa: BLE001 -- teardown must never raise mid-signal
            # Same loss as the discard path: hand edits absent from the save.
            try:
                from ...core.telemetry_errors import track_plugin_error
                track_plugin_error(stage="segment",
                                   error_code="review_fold_edits_failed",
                                   message=type(exc).__name__)
            except Exception:  # noqa: BLE001  # nosec B110
                pass
        # Persist a still-pending (billed) review before dropping it: a paid
        # detection must survive a project switch too, not just Finish/Exit. The
        # export falls back to a standalone GeoPackage when the shared project
        # gpkg is not writable, so it stays safe even as the old project winds
        # down. Runs after the handoff fold-in above so hand edits are included.
        self._autosave_pending_auto_review()
        self._auto_review = None
        self._stop_auto_detection()  # hard teardown; joined later via cancelled
        self._refine_handoff_active = False
        self._refine_add_mode_active = False
        self._ai_add_install_pending = False
        self._drop_cloud_correct_predictor()
        if self.dock_widget and getattr(self.dock_widget, "_refine_handoff", False):
            try:
                from ..ai_segmentation_dockwidget import Mode
                self.dock_widget.end_refine_handoff(Mode.INTERACTIVE)
            except (RuntimeError, AttributeError):
                pass
        try:
            self._teardown_manual_session()
        except Exception:
            pass  # nosec B110 -- teardown must never raise mid-signal
        self._store_auto_zone(None)
        self._auto_zone_polygon = None
        self._clear_auto_canvas()
        if self.dock_widget:
            try:
                self.dock_widget.reset_auto_to_start()
            except (RuntimeError, AttributeError):
                pass
        QgsMessageLog.logMessage(
            "Project closed: automatic run stopped.",
            "AI Segmentation", level=Qgis.MessageLevel.Info,
        )
