"""Automatic mode state: steps, zone, credits, run/review display updates.

Part of AISegmentationDockWidget (see ai_segmentation_dockwidget.py);
split out so agents and humans work on one concern per file. Methods
are plain mixin members: widgets/signals live on the dock instance.
"""
from __future__ import annotations


from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import (
    QAbstractSpinBox,
    QApplication,
    QComboBox,
    QLineEdit,
)


from ...core.i18n import tr
from ...core.review_defaults import (
    AUTO_REVIEW_CLEAN_DEFAULT as _AUTO_REVIEW_CLEAN_DEFAULT,
    AUTO_REVIEW_EXPAND_DEFAULT as _AUTO_REVIEW_EXPAND_DEFAULT,
    AUTO_REVIEW_FILL_HOLES_DEFAULT as _AUTO_REVIEW_FILL_HOLES_DEFAULT,
    AUTO_REVIEW_ORTHO_DEFAULT as _AUTO_REVIEW_ORTHO_DEFAULT,
    AUTO_REVIEW_SIMPLIFY_DEFAULT as _AUTO_REVIEW_SIMPLIFY_DEFAULT,
    AUTO_REVIEW_SMOOTH_DEFAULT as _AUTO_REVIEW_SMOOTH_DEFAULT,
)
from ...core.tile_manager import MAX_DETAIL_LEVEL
from .prompt_guard import is_known_object, validate_prompt
from .styles import (
    ERROR_TEXT,
    _snap_review_conf,
)
from .widgets import (
    Mode,
)


class DockAutoStateMixin:
    """Automatic mode state: steps, zone, credits, run/review display updates."""

    # ---- Automatic mode helpers ------------------------------------------------

    def _on_upgrade_clicked(self) -> None:
        from qgis.PyQt.QtCore import QUrl
        from qgis.PyQt.QtGui import QDesktopServices
        try:
            from ...core import telemetry
            sender = self.sender()
            if sender is getattr(self, "_subscribe_pill", None):
                source = "subscribe_pill"
            elif sender is getattr(self, "auto_exhausted_subscribe_link", None):
                source = "exhausted_status"
            else:
                source = "upsell_card"
            telemetry.track_pro_upsell_clicked(source=source)
        except Exception:
            pass  # nosec B110
        url = self._build_upgrade_url()
        QDesktopServices.openUrl(QUrl(url))

    def _build_upgrade_url(self) -> str:
        from ...core.activation_manager import get_dashboard_url
        base = get_dashboard_url()
        return (
            f"{base}?utm_source=qgis&utm_medium=plugin"
            "&utm_campaign=ai-segmentation-pro&utm_content=upsell_card"
        )

    def _on_shape_control_changed(self, control: str, value) -> None:
        """A review shape-refine control changed: re-derive the visible set (via
        the debounced auto_refine_changed) and track the adjustment once per
        control per review."""
        self.auto_refine_changed.emit()
        try:
            tracked = getattr(self, "_review_shape_tracked", None)
            if tracked is None:
                tracked = set()
                self._review_shape_tracked = tracked
            if control not in tracked:
                tracked.add(control)
                from ...core import telemetry
                telemetry.track_review_shape_adjusted(control=control, value=value)
        except Exception:
            pass  # nosec B110

    def _on_auto_layer_changed(self, layer) -> None:
        # A raster appearing or vanishing must show/hide the combo and the
        # no-rasters warning live, not only on the next mode switch.
        if self._mode == Mode.AUTOMATIC:
            self._update_ui_state_automatic()
        self._update_auto_detect_enabled()
        self._refresh_auto_layer_lock()

    def _on_auto_start_clicked(self) -> None:
        """Step 0 "Start": lock the chosen raster and open the draw-zone step.

        Mirrors the Interactive Start: from here on the layer combo is greyed
        and read-only (the header still names the locked raster) until the
        user clicks Exit.
        """
        layer = self.auto_layer_combo.currentLayer()
        if layer is None:
            return
        # Consent is NOT asked here: the checkbox sits on the last step, right
        # above Detect, and the first Detect seals it (see seal_tos_consent).
        # Clear any leftover "Saved N polygon(s)" banner from a previous run so
        # it never lingers into this fresh run's steps (it is shown on the Start
        # step right after Finish, and belongs only to that just-finished run).
        self.set_auto_status("idle")
        # Leaving step 0 for a fresh run retires the previous run's value recap.
        self.clear_last_run_recap()
        try:
            from ...core import telemetry
            telemetry.track_auto_start_clicked(
                layer_kind=self._auto_layer_kind(layer),
                has_credits_known=self._auto_credits is not None,
            )
        except Exception:
            pass  # nosec B110
        self._auto_started = True
        self._go_to_auto_step(1)

    @staticmethod
    def _auto_layer_kind(layer) -> str:
        """Coarse raster-source class for telemetry: local / xyz / wms / other."""
        try:
            provider = (layer.dataProvider().name() or "").lower()
            if provider == "gdal":
                return "local"
            source = (layer.source() or "").lower()
            if "type=xyz" in source:
                return "xyz"
            if provider in ("wms", "wmts"):
                return "wms"
            return provider or "other"
        except Exception:
            return "other"

    def reset_auto_to_start(self) -> None:
        """Exit the Automatic flow back to the Start step, layer editable again.

        Called by the dock Exit button (via the plugin, which first clears the
        zone + any review) and on any hard teardown that should unlock the
        layer. Idempotent.
        """
        self._auto_started = False
        # Leaving the flow retires the free-trial zone-cap message.
        self.set_auto_zone_rejected(None)
        self.hide_auto_exemplar_nudge()
        self.set_auto_exhausted_subscribe_visible(False)
        # Clear any leftover run status ("Saved N polygon(s) to ...") so it never
        # lingers on the Start / prompt page of the next run.
        self.set_auto_status("idle")
        # Back at Start the footer is always usable again (covers any hard
        # teardown that bypasses set_auto_run_active(False), e.g. a mode switch
        # mid-run): never leave the gear/help locked once the flow is reset.
        self.set_footer_controls_locked(False)
        # Likewise re-enable the mode toggle, disabled during review: at Start
        # there is no review to protect (skip while a handoff owns the toggle).
        if not self._refine_handoff:
            self.mode_switch.setEnabled(True)
            self.mode_switch.setToolTip("")
        self.auto_prompt_input.blockSignals(True)
        self.auto_prompt_input.clear()
        self.auto_prompt_input.blockSignals(False)
        self._auto_prompt_valid = False
        self._set_prompt_info()
        # The prompt was cleared with signals blocked (no textChanged), so
        # refresh the Detect + Detail gates explicitly: the next run must
        # start with the Detail slider gated again until an object exists.
        self._update_auto_detect_enabled()
        # The review hides the layer header (see set_auto_review_active); a
        # hard teardown that skips the review-off call must restore it.
        self.auto_layer_combo.setVisible(True)
        self._go_to_auto_step(0)

    def _apply_auto_detail_gate(self, has_object: bool) -> None:
        """Grey the Detail slider until the object is defined (typed prompt or
        drawn example). The slider's default is object-aware, so an adjustment
        made BEFORE the object was named was thrown away by the prompt-commit
        re-seed: gating the control makes the order explicit.
        The programmatic seed still lands (setValue works while disabled).
        The one-line hint explains the greyed state instead of leaving a dead
        control unexplained."""
        try:
            slider = self.auto_detail_slider
            if slider.isEnabled() == has_object:
                return
            slider.setEnabled(has_object)
            if has_object:
                self.auto_detail_hint.setText(
                    tr("Finer detail finds smaller objects."))
            else:
                self.auto_detail_hint.setText(tr(
                    "Name the object (or draw an example) first - Detail "
                    "then tunes itself to it."))
        except (RuntimeError, AttributeError):
            pass

    def set_prompt_text(self, text: str) -> None:
        """Set the prompt box (used by the Library 'Use this prompt' flow)."""
        self._prompt_from_library = True
        self.auto_prompt_input.setText(text or "")
        self.auto_prompt_input.setFocus()

    def _on_auto_search_text_changed(self, _text: str = "") -> None:
        # Track validity quietly while typing; the guard-rail message itself
        # only appears when the user commits (Detect / Enter), never on the
        # first keystrokes - see confirm_prompt_for_detect. Any edit clears a
        # previously shown message (the user is already acting on it).
        text = self.auto_prompt_input.text()
        ok, _reason, _suggestion = validate_prompt(text)
        self._auto_prompt_valid = ok
        self._apply_prompt_hint_on_edit()
        self._update_auto_detect_enabled()
        # Re-seed the object-aware detail default once the typed object settles.
        self._auto_prompt_debounce_timer.start(500)

    def confirm_prompt_for_detect(self) -> bool:
        """Commit-time guard rail: called by the plugin when a detection is
        requested (Detect click or Enter). A clean prompt (or an example-only
        run with an empty prompt) passes; anything off the rails blocks the
        run and shows the guidance right under the prompt box, with focus back
        in it so the fix is one keystroke away."""
        text = self.auto_prompt_input.text()
        if not text.strip():
            # Example-only run: Detect is only enabled with a drawn example, so
            # an empty prompt commits straight away (the examples drive it).
            self._apply_prompt_hint_on_edit()
            return True
        ok, reason, suggestion = validate_prompt(text)
        if ok and reason is None and not is_known_object(text):
            # Valid-LOOKING but unrecognized word: could be a language the
            # offline lexicon does not cover (Polish, Turkish, ...) or a rare
            # English word. One cached server lookup decides; None means "run
            # it as typed".
            token = self._resolve_prompt_via_server(text)
            if token:
                reason, suggestion = "translated", token
        elif not ok and reason == "language":
            # Foreign word beyond the offline lexicon: try the same server
            # lookup before giving up and lecturing about English.
            token = self._resolve_prompt_via_server(text)
            if token:
                ok, reason, suggestion = True, "translated", token
        self._auto_prompt_valid = ok
        if ok:
            if reason == "translated" and suggestion:
                # The user typed the object in their own language: run the
                # English token for them (setText first - its textChanged
                # clears the info line, which we then set) and say so in a
                # quiet note, not an error. The run proceeds untouched.
                typed = text.strip()
                self.auto_prompt_input.setText(suggestion)
                self._set_prompt_info(
                    tr('"{word}" will run as "{token}".').format(
                        word=typed, token=suggestion), info=True)
            else:
                self._set_prompt_info()
            return True
        # Off-rails text, but a drawn example is already a full query: for the
        # generic/subjective cases (where no single word fits) point the user at
        # clearing the box to run from the example alone. Clearing it makes the
        # prompt empty, which passes the guard, so the run is one keystroke away.
        if (self._EXEMPLARS_ENABLED
                and getattr(self, "_auto_positive_exemplars", 0) > 0  # noqa: W503
                and reason in ("abstract", "subjective")):  # noqa: W503
            guidance = tr(
                "Too generic to name. Clear the box to search from your "
                "example alone, or type a concrete object.")
        else:
            guidance = self._prompt_guidance_message(reason, suggestion)
        self._set_prompt_info(guidance, error=True)
        self.auto_prompt_input.setFocus()
        self.auto_prompt_input.selectAll()
        try:
            from ...core import telemetry
            telemetry.track_detect_blocked(
                reason="prompt_{}".format(reason or "invalid"))
        except Exception:
            pass  # nosec B110
        return False

    def _resolve_prompt_via_server(self, text: str) -> str | None:
        """Commit-time-only server translation for words the offline lexicon
        cannot resolve. Blocking but cached (one round-trip per distinct word
        per machine, failures negative-cached for the session); a wait cursor
        covers the one slow first lookup. The returned token is re-vetted by
        the guard so a bad server answer can never reach the model."""
        try:
            from ...api.prompt_translation import resolve_english_prompt
        except Exception:  # noqa: BLE001
            return None
        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            token = resolve_english_prompt(text)
        except Exception:  # noqa: BLE001
            token = None
        finally:
            QApplication.restoreOverrideCursor()
        if not token:
            return None
        ok, reason, _suggestion = validate_prompt(token)
        return token if ok and reason is None else None

    def _emit_auto_prompt_committed(self) -> None:
        """Fire after the prompt settles so the plugin re-seeds the detail
        default for the current object (no-op when the box is empty; the plugin
        also respects a manual slider override and requires a drawn zone)."""
        text = self.auto_prompt_input.text().strip()
        if text:
            try:
                from ...core import telemetry
                telemetry.track_auto_prompt_committed(
                    prompt=text,
                    from_library=getattr(self, "_prompt_from_library", False),
                )
            except Exception:
                pass  # nosec B110
        # A library pick's from_library marker is consumed once here; later manual
        # edits report from_library=False.
        self._prompt_from_library = False
        self.auto_prompt_committed.emit(text)

    def _on_auto_search_return_pressed(self) -> None:
        # Enter in the prompt box routes through the plugin's single Enter
        # dispatcher (_route_enter), which launches the detection once
        # everything required (layer locked, zone drawn, object) is in place.
        self.auto_enter_pressed.emit()

    def _set_prompt_info(self, text: str | None = None, error: bool = False,
                         info: bool = False) -> None:
        """Guard-rail message under the prompt. Hidden when the prompt is empty
        or valid; an amber callout when the committed prompt is off the rails;
        a quiet neutral note (``info=True``) for the silent-translation case
        ('"piscine" will run as "swimming pool"'). ``error`` is kept for
        call-site compatibility; non-info text shows as the amber callout."""
        if not text:
            self.auto_prompt_info.setText("")
            self.auto_prompt_info.setVisible(False)
            return
        self.auto_prompt_info.setText(text)
        if info:
            self.auto_prompt_info.setStyleSheet(
                "QLabel { background-color: rgba(128,128,128,0.08);"
                " border: 1px solid rgba(128,128,128,0.25); border-radius: 4px;"
                " padding: 6px 8px; font-size: 11px; color: palette(text); }")
        else:
            self.auto_prompt_info.setStyleSheet(
                "QLabel { background-color: rgba(245,166,35,0.12);"
                " border: 1px solid rgba(245,166,35,0.45); border-radius: 4px;"
                " padding: 6px 8px; font-size: 11px; color: palette(text); }")
        self.auto_prompt_info.setVisible(True)

    def _apply_prompt_hint_on_edit(self) -> None:
        """Keep the note under the prompt box in sync while the user types or
        draws examples. A non-empty prompt clears any stale guard message (the
        guard only fires on commit). An empty prompt with at least one positive
        example shows a positive 'examples drive the search' note instead of
        nothing, so an example-only run never looks like a missing prompt."""
        try:
            has_text = bool(self.auto_prompt_input.text().strip())
        except (RuntimeError, AttributeError):
            return
        if not has_text and (self._EXEMPLARS_ENABLED
                             and getattr(self, "_auto_positive_exemplars", 0) > 0):  # noqa: W503,E501
            self._set_prompt_info(tr("Your examples drive the search."), info=True)
        else:
            self._set_prompt_info()

    def _prompt_guidance_message(self, reason: str | None, suggestion: str | None) -> str:
        msgs = {
            "too_long": tr("Use just 1-2 words for the object."),
            "sentence": tr("Type the object itself, not a sentence or question."),
            "abstract": tr("Too generic. Draw an example instead, or use a "
                           "concrete word like building."),
            "subjective": tr("Name a concrete object, not how it looks."),
            "referential": tr("Segment one object - drop words like 'near' or 'with'."),
            "weird": tr("Use a real object word."),
            "language": tr("That word isn't recognized - try a common object like building or tree."),
            "multi": tr("One object per run - start with the first one, then run again."),
        }
        base = msgs.get(reason or "", tr("Use a 1-2 word object name."))
        if suggestion:
            return base + " " + tr("Did you mean '{term}'?").format(term=suggestion)
        return base + " " + tr("The Library has ready-to-use objects.")

    def _is_auto_for_us(self) -> bool:
        """True when the Automatic flow should own Enter: in Automatic mode,
        the flow is started, and no run is in flight (Enter has no job during
        a run; Escape has its own gate so it can soft-cancel one)."""
        return (self._mode == Mode.AUTOMATIC and self._auto_started
                and not self._auto_run_active)  # noqa: W503

    def _on_auto_escape_shortcut(self) -> None:
        """Escape: delegate to the plugin's single dispatcher (_route_escape).

        Unlike Enter this also fires DURING a run (Escape = soft Cancel
        there), so it gates only on the Automatic flow being started."""
        if self._mode == Mode.AUTOMATIC and self._auto_started:
            self.auto_escape_pressed.emit()

    def _on_auto_enter_shortcut(self) -> None:
        """Enter: delegate to the plugin's single dispatcher (_route_enter),
        unless a text editor or spinbox has focus (its own returnPressed /
        value-commit handles Enter, avoiding a double trigger)."""
        if not self._is_auto_for_us():
            return
        fw = QApplication.focusWidget()
        if isinstance(fw, (QLineEdit, QComboBox, QAbstractSpinBox)):
            return
        self.auto_enter_pressed.emit()

    def set_auto_shortcuts_enabled(self, enabled: bool) -> None:
        """Toggle the Escape/Enter shortcuts. Disabled while an example box is
        being drawn so the draw tool owns Escape (cancel) with no race."""
        for sc in (self.auto_escape_shortcut, self.auto_enter_shortcut,
                   self.auto_enter_shortcut_kp):
            try:
                sc.setEnabled(enabled)
            except (RuntimeError, AttributeError):
                pass

    def _on_auto_detail_changed(self, value: int) -> None:
        # The slider now shows plain Coarse/Fine ends; the only numeric feedback
        # is the credit cost, which the plugin recomputes from the real grid.
        self._refresh_auto_detail_hint()
        self.auto_detail_changed.emit(value)

    # -- Review confidence: slider <-> spinbox sync + debounced re-filter -----

    def _on_conf_slider_moved(self, value: int) -> None:
        """Slider dragged: snap to the nearest 5% stop, mirror into the spinbox
        only (cheap, no re-filter). QSlider singleStep only constrains keyboard
        input, so the mouse drag is snapped here to stop the handle (and the
        debounced re-filter on release) from landing on every tiny percentage.
        The spinbox keeps 1% precision; this snapped mirror just rounds it when
        the user drives from the slider."""
        snapped = _snap_review_conf(value)
        if snapped != value:
            self.auto_review_confidence_slider.blockSignals(True)
            self.auto_review_confidence_slider.setValue(snapped)
            self.auto_review_confidence_slider.blockSignals(False)
        if self.auto_review_confidence_spin.value() != snapped:
            self.auto_review_confidence_spin.blockSignals(True)
            self.auto_review_confidence_spin.setValue(snapped)
            self.auto_review_confidence_spin.blockSignals(False)
        # A user-initiated move retires the auto-lowered note and moves the
        # histogram's dimmed/kept boundary.
        self.auto_conf_lowered_note.setVisible(False)
        if getattr(self, "auto_conf_histogram", None) is not None:
            self.auto_conf_histogram.set_cutoff(snapped / 100.0)
        # Live feedback as the handle moves: a fast, cheap preview re-filter on a
        # short debounce, plus the accurate rebuild on a longer one so it also
        # runs after the user settles via the keyboard (no sliderReleased then).
        # A mouse release additionally triggers the accurate path immediately.
        self._auto_conf_preview_timer.start(40)
        self._auto_conf_debounce_timer.start(250)

    def _emit_auto_confidence_preview(self) -> None:
        self.auto_review_confidence_preview.emit(self.auto_review_confidence_spin.value())

    def _on_conf_spin_changed(self, value: int) -> None:
        """Spinbox edited: mirror into the slider, then schedule the re-filter."""
        if self.auto_review_confidence_slider.value() != value:
            self.auto_review_confidence_slider.blockSignals(True)
            self.auto_review_confidence_slider.setValue(value)
            self.auto_review_confidence_slider.blockSignals(False)
        self._schedule_conf_refilter()

    def _schedule_conf_refilter(self) -> None:
        """Coalesce rapid confidence changes so the heavy re-merge runs once."""
        self._auto_conf_debounce_timer.start(200)

    def _emit_auto_confidence_changed(self) -> None:
        # Emit the spinbox value: it is the exact-cutoff source of truth (free 1%
        # precision). A slider drag has already snapped itself to a 5% step and
        # mirrored that into the spinbox, so this stays correct for both paths.
        self.auto_review_confidence_changed.emit(self.auto_review_confidence_spin.value())

    def set_auto_detail_value(self, n: int) -> None:
        """Seed the detail slider with a good default for a freshly drawn zone.

        Signal-free: the plugin recomputes the credit estimate (and the proper
        slider max) right after. Raises the max first if needed so the seeded
        value is not clamped by a previous zone's smaller cap.
        """
        s = self.auto_detail_slider
        s.blockSignals(True)
        if s.maximum() < n:
            s.setMaximum(min(MAX_DETAIL_LEVEL, int(n)))
        s.setValue(max(s.minimum(), min(s.maximum(), int(n))))
        s.blockSignals(False)
        self._refresh_auto_detail_hint()

    def set_auto_detail_visible(self, visible: bool) -> None:
        """Show the detail slider whenever a zone is drawn; hidden while no zone is set."""
        self.auto_detail_row.setVisible(visible)

    def set_auto_detail_gsd_warning(self, coarse: bool) -> None:
        """Show the boxed amber warning when the chosen detail leaves the imagery
        too coarse for the cloud model (effective ground resolution >= ~0.5 m/px, where
        detection quality drops sharply). The detail seed now auto-raises past
        the soft tile budget, so this fires only when the USER dragged detail
        down (fix: raise it back) or the zone is so large even the slider max
        stays coarse (fix: a smaller zone; "raise detail" would be a dead end).
        The neutral hint hides while the warning shows so the two never stack."""
        if coarse:
            s = self.auto_detail_slider
            self.auto_detail_warning_label.setText(
                tr("This area is large for this detail level. Raise detail or zoom"
                   " in for sharper detections.")
                if s.value() < s.maximum() else
                tr("This zone is too large for sharp detections, even at maximum"
                   " detail. Draw a smaller zone for the best results."))
        self.auto_detail_warning.setVisible(coarse)
        self.auto_detail_hint.setVisible(not coarse)

    def set_auto_detail_max(self, n: int) -> None:
        """Cap the detail slider at ``n`` useful levels (1-MAX_DETAIL_LEVEL).

        Clamps the current value down if it now exceeds the cap. Signal-free
        on purpose: the plugin calls this from _update_credit_estimate right
        before recomputing the grid, so the clamped value is picked up
        immediately without a re-entrant signal.
        """
        n = max(1, min(MAX_DETAIL_LEVEL, int(n)))
        slider = self.auto_detail_slider
        slider.blockSignals(True)
        slider.setMaximum(n)
        if slider.value() > n:
            slider.setValue(n)
        slider.blockSignals(False)
        self._refresh_auto_detail_hint()

    def _refresh_auto_detail_hint(self) -> None:
        """Swap the muted line under the detail slider when the handle sits at
        a maximum the zone size capped below MAX_DETAIL_LEVEL, so a slider that
        stops early never reads as broken. Same label, text swap only (no
        layout jump); the normal hint returns as soon as the user is below the
        cap or the cap is the full range."""
        s = self.auto_detail_slider
        capped = s.maximum() < MAX_DETAIL_LEVEL and s.value() >= s.maximum()
        self.auto_detail_hint.setText(
            tr("Max detail for this zone - draw a larger zone for finer detail.")
            if capped else tr("Finer detail finds smaller objects."))

    def on_zone_deleted_from_canvas(self) -> None:
        """Called by the plugin when the user clicks the zone's x badge."""
        self.set_auto_zone_state("idle")
        self._go_to_auto_step(1)

    def _go_to_auto_step(self, index: int) -> None:
        """Switch the Automatic flow to the given step.

        step 0 Start (layer editable) | step 1 Draw zone | step 2 Prompt +
        settings. From step 1 on the layer header is locked; the canvas x badge
        re-draws the zone and the Exit button returns to step 0.
        """
        # A running detection pins the flow to the launch step.
        if self._auto_run_active:
            index = 2
        # The stack is hidden in the empty state (hero only); any explicit step
        # change means the flow is live, so it must be visible again.
        self.auto_steps.setVisible(True)
        self.auto_steps.setCurrentIndex(index)
        self._refresh_auto_layer_lock()
        # Leaving step 0 launches the Automatic flow (returning to it exits): the
        # mode switch is only shown on the Start screen, so re-evaluate it here.
        self._refresh_mode_switch_visibility()
        self._update_auto_detect_enabled()
        # The exemplar panel belongs to the idle prompt step (2): an example is
        # drawn inside the zone, which exists by the time step 2 opens. Hidden on
        # the Start/Draw steps and while a run or review is active. Also gated
        # behind _EXEMPLARS_ENABLED (feature hidden for now).
        self.auto_exemplar_panel.setVisible(
            self._EXEMPLARS_ENABLED and index == 2 and not self._auto_run_active and not self._auto_review_active
        )
        # The bottom-pinned first-steps guide banner shows on the Start step only.
        self._update_auto_tutorial_banner_visibility()
        # The plugin reacts to step changes (e.g. arms the zone drawing tool
        # whenever the zone step opens without a zone set).
        self.auto_step_changed.emit(index)

    def _refresh_auto_layer_lock(self) -> None:
        """Greyed/locked layer header from step 1 on; editable on the Start step.

        Reuses the Interactive locked-combo look: disabled combo with the
        dropdown arrow hidden, and the label hidden so only the raster name
        shows. Step 0 restores the editable combo + its label.
        """
        # Empty-project guard: on the Start step with NO raster loaded the page
        # is the no-imagery hero, so the header must stay hidden. Without this,
        # this method (which runs LAST on a combo change) would re-show the
        # "Select a Raster Layer" label over the hero - the orphaned-label bug
        # seen after deleting the last layer. The fresh path never calls this,
        # which is why only the delete path broke.
        if self.auto_steps.currentIndex() == 0 and self.auto_layer_combo.count_layers() == 0:
            self.auto_layer_label.setVisible(False)
            self.auto_layer_combo.setVisible(False)
            return
        on_start = self.auto_steps.currentIndex() == 0
        self.auto_layer_label.setVisible(on_start)
        self.auto_layer_combo.setEnabled(on_start)
        # Freeze the combo whenever the header is locked (steps 1/2/run/review):
        # once a source is locked, hiding layers in the tree must not drop it from
        # the list or re-pick another. Unfreezes + resyncs on returning to Start.
        self.auto_layer_combo.set_frozen(not on_start)
        if on_start:
            self.auto_layer_combo.setStyleSheet(
                "QComboBox { color: palette(text); }")
        else:
            self.auto_layer_combo.setStyleSheet(
                "QComboBox { color: palette(text); }"
                "QComboBox::drop-down { width: 0px; border: none; }")

    def _on_auto_cancel_clicked(self) -> None:
        # Dock-side no-op: the plugin connects this same button to its real
        # cancel handler (request_stop + teardown). Kept so the button has a
        # dock-side slot and future dock-only feedback has a home.
        pass

    def set_auto_cancelling(self) -> None:
        """Instant feedback the moment Cancel is pressed, BEFORE the worker
        thread winds down. The stop is cooperative (the worker checks its flag
        between network events and drains the tiles already in flight), so the
        page cannot flip to the review on the same click; without this the
        button stays 'Cancel detection' and the bar keeps moving, so the click
        reads as ignored. Disable + relabel the button (a second click is a
        no-op anyway) and swap the progress line to a reassuring 'keeping what's
        found' note. The run's terminal handler (_on_auto_cancelled) then flips
        into the review of the salvaged tiles."""
        self._auto_cancelling = True
        try:
            self.auto_cancel_btn.setEnabled(False)
            self.auto_cancel_btn.setText(tr("Stopping…"))
        except (RuntimeError, AttributeError):
            pass
        # Keep the progress card up and say the paid-for tiles are being kept.
        # The _auto_cancelling flag makes set_auto_tile_progress hold this note
        # even as the salvaged tiles tick the count up during the drain.
        try:
            if self.auto_progress_card.isVisible():
                self.auto_progress_label.setText(
                    tr("Stopping - keeping the tiles already found…"))
                self.auto_progress_label.setVisible(True)
        except (RuntimeError, AttributeError):
            pass

    def set_auto_zone_rejected(self, area_km2: float | None) -> None:
        """Show (or hide with None) the free-trial zone-cap message in the
        step-1 draw hero: the drawn zone was refused because it exceeds
        FREE_TRIAL_MAX_ZONE_KM2. Contextual upsell touchpoint: the subscribe
        link opens the same dashboard upgrade URL as the footer pill. The
        message clears as soon as a valid zone lands or the flow is exited
        (see set_auto_zone_state / reset_auto_to_start)."""
        label = getattr(self, "_auto_zone_cap_label", None)
        if area_km2 is None:
            if label is not None:
                try:
                    label.setVisible(False)
                except (RuntimeError, AttributeError):
                    pass
            return
        if label is None:
            from qgis.PyQt.QtWidgets import QLabel
            label = QLabel()
            label.setWordWrap(True)
            label.setTextFormat(Qt.TextFormat.RichText)
            label.setAlignment(Qt.AlignmentFlag.AlignCenter)
            label.setTextInteractionFlags(
                Qt.TextInteractionFlag.TextBrowserInteraction)
            # Quiet warning card: amber border + faint tint, readable text on
            # both themes (palette(text), never palette(mid)).
            label.setStyleSheet(
                "font-size: 12px; color: palette(text);"
                "border: 1px solid rgba(240, 160, 60, 160);"
                "border-radius: 6px; padding: 8px;"
                "background: rgba(240, 160, 60, 26);"
            )
            label.linkActivated.connect(self._on_zone_cap_link_activated)
            try:
                self.auto_zone_hero.layout().addWidget(label)
            except (RuntimeError, AttributeError):
                return
            self._auto_zone_cap_label = label
        from ..plugin.shared import FREE_TRIAL_MAX_ZONE_KM2
        line1 = tr(
            "This zone is {area} km2 - free trial zones go up to {max} km2."
        ).format(area="{:.1f}".format(area_km2),
                 max="{:g}".format(FREE_TRIAL_MAX_ZONE_KM2))
        line2 = tr(
            'Draw a smaller zone, or <a href="{url}">subscribe</a> to '
            "segment areas of any size."
        ).format(url=self._build_upgrade_url())
        label.setText("{}<br/>{}".format(line1, line2))
        label.setVisible(True)

    def _on_zone_cap_link_activated(self, url: str) -> None:
        """Subscribe link inside the zone-cap message: same destination as the
        footer pill, tracked with its own upsell source."""
        from qgis.PyQt.QtCore import QUrl
        from qgis.PyQt.QtGui import QDesktopServices
        try:
            from ...core import telemetry
            telemetry.track_pro_upsell_clicked(source="zone_too_large")
        except Exception:
            pass  # nosec B110
        QDesktopServices.openUrl(QUrl(url))

    def _update_auto_low_credit_note(self) -> None:
        """Free-tier low-credit nudge on the Automatic Start step (step 0).

        Shows a discreet one-line "Running low" note with a Subscribe link once
        a free user drops below 20% of their free detections (and still has some
        left; a fully exhausted balance shows the upsell card instead).
        Subscribers never see it: the footer credit ring owns their balance.
        The line lives on the step-0 page, so it only shows on Start.
        """
        remaining = self._auto_credits
        total = self._auto_credits_total
        show = (
            self._mode == Mode.AUTOMATIC and self._plugin_activated
            and not self._auto_is_subscriber  # noqa: W503
            and remaining is not None and total and total > 0  # noqa: W503
            and 0 < remaining <= total * 0.20  # noqa: W503
        )
        line = getattr(self, "_auto_low_credit_line", None)
        if not show:
            if line is not None:
                try:
                    line.setVisible(False)
                except (RuntimeError, AttributeError):
                    pass
            return
        if line is None:
            line = self._build_auto_low_credit_line()
            if line is None:
                return
        line.setText(tr(
            'Running low: {n} free detections left. '
            '<a href="{url}">Subscribe</a> to keep going.'
        ).format(n=remaining, url=self._build_upgrade_url()))
        line.setVisible(True)
        # Track the banner view once per session (the click was already
        # tracked, the view was not).
        if not getattr(self, "_low_credit_note_seen", False):
            self._low_credit_note_seen = True
            try:
                from ...core import telemetry
                telemetry.track_low_credit_banner_viewed(int(remaining), int(total))
            except Exception:  # nosec B110
                pass

    def _build_auto_low_credit_line(self):
        """Lazily create the step-0 low-credit note (amber card) and slot it
        under the free-trial line. Returns the label, or None if the step-0
        page is not built yet."""
        from qgis.PyQt.QtWidgets import QLabel
        try:
            page = self.auto_steps.widget(0)
            layout = page.layout()
        except (RuntimeError, AttributeError):
            return None
        if layout is None:
            return None
        label = QLabel()
        label.setWordWrap(True)
        label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        label.setTextFormat(Qt.TextFormat.RichText)
        label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
        label.setOpenExternalLinks(False)
        label.setStyleSheet(
            "font-size: 11px; color: palette(text);"
            "border: 1px solid rgba(240, 160, 60, 160);"
            "border-radius: 6px; padding: 6px 8px;"
            "background: rgba(240, 160, 60, 26);"
        )
        label.linkActivated.connect(self._on_low_credit_link_activated)
        anchor = getattr(self, "auto_start_caption", None)
        idx = layout.indexOf(anchor) if anchor is not None else -1
        if idx >= 0:
            layout.insertWidget(idx + 1, label)
        else:
            layout.addWidget(label)
        self._auto_low_credit_line = label
        return label

    def _on_low_credit_link_activated(self, url: str) -> None:
        """Subscribe link inside the low-credit note: same destination as the
        footer pill, tracked with its own upsell source."""
        from qgis.PyQt.QtCore import QUrl
        from qgis.PyQt.QtGui import QDesktopServices
        try:
            from ...core import telemetry
            telemetry.track_pro_upsell_clicked(source="low_credit")
        except Exception:
            pass  # nosec B110
        QDesktopServices.openUrl(QUrl(url))

    def set_auto_zone_state(self, state: str) -> None:
        """Reflect the canvas zone state. State: 'idle', 'drawing', 'zone_set'."""
        self._auto_zone_is_set = state == "zone_set"
        # A valid zone landing, or leaving the flow, retires the free-trial
        # zone-cap message; while state stays 'drawing' (a rejected zone puts
        # the user back there) the message remains as guidance.
        if state in ("idle", "zone_set"):
            self.set_auto_zone_rejected(None)
        if state in ("idle", "drawing"):
            self._auto_zone_too_large = False
            # No zone = no per-zone estimate; drop the stale cost label AND the
            # cached estimate/gate so neither lingers from the previous zone.
            self._auto_est_credits = None
            self._auto_insufficient_credits = False
            self.auto_credit_cost_label.setVisible(False)
        elif state == "zone_set":
            # A drawn zone completes step 2.
            self._go_to_auto_step(2)
            # Land AND HOLD the caret in the prompt box so the user types the
            # object without a click. A single setFocus is not enough: the zone
            # finishes on a canvas event, and for the first ~second the map
            # canvas can reclaim keyboard focus on a deferred repaint (the slow
            # basemap redraw), which yanked the caret back out after ~0.5 s. So
            # this holds focus across that window (see _begin_auto_prompt_focus).
            self._begin_auto_prompt_focus()
        self._update_auto_detect_enabled()

    def _begin_auto_prompt_focus(self) -> None:
        """Start holding the keyboard caret in the prompt box for ~1 s.

        The caret is re-asserted on a short repeating timer because a single
        setFocus loses to the canvas reclaiming focus a few hundred ms later
        (deferred basemap repaint after the zone is drawn). Each tick reclaims
        focus ONLY when it has drifted OUTSIDE the dock (i.e. to the map
        canvas) or nowhere, so a deliberate click on another dock control is
        respected and typing is never interrupted. Stops after the window."""
        from qgis.PyQt.QtCore import QTimer
        timer = getattr(self, "_auto_prompt_focus_timer", None)
        if timer is None:
            timer = QTimer(self)
            timer.setInterval(120)  # ~8 ticks fill the ~1 s steal window
            timer.timeout.connect(self._tick_auto_prompt_focus)
            self._auto_prompt_focus_timer = timer
        self._auto_prompt_focus_ticks = 0
        self._tick_auto_prompt_focus()  # claim immediately, then hold
        timer.start()

    def _tick_auto_prompt_focus(self) -> None:
        """One focus-hold tick (see _begin_auto_prompt_focus)."""
        timer = getattr(self, "_auto_prompt_focus_timer", None)
        try:
            self._auto_prompt_focus_ticks = getattr(
                self, "_auto_prompt_focus_ticks", 0) + 1
            prompt = self.auto_prompt_input
            if (self.auto_steps.currentIndex() != 2 or not prompt.isVisible() or not prompt.isEnabled()):
                if timer is not None:
                    timer.stop()
                return
            focused = QApplication.focusWidget()
            # Reclaim only when focus sits outside the dock (the map canvas) or
            # nowhere; a real click on a sibling dock control (focus inside the
            # dock) is left alone, and if the prompt already holds it this is a
            # no-op.
            if focused is not prompt and (
                    focused is None or not self.isAncestorOf(focused)):
                prompt.setFocus(Qt.FocusReason.OtherFocusReason)
            if self._auto_prompt_focus_ticks >= 8 and timer is not None:
                timer.stop()
        except (RuntimeError, AttributeError):
            if timer is not None:
                timer.stop()

    def set_zone_draw_progress(self, count: int) -> None:
        """Live guidance under the 'Draw your zone' title while the user clicks
        points, so it is always clear what to do next and how to finish."""
        if count <= 0:
            txt = tr("Click on the map to outline the area to scan.")
        elif count < 3:
            txt = tr("Keep clicking around the area, at least 3 points.")
        else:
            txt = tr("Click the first point to close the zone.")
        try:
            self._auto_zone_hint.setText(txt)
        except (RuntimeError, AttributeError):
            pass

    def set_auto_credits(self, credits: int, reset_date: str,
                         is_subscriber: bool,
                         total: int | None = None) -> None:
        """Called by plugin after loading usage data. Main thread only.

        ``total`` feeds the footer credit ring (remaining / total). Optional
        so older callers keep working; without it the ring stays hidden and
        only the count label shows.
        """
        self._auto_credits = credits
        self._auto_credits_total = total
        self._auto_is_subscriber = is_subscriber
        self._auto_reset_date = reset_date or ""
        if not is_subscriber:
            self._auto_free_left = credits
        self._refresh_auto_credits_display()
        # A balance change (typically the post-run refresh that debits the tiles
        # just spent) must re-run the credit gate against the LAST estimate, so a
        # now-underfunded zone blocks Detect immediately instead of waiting for
        # the next slider move. Only while a zone is set and no run/review owns
        # the cost label, so this never force-shows it on the Start step.
        _cost_label_free = not self._auto_run_active and not self._auto_review_active
        if self._auto_est_credits is not None and self._auto_zone_is_set and _cost_label_free:
            self.set_auto_credit_estimate(self._auto_est_credits)
        self._update_full_ui()

    def set_auto_run_active(self, active: bool) -> None:
        self._auto_run_active = active
        self.auto_cancel_btn.setVisible(active)
        # A fresh run clears any leftover exhausted-credits subscribe link and
        # restores the cancel button from a previous run's "Stopping…" state
        # (set_auto_cancelling disables + relabels it; the button is hidden but
        # not reset when the run winds down).
        if active:
            self.set_auto_exhausted_subscribe_visible(False)
            self._auto_cancelling = False
            self.auto_cancel_btn.setEnabled(True)
            self.auto_cancel_btn.setText(tr("Cancel detection"))
        # Lock the gear (Account Settings) and help controls while tiles are in
        # flight: opening either fires its own network work on top of an already
        # busy GUI thread, which compounds the freeze. They unlock the moment the
        # run ends (review or back to Start).
        self.set_footer_controls_locked(active)
        # Mirror AI Edit: while tiles are in flight, clear away the non-essential
        # params (detail, confidence, cost) and the Detect/Exit row so only the
        # "Detecting X" label + progress + Cancel remain. They reappear when the
        # run ends; if the run then enters review, set_auto_review_active
        # re-hides them. The detail row honors the zone state on restore.
        self.auto_detect_row.setVisible(not active)
        # The confidence box stays hidden in the prompt step (post-run only).
        self.auto_settings_box.setVisible(False)
        self.auto_detail_row.setVisible(
            self._auto_zone_is_set if not active else False)
        self.auto_credit_cost_label.setVisible(
            self.auto_credit_cost_label.text() != "" if not active else False)
        # Keep the prompt card VISIBLE during a run, read-only (AI Edit pattern):
        # the chosen object stays framed above the progress so the user always
        # knows what is being detected, and the Library button stays clickable
        # (view-only). It returns to editable when the run ends; if the run
        # enters review, set_auto_review_active hides the whole card.
        self.auto_prompt_card.setVisible(True)
        self._set_auto_prompt_readonly(active)
        if active:
            self._go_to_auto_step(2)
        else:
            self._refresh_auto_layer_lock()
        # Keep the drawn reference visible during a run, read-only: hide the
        # add/exclude/remove affordances, keep the thumbnails browsable (click
        # to enlarge). No reference drawn = the whole panel stays hidden. Done
        # AFTER _go_to_auto_step, which force-hides the panel during a run - this
        # is the deliberate in-run exception that keeps the reference on screen.
        has_ref = self._EXEMPLARS_ENABLED and self._auto_positive_exemplars > 0
        if active:
            self._set_exemplar_readonly(True)
            self.auto_exemplar_panel.setVisible(has_ref)
        else:
            self._set_exemplar_readonly(False)
            self.auto_exemplar_panel.setVisible(
                self._EXEMPLARS_ENABLED and self.auto_steps.currentIndex() == 2 and not self._auto_review_active)
        self._update_auto_detect_enabled()
        if active:
            # Reset the live readout for the fresh run.
            self._auto_found_count = 0
            self._auto_progress_pair = (0, 0)
            self._auto_progress_ratio = 0.0
            self.auto_progress_count_label.setText("")
            self.auto_progress_pct_label.setText("")
            self.auto_progress_label.setVisible(False)

    def _set_auto_prompt_readonly(self, readonly: bool) -> None:
        """Lock the prompt card for the in-run read-only view: the text stays
        crisp and readable (setReadOnly, not disable, so it never greys out),
        the clear button is dropped, and the Library button stays clickable so
        the user can browse the library view-only while tiles are in flight."""
        try:
            self.auto_prompt_input.setReadOnly(readonly)
            self.auto_prompt_input.setClearButtonEnabled(not readonly)
            self.auto_library_btn.setEnabled(True)
            self.auto_library_btn.setToolTip(
                tr("Browse the library (view only while detecting).") if readonly
                else tr("Browse ready-to-use objects with before / after previews."))
        except (RuntimeError, AttributeError):
            pass

    def _set_exemplar_readonly(self, readonly: bool) -> None:
        """Swap the reference panel between its editable form and the in-run
        read-only form: hide the header/hint/buttons, show a quiet caption, and
        hide the per-thumbnail remove x while keeping the thumbnail click-to-
        enlarge alive. Best-effort (the panel may not be built yet)."""
        try:
            self._auto_exemplar_header.setVisible(not readonly)
            self.auto_exemplar_readonly_caption.setVisible(readonly)
            self.auto_exemplar_edit_controls.setVisible(not readonly)
            layout = self._auto_exemplar_chips_layout
            for i in range(layout.count()):
                w = layout.itemAt(i).widget()
                if w is None:
                    continue
                rb = getattr(w, "_remove_btn", None)
                if rb is not None:
                    rb.setVisible(not readonly)
        except (RuntimeError, AttributeError):
            pass

    def set_auto_run_found_count(self, obj: str, count: int) -> None:
        """Live in-run feedback: the running found-object count, shown in the
        progress card's Row 1 next to the tile count so a slow zone never feels
        dead. ``obj`` is kept for call-site compatibility (the card no longer
        restates the object; the prompt card above already names it)."""
        self._auto_found_count = count if count > 0 else 0
        if self.auto_progress_card.isVisible():
            self._refresh_auto_progress_readout()

    def set_footer_controls_locked(self, locked: bool) -> None:
        """Disable the footer gear (Account Settings) and help controls during a
        run. Each opens a dialog/menu that does its own network work, so clicking
        them mid-detection piles onto a busy GUI thread and worsens the freeze;
        they grey out until the run finishes. Best-effort: the footer buttons may
        not exist yet on an early call."""
        busy_tip = tr("Available when detection finishes")
        for btn, ready_tip in (
            (getattr(self, "_settings_btn", None), tr("Settings")),
            (getattr(self, "_help_btn", None), tr("Help / Report a problem")),
        ):
            if btn is None:
                continue
            try:
                btn.setEnabled(not locked)
                btn.setToolTip(busy_tip if locked else ready_tip)
            except (RuntimeError, AttributeError):
                pass

    def _format_auto_review_count(self, visible: int, total: int, pct: int) -> str:
        """ONE compact review readout line, always honest: green check + bold
        shown-count, then a muted tail with what the confidence cutoff hides.
        Sits at the top of the review card (it is the live readout of the
        filters below it). A run that found something NEVER reads as '0
        detected'."""
        check = '<span style="color:#43a047;">&#10003;</span> '
        muted = 'style="color: rgba(128,128,128,0.95);"'
        if total <= 0:
            # Empty runs use the guidance box instead of this label; safe fallback.
            return '<b>{title}</b>'.format(title=tr("No objects found"))
        if visible >= total:
            bold = tr("{n} objects found").format(n=total)
            tail = tr("all shown")
        elif visible > 0:
            bold = tr("{visible} of {n} shown").format(visible=visible, n=total)
            tail = tr("{hidden} below {pct}%").format(
                hidden=total - visible, pct=pct)
        else:
            # No green check at 0 visible: nothing is shown, but the count is
            # honest and the tail tells the user how to reveal them.
            bold = tr("{n} objects found").format(n=total)
            tail = tr(
                "0 shown at {pct}% - lower Confidence to reveal them").format(pct=pct)
            return '<b>{bold}</b> <span {muted}>· {tail}</span>'.format(
                bold=bold, muted=muted, tail=tail)
        return '{check}<b>{bold}</b> <span {muted}>· {tail}</span>'.format(
            check=check, bold=bold, muted=muted, tail=tail)

    def set_auto_review_installing(self, active: bool) -> None:
        """Reflect a background local-AI install kicked off from the review (D1).

        Clicking Refine in Manual mode without the local AI installed starts the
        setup in the BACKGROUND while this review stays fully usable. The inline
        banner shows progress (fed by set_install_progress) and the Refine button
        is disabled until the AI is ready, at which point the plugin re-enables it
        and opens the handoff automatically.
        """
        self._auto_review_installing = bool(active)
        try:
            self.auto_review_install_banner.setVisible(active)
            if active:
                self.auto_review_install_progress.setValue(0)
                self.auto_review_install_label.setText(tr(
                    "Setting up Manual mode in the background. You can keep "
                    "reviewing; refining opens automatically when it is ready."))
            self.auto_refine_in_manual_btn.setEnabled(not active)
            self.auto_refine_in_manual_btn.setText(
                ("✎  " + tr("Preparing Manual mode...")) if active
                else "✎  " + tr("Refine in Manual mode"))
        except (RuntimeError, AttributeError):
            pass

    def set_auto_review_active(self, active: bool, count: int = 0,
                               reset_controls: bool = True,
                               preset: dict | None = None) -> None:
        """Post-run review state: refine + Finish replace the Detect/Exit row.

        reset_controls=False keeps the current filter values: used when returning
        from a Manual refine handoff, where a reset would wipe the locked
        confidence and any size filter the user set before handing off.

        preset: the run's smart review defaults (core.review_presets dict,
        keyed like the controls). When given with reset_controls, the shape
        refine + Min size seed from it instead of the static review_defaults
        constants, so the first result is already tuned to the prompt and the
        run resolution. Every NEW review reseeds (no cross-run memory).
        """
        self._auto_review_active = active
        # A fresh review or a review teardown ends any prior background-install
        # banner (D1); a refine-handoff return (reset_controls=False) is left as
        # is (its env is already ready, no install running).
        if not active or reset_controls:
            self.set_auto_review_installing(False)
        # The exemplar nudge is per-run: clear it on every review transition; the
        # plugin re-shows it after a fresh review only when the run was
        # bottom-heavy and used no example.
        self.hide_auto_exemplar_nudge()
        if active and reset_controls:
            # Fresh review: reset the per-control shape-adjust telemetry dedup set.
            self._review_shape_tracked = set()
            # The collapsible "Refine detections" section starts closed on every
            # NEW review (setVisible only, no control signals) so Confidence and
            # the action buttons stay above the fold. A refine handoff return
            # (reset_controls=False) keeps whatever the user had open.
            self.set_auto_shape_expanded(False)
        # The top mode toggle is disabled during review: switching modes here is
        # the destructive path that discards (and red-autosaves) the review. The
        # only intended door to Manual is the "Refine in Manual mode" button,
        # which preserves the detections. (The handoff itself manages the toggle
        # separately, so don't fight it while a handoff is live.)
        if not self._refine_handoff:
            self.mode_switch.setEnabled(not active)
            self.mode_switch.setToolTip(
                tr("Finish or exit the review to switch modes.") if active else "")
        self.auto_review_panel.setVisible(active)
        # The locked, greyed layer header is dead weight during review (the
        # raster cannot change until the review ends anyway): hide it so the
        # result + filters own the panel. Restored on review end and, for hard
        # teardowns that skip this call, by reset_auto_to_start.
        self.auto_layer_combo.setVisible(not active)
        # Hide the prompt card (Describe what to find + Library) during review:
        # the search is done, so the result + filters should own the panel.
        self.auto_prompt_card.setVisible(not active)
        self.auto_detect_row.setVisible(not active)
        self.auto_exemplar_panel.setVisible(not active)
        self.auto_detail_row.setVisible(not active and self.auto_detail_row.isVisible())
        # The pre-run confidence box is always hidden now (confidence lives in
        # the review panel below); keep it hidden here too.
        self.auto_settings_box.setVisible(False)
        # The run is paid for: the cost estimate is pre-run info that only
        # confuses the review. (The locked layer header keeps naming the raster.)
        self.auto_credit_cost_label.setVisible(
            not active and self.auto_credit_cost_label.isVisible())
        # Going back mid-review would silently desync the review from the
        # inputs; the exits (Finish, zone x / Escape, mode switch) all discard
        # or commit the review explicitly.
        if active:
            # Clear any leftover run status (tile progress / info banner) as
            # the review opens so it never lingers next to the result count.
            self.set_auto_status("idle")
            if reset_controls:
                # A fresh review is never a refine handoff: confidence is editable.
                self.set_protected_note(False)
                # Fresh review: size filters seed from the run's smart preset
                # (prompt-aware Min size floor; no preset = neutral 0). block-
                # Signals avoids debounced preview refreshes; the plugin renders
                # the preview right after.
                p = preset or {}
                for w in (self.auto_min_size_spin, self.auto_max_size_spin):
                    w.blockSignals(True)
                self.auto_min_size_spin.setValue(float(p.get("min_size_m2", 0.0)))
                self.auto_max_size_spin.setValue(0)
                for w in (self.auto_min_size_spin, self.auto_max_size_spin):
                    w.blockSignals(False)
                # Shape refine seeds from the same preset (Right angles + Fill
                # holes for buildings, Round corners for vegetation, ...), with
                # the static faithful defaults as fallback. blockSignals so this
                # seed never fires the debounced re-derive.
                for w in (self.auto_simplify_spin, self.auto_round_corners_check,
                          self.auto_expand_spin, self.auto_fill_holes_check,
                          self.auto_clean_spin, self.auto_ortho_check):
                    w.blockSignals(True)
                self.auto_simplify_spin.setValue(
                    float(p.get("simplify_px", _AUTO_REVIEW_SIMPLIFY_DEFAULT)))
                self.auto_round_corners_check.setChecked(
                    bool(p.get("smooth", _AUTO_REVIEW_SMOOTH_DEFAULT)))
                self.auto_expand_spin.setValue(
                    int(p.get("expand_px", _AUTO_REVIEW_EXPAND_DEFAULT)))
                self.auto_fill_holes_check.setChecked(
                    bool(p.get("fill_holes", _AUTO_REVIEW_FILL_HOLES_DEFAULT)))
                self.auto_clean_spin.setValue(
                    float(p.get("clean_px", _AUTO_REVIEW_CLEAN_DEFAULT)))
                self.auto_ortho_check.setChecked(
                    bool(p.get("ortho", _AUTO_REVIEW_ORTHO_DEFAULT)))
                for w in (self.auto_simplify_spin, self.auto_round_corners_check,
                          self.auto_expand_spin, self.auto_fill_holes_check,
                          self.auto_clean_spin, self.auto_ortho_check):
                    w.blockSignals(False)
                # Debug tile overlay is off by default each new review (signal-free:
                # the plugin clears the grid when the run started).
                self.auto_show_tiles_check.blockSignals(True)
                self.auto_show_tiles_check.setChecked(False)
                self.auto_show_tiles_check.blockSignals(False)
                # Seed the review confidence slider from the pre-run dial so the
                # starting filter matches what the run used (no signal: the plugin
                # renders the preview right after).
                pct = _snap_review_conf(int(round(self.auto_confidence_spin.value() * 100)))
                self.auto_review_confidence_slider.blockSignals(True)
                self.auto_review_confidence_slider.setValue(pct)
                self.auto_review_confidence_slider.blockSignals(False)
                self.auto_review_confidence_spin.blockSignals(True)
                self.auto_review_confidence_spin.setValue(pct)
                self.auto_review_confidence_spin.blockSignals(False)
            # The header + Export label are set by the plugin's first
            # update_auto_review_count call (right after this, via the review
            # preview push), so both counts and the pct are always consistent.
        self._update_auto_detect_enabled()

    def update_auto_review_count(self, visible: int, total: int, pct: int) -> None:
        """Update the two-line review header + the Export button label after a
        live confidence re-filter. ``visible`` = objects shown now, ``total`` =
        objects the run found, ``pct`` = current confidence cutoff."""
        try:
            self._auto_review_count_label.setText(
                self._format_auto_review_count(visible, total, pct))
            self.auto_export_btn.setText(tr("Export {n} polygons").format(n=visible))
            self.auto_export_btn.setEnabled(visible > 0)
            self.auto_export_btn.setToolTip(
                tr("Lower Confidence to show objects first.") if visible == 0 else "")
        except (RuntimeError, AttributeError):
            pass

    def set_review_conf_lowered_note(self, lowered: bool, pct: int,
                                     adaptive: bool = False,
                                     tuned: bool = False) -> None:
        """Show/hide the starting-cutoff explainer note under the slider.

        Three distinct reasons, three texts: ``tuned`` means the start IS the
        default, just an object-specific one (transparency, not a lowering);
        ``adaptive`` means the run's score distribution lowered the start to
        show more of one coherent population (objects DO score above);
        otherwise nothing scored above the default."""
        if lowered:
            if tuned:
                self.auto_conf_lowered_note.setText(
                    tr("Started at {pct}% - the usual sweet spot for this "
                       "object type.").format(pct=pct))
            elif adaptive:
                self.auto_conf_lowered_note.setText(
                    tr("Started at {pct}% to fit this run's scores - raise "
                       "to tighten.").format(pct=pct))
            else:
                self.auto_conf_lowered_note.setText(
                    tr("Started at {pct}% - nothing scored above.").format(pct=pct))
        self.auto_conf_lowered_note.setVisible(bool(lowered))

    def set_auto_display_mode(self, mode: str) -> None:
        """Programmatically select a review display colour mode ('normal' /
        'outline' / 'confidence' / 'random') without emitting
        auto_display_mode_changed: the plugin stores the mode and applies the
        renderer itself, so the combo must follow silently (never desync)."""
        combo = getattr(self, "auto_display_combo", None)
        if combo is None:
            return
        idx = combo.findData(mode)
        if idx < 0:
            return
        combo.blockSignals(True)
        combo.setCurrentIndex(idx)
        combo.blockSignals(False)

    def set_display_legend(self, text: str) -> None:
        """Set the muted legend line under the Display colors combo (what the
        colours mean for the selected mode). No-op if the label is absent."""
        legend = getattr(self, "auto_display_legend", None)
        if legend is not None:
            legend.setText(text)

    def show_auto_exemplar_nudge(self, object_word: str) -> None:
        """Show the review exemplar nudge. Called by the plugin
        only for bottom-heavy, no-exemplar runs."""
        obj = object_word or tr("object")
        self.auto_exemplar_nudge_link.setText(
            tr("Tip: draw an example of one {object} to catch more of them.").format(
                object=obj))
        self.auto_exemplar_nudge_link.setVisible(True)

    def hide_auto_exemplar_nudge(self) -> None:
        try:
            self.auto_exemplar_nudge_link.setVisible(False)
        except (RuntimeError, AttributeError):
            pass

    def set_auto_exhausted_subscribe_visible(self, visible: bool) -> None:
        """Show/hide the free-user 'Subscribe to finish this zone' link shown
        under the status when a run stops on exhausted credits (Moment C)."""
        try:
            self.auto_exhausted_subscribe_link.setVisible(bool(visible))
        except (RuntimeError, AttributeError):
            pass

    @staticmethod
    def _format_recap_area(area_km2) -> str:
        """Compact km2 for the recap line: integer at scale, else 1-2 decimals
        so a small town-block zone never reads as a bare '0.0'."""
        try:
            a = float(area_km2 or 0.0)
        except (TypeError, ValueError):
            return "0.00"
        if a >= 10:
            return str(int(round(a)))
        if a >= 1:
            return "{:.1f}".format(a)
        return "{:.2f}".format(a)

    def set_last_run_recap(self, count: int, object_word: str, area_km2,
                           credits_used, credits_left=None) -> None:
        """Show the session-only value recap on the Automatic Start page after a
        successful Finish: one quiet line summarizing what the run produced.

        Best-effort by contract (the export already committed): never raises, so
        a recap problem can never surface as a failed Finish. Balance is dropped
        when unknown (the post-run usage refresh has not returned yet)."""
        try:
            recap = getattr(self, "auto_last_run_recap", None)
            if recap is None:
                return
            obj = (object_word or tr("object")).strip() or tr("object")
            area = self._format_recap_area(area_km2)
            if credits_left is not None:
                text = tr(
                    "Last run: {count} {object} exported · {area} km2 "
                    "· {used} credits used, {left} left"
                ).format(count=count, object=obj, area=area,
                         used=credits_used, left=credits_left)
            else:
                text = tr(
                    "Last run: {count} {object} exported · {area} km2 "
                    "· {used} credits used"
                ).format(count=count, object=obj, area=area, used=credits_used)
            recap.setText(text)
            recap.setVisible(True)
        except Exception:  # nosec B110 -- recap is best-effort, never break Finish
            pass

    def clear_last_run_recap(self) -> None:
        """Hide the last-run recap card (session only; called when a new run
        starts). Safe to call when the card was never built."""
        try:
            recap = getattr(self, "auto_last_run_recap", None)
            if recap is not None:
                recap.setVisible(False)
        except (RuntimeError, AttributeError):
            pass

    def set_auto_credit_estimate(self, credits: int) -> None:
        # Remember the estimate so a later balance change (e.g. after a run
        # consumes credits) can re-run the gate against it, see set_auto_credits.
        self._auto_est_credits = credits
        if credits < 0:
            self.auto_credit_cost_label.setText(
                tr("Zone too large - reduce the selection area"))
            self.auto_credit_cost_label.setStyleSheet(
                f"color: {ERROR_TEXT}; font-size: 11px;")
            self.auto_credit_cost_label.setToolTip("")
            self._auto_zone_too_large = True
            self._auto_insufficient_credits = False
        else:
            # Make the per-tile billing explicit right before Detect: the run
            # scans the zone tile by tile and spends 1 credit per tile, so the
            # count reads as the equation "N tiles = N credits" (same N on
            # purpose - that IS the lesson). The footer credit ring owns the
            # remaining balance, so no "M left" suffix here.
            if credits == 1:
                text = tr("≈ 1 tile = 1 credit")
            else:
                text = tr("≈ {n} tiles = {n} credits").format(n=credits)
            remaining = (self._auto_credits if self._auto_is_subscriber
                         else self._auto_free_left)
            # Hard credit gate: a run may never launch
            # under-funded. When the estimate exceeds the known balance, block
            # Detect and turn the cost line red with a fix-it instruction, the
            # same in-context pattern as the "Zone too large" block. This
            # replaces the old amber "will stop after N" partial-run allowance,
            # which let a run burn straight down to 0 and stop mid-zone.
            insufficient = remaining is not None and credits > int(remaining)
            self._auto_insufficient_credits = insufficient
            if insufficient:
                # A subscriber is already paying, so point them at the levers they
                # can pull now (detail/zone); only free users get the subscribe CTA.
                if self._auto_is_subscriber:
                    text = tr(
                        "Not enough credits: {n} tiles, only {left} left. "
                        "Reduce the detail or zone.").format(
                            n=credits, left=int(remaining))
                else:
                    text = tr(
                        "Not enough credits: {n} tiles, only {left} left. "
                        "Reduce the detail or zone, or subscribe.").format(
                            n=credits, left=int(remaining))
                self.auto_credit_cost_label.setStyleSheet(
                    f"color: {ERROR_TEXT}; font-weight: bold; font-size: 11px;")
            else:
                self.auto_credit_cost_label.setStyleSheet(
                    "color: palette(text); font-size: 11px;")
            self.auto_credit_cost_label.setText(text)
            _base_tip = tr(
                "Automatic mode scans your zone tile by tile. 1 tile = 1 credit, "
                "so this run costs about {n} credits. More detail splits the zone "
                "into more tiles, which costs more credits.").format(n=credits)
            _extra_tip = tr("1 credit ~ 0.17 km2 at default detail.")
            self.auto_credit_cost_label.setToolTip(_base_tip + " " + _extra_tip)
            self._auto_zone_too_large = False
        self.auto_credit_cost_label.setVisible(True)
        self._update_auto_detect_enabled()

    def _set_auto_progress_visible(self, visible: bool) -> None:
        """Show/hide the run progress card as one unit (count row + bar + the
        conditional status line)."""
        self.auto_progress_card.setVisible(visible)

    def _refresh_auto_progress_readout(self) -> None:
        """Rebuild the progress card's Row 1 (tile count + live found count) and
        the right-aligned percent from the remembered tile pair + found count."""
        current, total = getattr(self, "_auto_progress_pair", (0, 0))
        found = getattr(self, "_auto_found_count", 0)
        count_txt = tr("Tile {current}/{total}").format(
            current=max(current, 0), total=total)
        if found > 0:
            count_txt += (
                ' <span style="color: rgba(128,128,128,0.95);">· ' + tr("{n} found so far").format(n=found) + "</span>")
        self.auto_progress_count_label.setText(count_txt)
        pct = int(round(current / total * 100)) if total and current > 0 else 0
        self.auto_progress_pct_label.setText("{}%".format(max(0, min(100, pct))))

    def set_auto_tile_progress(self, current: int, total: int) -> None:
        self.auto_status_banner.setVisible(False)
        self.auto_tile_progress.setMaximum(total)
        self.auto_tile_progress.setValue(current)
        # Remembered so a cleared queue state can restore the live tile count.
        self._auto_progress_pair = (current, total)
        self._refresh_auto_progress_readout()
        # Before the first tile returns (cold start can take ~10s) the count row
        # sits at "Tile 0/N" and would read as frozen. Keep an honest "Sending..."
        # status line (Row 3) until the first tile lands; once tiles flow, drop
        # Row 3 (no queue message) so the card stays clean.
        if self._auto_cancelling:
            # A cancel is winding down: hold the reassuring stop note on Row 3
            # even as salvaged tiles tick the count up (see set_auto_cancelling).
            self.auto_progress_label.setText(
                tr("Stopping - keeping the tiles already found…"))
            self.auto_progress_label.setVisible(True)
        elif current <= 0:
            self.auto_progress_label.setText(tr("Sending to the AI…"))
            self.auto_progress_label.setVisible(True)
        else:
            self.auto_progress_label.setVisible(False)
        self._set_auto_progress_visible(True)
        self._auto_progress_ratio = (current / total) if total else 0.0

    def set_auto_queue_state(self, position: int, depth: int, eta_s: int) -> None:
        """Honest launch-spike feedback on the progress bar's label. The server
        answers a saturated moment with a real place in its fair queue; showing
        that place (and watching it move) is what keeps a user from reading the
        wait as a hang. position >= 1 = known place in line; -1 = busy but no
        position known (older server / platform rejection / cold start);
        (0, 0, 0) = flowing again, restore the tile count. The bar itself is
        never animated on a timer: only real state changes repaint it."""
        if not self.auto_progress_card.isVisible():
            return
        if position == 0 and depth == 0:
            current, total = getattr(self, "_auto_progress_pair", (0, 0))
            self.set_auto_tile_progress(current, total)
            return
        # Positive, customer-centric copy: the user's fear is "did it break /
        # will I get my result", so the label says their spot is SAFE and when
        # it starts (honest ETA from the server, it visibly shrinks) - never
        # "you are behind N people".
        if position == 1:
            text = tr("You're next · starting now…")
        elif position > 1:
            if 0 < eta_s < 10:
                text = tr("Spot reserved · starting in a few seconds…")
            else:
                eta = self._friendly_eta(eta_s)
                if eta:
                    text = tr("Spot reserved · starting in ~{eta}").format(eta=eta)
                else:
                    text = tr("Spot reserved · starting soon…")
        else:
            text = tr("High demand · your spot is held…")
        self.auto_progress_label.setText(text)
        self.auto_progress_label.setVisible(True)

    @staticmethod
    def _friendly_eta(eta_s: int) -> str:
        """Rounded, human wait estimate ('' when unknown or tiny). Never
        false-precise: seconds snap to 5s steps, past a minute whole minutes."""
        if eta_s is None or eta_s < 10:
            return ""
        if eta_s < 60:
            return tr("{s} seconds").format(s=int(round(eta_s / 5.0) * 5))
        return tr("{m} min").format(m=int((eta_s + 59) // 60))

    def set_auto_status(self, kind: str, message: str = "") -> None:
        """Single surface for run feedback. kind: 'idle', 'progress', 'info',
        'error'. Exactly one of progress bar / status banner is visible at a
        time; 'idle' hides both and clears any stale text."""
        if kind == "progress":
            self.auto_status_banner.setVisible(False)
            return  # set_auto_tile_progress drives the bar itself
        self._set_auto_progress_visible(False)
        if kind == "idle" or not message:
            self.auto_status_banner.setVisible(False)
            self.auto_status_banner.setText("")
            return
        if kind == "error":
            self.auto_status_banner.setStyleSheet(
                "background-color: rgba(239, 83, 80, 0.10);"
                " border: 1px solid rgba(239, 83, 80, 0.45);"
                " border-radius: 4px; padding: 8px;"
                f" color: {ERROR_TEXT}; font-size: 12px;")
        else:
            self.auto_status_banner.setStyleSheet(
                "background-color: rgba(128, 128, 128, 0.08);"
                " border: 1px solid rgba(128, 128, 128, 0.2);"
                " border-radius: 4px; padding: 8px;"
                " color: palette(text); font-size: 12px;")
        self.auto_status_banner.setText(message)
        self.auto_status_banner.setVisible(True)
