"""Install progress, session state, instructions, exemplar chips, cleanup.

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.core import QgsProject, QgsRasterLayer
from qgis.PyQt.QtCore import QLocale, Qt
from qgis.PyQt.QtWidgets import (
    QFrame,
    QLabel,
    QToolButton,
    QWidget,
)

from ...core.cloud_notice_seen import cloud_notice_seen
from ...core.i18n import tr
from ...core.surface_dials import install_eta_ceiling_s, install_eta_honest_s
from .cloud_notice_line import cloud_notice_line_html
from .font_scale import scale_qss_font_px, widget_pixel_ratio
from .guidance import (
    HINT_EXEMPLAR_DRAW_BOX,
    HINT_EXEMPLAR_EXCLUDE_BOX,
    HINT_PREVIEW_ZOOM,
    HINT_TUTORIAL_FIRST_STEPS,
    is_hint_dismissed,
)
from .setup_status_text import setup_status_sentence
from .styles import (
    _INSTRUCTIONS_CARD_QSS,
    _INSTRUCTIONS_HINT_QSS,
    BRAND_GREEN,
    ERROR_TEXT,
    _msg_label_qss,
    _msg_text,
)
from .widgets import (
    Mode,
)

# The two signs of the click legend: extend and trim. They quote the canvas,
# where a left click drops a green dot (MARKER_POSITIVE) and a right click a red
# one (MARKER_NEGATIVE), so the legend and the map name the same two things with
# the same two colours. A "+" and a "−" said it in the panel's own grey and lost
# that link, which is the only reason the legend is there.
_SIGN_ADD = "\U0001F7E2"
_SIGN_TRIM = "\u274C"

# The install countdown. Past the first figure the estimate stops naming a
# number and says "more than", because a fresh install on a slow link runs far
# longer than any early reading suggests. The second figure is the hard cap on
# the arithmetic, so a stalled tick cannot produce an absurd countdown.
_INSTALL_ETA_HONEST_S = 20 * 60
_INSTALL_ETA_CEILING_S = 4 * 60 * 60


def format_km2_left(value) -> str:
    """A km² balance for a gauge: at most one decimal, no trailing ".0".

    One decimal is what a user can act on (3, 2.4). A balance smaller than
    0.05 km² keeps a second decimal, because rounding it to 0 would read as
    spent while the run is still allowed. Under the second decimal too, the
    figure becomes "< 0.01": a balance that still allows a run must never be
    printed as 0.
    """
    try:
        number = float(value)
    except (TypeError, ValueError):
        return "0"
    rounded = round(number, 1)
    if number > 0 and rounded == 0:
        rounded = round(number, 2)
        if rounded == 0:
            return "< 0.01"
    return f"{rounded:g}"


def _gauge_percent_used(used, cap, left) -> int:
    """The share of an envelope spent, as a whole percent for a ring arc.

    Held one short of full while anything is left: a ring drawn full says the
    allowance is gone, and the user can still run.
    """
    try:
        percent = min(100, int(round(100.0 * float(used) / float(cap))))
    except (TypeError, ValueError, ZeroDivisionError):
        return 0
    if percent >= 100 and (left or 0) > 0:
        return 99
    return max(0, percent)


def format_quota_count(value) -> str:
    """A quota count with its thousands grouped the way the user's locale does.

    A hard-coded comma reads as a decimal point to a large part of the fleet,
    so 1,500 objects left can read as one and a half.
    """
    try:
        return QLocale().toString(int(value or 0))
    except (TypeError, ValueError):
        return "0"


def format_km2_surface(value) -> str:
    """The surface of one drawn zone, as many digits as it is worth reading.

    Precision follows size: under 1 km² two decimals (0.41), under 10 one
    (2.3), above that none (14). A zone is priced by this number, so a small
    one must not round to 0 and a large one must not carry noise digits.

    Under the two-decimal step the digits keep going instead of collapsing.
    Two decimals turned every zone below 0.005 km² into "0", which reads as a
    free run over ground the account is charged for; four decimals cover a
    zone down to a hundred square metres, and anything smaller still prints
    its smallest non-zero step rather than zero.
    """
    try:
        number = max(0.0, float(value))
    except (TypeError, ValueError):
        return "0"
    if number <= 0:
        return "0"
    if number < 0.005:
        return f"{max(number, 0.0001):.4f}".rstrip("0").rstrip(".")
    if number < 1:
        return f"{round(number, 2):g}"
    if number < 10:
        return f"{round(number, 1):g}"
    return f"{int(round(number))}"


class DockStateMixin:
    """Install progress, session state, instructions, exemplar chips, cleanup."""

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

    #: The tick that fronts a ready line. It belongs to the label, not to the
    #: sentence: baked into a caller's string it travelled into every
    #: translation and each caller had to remember it.
    _READY_MARK = "\u2713"

    def _set_install_button_kind(self, kind: str, text: str) -> None:
        """Label the setup primary and record WHICH install it offers.

        Later ticks branch on the kind, never on the label: the label is
        translated, and it also carries wordings ("Retry", the model-only
        download) that no reading of "Install" or "Update" can tell apart.
        """
        self._install_kind = (kind, text)
        try:
            self.install_button.setText(text)
        except (RuntimeError, AttributeError):
            pass

    def _install_button_kind(self) -> str:
        """The install the setup primary offers now: "install", "update" or
        "retry".

        Falls back to reading the label when another surface wrote it (the
        model-only download does), so the recorded kind is never trusted over
        a label it does not match.
        """
        recorded = getattr(self, "_install_kind", None)
        try:
            live = self.install_button.text()
        except (RuntimeError, AttributeError):
            live = ""
        if isinstance(recorded, tuple) and len(recorded) == 2 and recorded[1] == live:
            return str(recorded[0])
        if live == tr("Update"):
            return "update"
        if live == tr("Retry"):
            return "retry"
        return "install"

    def set_dependency_status(self, ok: bool, message: str, mark: bool = True):
        """Show the setup status. ``mark`` fronts a ready line with the tick.

        Pass mark=False for a state that is good but not finished, so the
        tick keeps meaning "nothing left to do".
        """
        self._dependencies_ok = ok

        if ok:
            text = message or ""
            if text and mark and not text.startswith(self._READY_MARK):
                text = f"{self._READY_MARK} {text}"
            self.setup_status_label.setText(text)
            self.setup_status_label.setToolTip("")
            self.setup_status_label.setVisible(True)
            self.setup_status_label.setStyleSheet("font-weight: bold; color: palette(text);")
            self.install_button.setVisible(False)
            self.cancel_toggle.setVisible(False)
            self.cancel_button.setVisible(False)
            self.setup_progress.setVisible(False)
            self.setup_progress_label.setVisible(False)
        else:
            # The startup check answers in raw English written for a log line.
            # Read the two flags below off that raw text, then show the user
            # the sentence for it in their own language.
            is_update = "updating" in message.lower() or "upgrading" in message.lower()
            is_dll_error = "dll" in message.lower() and "failed" in message.lower()
            display = setup_status_sentence(message) or message
            if is_dll_error:
                display = tr(
                    "Missing Visual C++ Redistributable. "
                    "Install it, restart your computer, then click Retry.")
                self.setup_status_label.setText(display)
                self.setup_status_label.setStyleSheet(
                    f"font-weight: bold; color: {ERROR_TEXT};")
                self.setup_status_label.setVisible(True)
                self._set_install_button_kind("retry", tr("Retry"))
            else:
                # Show what came in. Three callers report a refused install
                # through this line and nowhere else (a sandboxed QGIS, a Mac
                # with no local runtime, a removal of the downloaded AI data),
                # so hiding it left the panel saying nothing at all about a
                # click that had just done nothing.
                self.setup_status_label.setText(display)
                self.setup_status_label.setStyleSheet(
                    "font-weight: bold; color: palette(text);")
                self.setup_status_label.setVisible(bool(display))
                if is_update:
                    self._set_install_button_kind("update", tr("Update"))
                else:
                    self._set_install_button_kind("install", tr("Install"))
            # The raw text is what a bug report needs, so it stays one hover
            # away. Cleared when the line already shows it, so no tooltip
            # repeats the sentence under it.
            self.setup_status_label.setToolTip(
                message if display != message else "")
            self.install_button.setVisible(True)
            self.install_button.setEnabled(True)
            self.setup_group.setVisible(True)
            # The setup section is now the interactive content: remember it so a
            # switch to Automatic and back restores it (see _setup_section_wanted).
            self._setup_section_wanted = True
            # An install that refused to start reports itself here and starts
            # no worker, so nothing will ever tick. With nothing on the wire
            # the offline-install window has nothing left to show: hand the
            # page back now rather than after a watchdog. Guarded on the
            # running install so a status arriving mid-download cannot close a
            # window that is doing its job.
            if not self._manual_install_running():
                self._manual_install_wants_model = False
                self._finish_manual_install_window(False)

        self._update_full_ui()

    def set_install_progress(self, percent: int, message: str,
                             state: str | None = None):
        """Unified progress for deps install + model download.

        ``state`` names how the install ended: "cancelled", "failed", or None
        while it is still running. Reading the end off the displayed sentence
        was wrong in every language but English, so the caller says it. The
        text test stays as the fallback for a caller that says nothing.
        """
        import time

        # Mirror progress into the review's inline install banner while a
        # background install runs from the Automatic review (D1). Harmless no-op
        # otherwise. The Manual setup group below stays hidden in Automatic mode.
        if getattr(self, "_auto_review_installing", False):
            try:
                self.auto_review_install_progress.setValue(
                    max(0, min(100, int(percent))))
                if message:
                    self.auto_review_install_label.setText(message)
            except (RuntimeError, AttributeError):
                pass

        self._target_progress = percent

        time_info = ""
        now = time.time()
        if percent > 10 and percent < 100 and self._install_start_time:
            elapsed = now - self._install_start_time
            if elapsed > 5:
                overall_speed = percent / elapsed
                remaining_pct = 100 - percent

                has_prev = self._last_percent_time is not None
                pct_increased = percent > self._last_percent
                time_increased = now > self._last_percent_time if has_prev else False
                if has_prev and pct_increased and time_increased:
                    dt = now - self._last_percent_time
                    dp = percent - self._last_percent
                    recent_speed = dp / dt
                    blended_speed = 0.7 * recent_speed + 0.3 * overall_speed
                else:
                    blended_speed = overall_speed

                if blended_speed > 0:
                    remaining = remaining_pct / blended_speed
                    # A long install used to be clamped to eight minutes and
                    # then sat on "~8 min left" for the next half hour. The
                    # ceiling is now high enough to cover a slow link, and
                    # anything past the honest-estimate mark says so instead
                    # of naming a number nobody should trust.
                    honest_s = install_eta_honest_s(_INSTALL_ETA_HONEST_S)
                    remaining = min(remaining, install_eta_ceiling_s(_INSTALL_ETA_CEILING_S))
                    if remaining > honest_s:
                        time_info = " " + tr("(more than {n} min left)").format(
                            n=int(honest_s / 60))
                    elif remaining > 60:
                        time_info = " " + tr("(~{n} min left)").format(
                            n=int(remaining / 60))
                    elif remaining > 10:
                        time_info = " " + tr("(~{n} sec left)").format(
                            n=int(remaining))

        if percent > self._last_percent:
            self._last_percent_time = now
            self._last_percent = percent

        self.setup_progress_label.setText(f"{message}{time_info}")
        # The same line, mirrored into the offline-install window when one is
        # up. Here rather than at the top of the method, so the window gets the
        # estimate with it: "how long is left" is the whole reason anybody
        # watches an install. A no-op for every install this window did not ask
        # for (the review's lanes, Automatic's light setup), which carry their
        # own progress.
        self._mirror_manual_install_progress(percent, f"{message}{time_info}")

        is_update = self._install_button_kind() == "update"

        # How this tick ended. The caller's word wins; the text test only
        # answers for a caller that passed nothing.
        lowered = (message or "").lower()
        install_cancelled = (state == "cancelled"
                             or (state is None and "cancel" in lowered))
        install_failed = (state == "failed"
                          or (state is None and "failed" in lowered))

        if percent == 0:
            # An install just started: the setup section owns the interactive
            # content until it completes, so a mode round trip must restore it.
            self._setup_section_wanted = True
            # A fresh attempt clears the last one's verdict. Without this the
            # panel keeps the failed install's card up over a running one.
            self._manual_install_failed = False
            self._install_start_time = time.time()
            self._current_progress = 0
            self._last_percent = 0
            self._last_percent_time = None
            self._creep_counter = 0
            self.setup_progress.setValue(0)
            self.setup_progress.setVisible(True)
            self.setup_progress_label.setVisible(True)
            # The Cancel button, straight away. It used to hide behind a
            # disclosure arrow, so stopping an install took three actions and
            # two of them said nothing: open the arrow, press the button that
            # appears, then confirm. The confirm is the one that matters.
            self.cancel_toggle.setVisible(False)
            self.cancel_button.setText(tr("Cancel installation"))
            self.cancel_button.setVisible(True)
            self.install_button.setVisible(False)
            self.setup_status_label.setVisible(False)
            self.welcome_title.setText(tr("Installing AI Segmentation..."))
            self._progress_timer.start(500)
        elif percent >= 100 or install_cancelled or install_failed:
            self._progress_timer.stop()
            self._install_start_time = None
            # Whatever this install carried, it is over. The next one says for
            # itself whether it brings the on-device AI.
            self._manual_install_wants_model = False
            self.setup_progress.setValue(percent)
            self.setup_progress.setVisible(False)
            self.setup_progress_label.setVisible(False)
            self.cancel_toggle.setVisible(False)
            self.cancel_button.setVisible(False)
            self.install_button.setVisible(True)
            self.install_button.setEnabled(True)
            if is_update:
                self._set_install_button_kind("update", tr("Update"))
            else:
                self._set_install_button_kind("install", tr("Install"))
            if install_cancelled:
                self.setup_status_label.setVisible(True)
                self.setup_status_label.setText(tr("Installation cancelled"))
                self.welcome_title.setText(tr("Click Install to set up AI Segmentation"))
            elif install_failed:
                self.setup_status_label.setVisible(True)
                self.setup_status_label.setText(tr("Installation failed"))
                self.welcome_title.setText(tr("Click Install to set up AI Segmentation"))
                # The one state that hands the page back to the install card:
                # it carries the real message and the Retry button, which a
                # window closing on the failure would take away with it.
                self._manual_install_failed = True
            else:
                self.welcome_title.setText(tr("Click Install to set up AI Segmentation"))
            # The install is over, whichever way. The window goes with it, and
            # a failure asks the panel to take over.
            self._finish_manual_install_window(not install_failed)
        else:
            # Intermediate tick (1..99). The deps phase hides the bar via
            # set_dependency_status once deps are validated; the very next
            # callback then enters the model-download/load phase but never
            # re-shows the progress UI, so users sat on "Dependencies ready"
            # for several minutes with no visible activity. Re-asserting
            # visibility here keeps the hand-off seamless.
            self.setup_progress.setVisible(True)
            self.setup_progress_label.setVisible(True)
            # An install does not always open at 0. With the packages already
            # on disk only the model file is left, and that one starts at 80,
            # so the timer the rest of the dock reads to answer "is a download
            # on the wire" never started: the offline-install window's watchdog
            # called a live download failed, and Start offered to run a second
            # install over the same environment.
            if not self._progress_timer.isActive():
                self._progress_timer.start(500)
            if self._current_progress < percent:
                self._current_progress = percent
                self.setup_progress.setValue(percent)
            msg_lower = message.lower() if message else ""
            if "loading" in msg_lower and "model" in msg_lower:
                self.welcome_title.setText(tr("Loading AI model..."))
            elif "downloading" in msg_lower and "model" in msg_lower:
                self.welcome_title.setText(tr("Downloading AI model..."))
            elif "verifying" in msg_lower:
                self.welcome_title.setText(tr("Verifying installation..."))

    def _on_progress_tick(self):
        """Animate progress bar smoothly between updates."""
        if self._current_progress < self._target_progress:
            step = max(1, (self._target_progress - self._current_progress) // 3)
            self._current_progress = min(
                self._current_progress + step, self._target_progress)
            self._creep_counter = 0
        elif self._current_progress < 99 and self._target_progress > 0:
            self._creep_counter += 1
            if self._creep_counter >= 4:
                self._creep_counter = 0
                if self._current_progress < self._target_progress + 5:
                    self._current_progress += 1

        self.setup_progress.setValue(self._current_progress)

    def set_checkpoint_status(self, ok: bool, message: str):
        self._checkpoint_ok = ok
        if not ok:
            # The one caller is the model refusing to load, which is the end of
            # that install and sends no further tick. Nothing else would stop
            # the timer, and a timer left running says a download is on the
            # wire for the rest of the session: Start stays grey behind "the
            # offline AI is still downloading" with nothing downloading.
            try:
                self._progress_timer.stop()
            except (RuntimeError, AttributeError):
                pass  # nosec B110 -- teardown
            self._manual_install_wants_model = False
            # The caller carries the reason the model refused to load. Without
            # it on screen the panel goes back to its idle text and the click
            # that just failed reads as if it did nothing.
            if message:
                self.setup_status_label.setText(message)
                self.setup_status_label.setStyleSheet(
                    f"font-weight: bold; color: {ERROR_TEXT};")
                self.setup_status_label.setVisible(True)
                self.setup_group.setVisible(True)
                self._setup_section_wanted = True
        if ok:
            self.setup_status_label.setText(message)
            self.setup_status_label.setStyleSheet("font-weight: bold; color: palette(text);")
            self.setup_group.setVisible(False)
            # Model ready = setup done: the setup section is no longer the
            # interactive content, so a later mode round trip must not resurrect it.
            self._setup_section_wanted = False
            self._manual_install_failed = False
            self._manual_install_wants_model = False
            # The offline AI is on the machine, so the window that was asking
            # for it has nothing left to say.
            self._finish_manual_install_window(True)
        self._update_full_ui()

    def set_segmentation_active(self, active: bool, layer=None):
        self._segmentation_active = active

        # Track which layer we're segmenting. The caller may pass the layer
        # explicitly (the Refine-in-Manual handoff starts on the resolved run
        # raster, which the combo may not yet reflect); fall back to the combo
        # for standalone Manual. Keeps MCP/other callers backward-compatible.
        if active:
            layer = layer or self.layer_combo.currentLayer()
            self._segmentation_layer_id = layer.id() if layer else None
        else:
            self._segmentation_layer_id = None

        # A live session owns its raster. The combo otherwise follows the map
        # view, and a pan away from the work would swap the layer under it.
        # Freezing goes with it: the tracking flag alone leaves the layer-tree
        # signals free to re-pick when a layer is hidden, added or reordered.
        self.layer_combo.set_view_tracking(not active)
        self.layer_combo.set_frozen(active)

        self._update_button_visibility()
        self._update_ui_state()
        # Starting/stopping a Manual session crosses the Start screen boundary,
        # so re-evaluate whether the mode switch belongs on screen.
        self._refresh_mode_switch_visibility()
        # Same boundary for the engine box: the route is fixed for the life of
        # a session, so a box that could still be ticked would promise
        # something it cannot do. Driven here rather than from _update_full_ui
        # because that one does not run on a session start or stop.
        self._refresh_manual_engine_ui()
        self._refresh_manual_credit_gate()
        if active:
            self._update_instructions()

    def _set_layer_combo_style(self, qss: str) -> None:
        """Write the picker's stylesheet only when it changes: this runs on
        every refresh, and a stylesheet write re-polishes the whole combo."""
        if getattr(self, "_layer_combo_qss", None) == qss:
            return
        self._layer_combo_qss = qss
        try:
            self.layer_combo.setStyleSheet(qss)
        except (RuntimeError, AttributeError):
            pass

    def _update_button_visibility(self):
        if self._segmentation_active:
            # Hide label, lock combo (grayed out, no dropdown arrow)
            self.layer_label.setVisible(False)
            self.layer_combo.setEnabled(False)
            self._set_layer_combo_style(
                "QComboBox { color: palette(text); }"
                "QComboBox::drop-down { width: 0px; border: none; }")

            self.start_container.setVisible(False)
            # In a refine handoff the state card IS the guidance (and carries
            # the action buttons); the instructions label belongs to base Manual.
            self.instructions_label.setVisible(not self._refine_handoff)
            self._update_instructions()

            # Refine panel visibility
            self._update_refine_panel_visibility()

            # Save/Undo/Stop are base-Manual controls. In a refine handoff the
            # state card owns every action (Keep / Edit shape / Undo click /
            # Remove), so the legacy rows stay hidden for the whole handoff.
            #
            # These three are the session's fixed row and stay GREYED when
            # they have nothing to act on, where Export HIDES. The exception is
            # deliberate: they are the gesture the session teaches (click,
            # then save), so a row that appeared only after the first click
            # would hide the very step the user is looking for, and the panel
            # would jump under the cursor on every click. Export names an
            # outcome that does not exist yet, so it has nothing to teach.
            save_visible = not self._refine_handoff
            self.save_mask_button.setVisible(save_visible)
            self.save_mask_button.setEnabled(self._has_mask)

            # Export button: shown once a polygon is kept, and never during a
            # refine handoff. _update_export_button_style owns both rules.
            self._update_export_button_style()

            secondary_visible = not self._refine_handoff
            self.secondary_buttons_widget.setVisible(secondary_visible)
            self.undo_button.setVisible(secondary_visible)
            has_points = self._positive_count > 0 or self._negative_count > 0
            can_undo_saved = self._saved_polygon_count > 0
            self.undo_button.setEnabled(has_points or can_undo_saved)
            # Stop discards the manual session; in a refine handoff that would
            # bypass the return to review, so hide it (Back to review is the exit).
            self.clear_selection_button.setVisible(secondary_visible)
            self.clear_selection_button.setEnabled(has_points)
            self.stop_button.setVisible(not self._refine_handoff)
            self.stop_button.setEnabled(True)

            # Info box ("one element at a time / save before next"). In a refine
            # handoff the blue refine hint already says this, so hide it to avoid
            # two stacked hints saying the same thing.
            self.batch_info_widget.setVisible(not self._refine_handoff)

            # The zoom-precision tip belongs to the same state as the hover
            # ghost it explains. Hidden everywhere else, never greyed.
            self.preview_zoom_hint.setVisible(self._preview_zoom_tip_wanted())
        else:
            # Not segmenting - show label, unlock combo, restore dropdown arrow
            self.layer_label.setVisible(True)
            self.layer_combo.setEnabled(True)
            self._set_layer_combo_style("QComboBox { color: palette(text); }")

            self.start_container.setVisible(True)
            self.instructions_label.setVisible(False)
            self.preview_zoom_hint.setVisible(False)
            self.refine_group.setVisible(False)
            self.save_mask_button.setVisible(False)
            self.export_button.setVisible(False)
            self.undo_button.setVisible(False)
            self.clear_selection_button.setVisible(False)
            self.stop_button.setVisible(False)
            self.secondary_buttons_widget.setVisible(False)
            self.batch_info_widget.setVisible(False)

    def _should_show_auto_tutorial(self) -> bool:
        """True only on the Automatic Start step: signed in, Automatic mode, on
        step 0, hint not dismissed. Hidden mid-flow, during a run/review, and in
        Manual mode."""
        if not (self._plugin_activated and self._mode == Mode.AUTOMATIC):
            return False
        if is_hint_dismissed(HINT_TUTORIAL_FIRST_STEPS):
            return False
        try:
            # The empty state shows ONLY the hero card (one info per state):
            # the guide banner waits until imagery exists.
            if self.auto_layer_combo.count_layers() == 0:
                return False
        except (RuntimeError, AttributeError):
            pass
        try:
            return self.auto_steps.currentIndex() == 0
        except (RuntimeError, AttributeError):
            return not getattr(self, "_auto_started", False)

    def _update_auto_tutorial_banner_visibility(self):
        """Drive the bottom-pinned first-steps guide banner from its gate."""
        banner = getattr(self, "auto_tutorial_banner", None)
        if banner is not None:
            banner.setVisible(self._should_show_auto_tutorial())

    def _refine_preview_live(self) -> bool:
        """Whether the hover ghost makes refine worth showing before any click.

        A cloud session with previews on re-shapes the ghost live as a refine
        control moves, so the panel earns its place from the first hover. An
        offline session has no ghost, and there the panel keeps waiting for
        the first mask. An empty balance has no ghost either, so the credits
        gate belongs here too. Fail-closed: an unreadable gate means no
        preview.
        """
        try:
            from ...core.hover_preview_client import hover_preview_offered

            return bool(self._manual_cloud_route_picked()
                        and hover_preview_offered()
                        and not self._manual_credits_exhausted())
        except Exception:  # noqa: BLE001 -- an unreadable gate is a closed one
            return False

    def _preview_zoom_tip_wanted(self) -> bool:
        """The zoom tip belongs to ONE state: a live base Semi-Auto session
        whose clicks go to Cloud AI with the hover preview on. The offline
        engine never sees this cloud-only affordance (hidden, never greyed),
        and the handoff has its own guidance."""
        if not self._segmentation_active or self._refine_handoff:
            return False
        if is_hint_dismissed(HINT_PREVIEW_ZOOM):
            return False
        return self._refine_preview_live()

    def _update_refine_panel_visibility(self):
        """Update refine panel visibility based on mask state."""
        # Which engine answers the clicks decides what the panel carries, and
        # this runs on every refresh, so a switch before Start lands here.
        try:
            self._sync_refine_shape_toggles()
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- panel not built yet, or torn down
        if not self._segmentation_active:
            self.refine_group.setVisible(False)
            return

        if self._refine_handoff:
            # "Shape settings" belong to the OPEN edit only (the geometry-delta
            # edit has no SAM points, so _has_mask alone would keep the panel
            # hidden forever). A mere selection shows just Edit shape / Remove.
            # The open fix session is what earns the panel: a reshape has no
            # SAM points of its own, so _has_mask alone kept Shape settings
            # hidden until the first click.
            self.refine_group.setVisible(
                self._has_mask
                or bool(getattr(self, "_auto_correct_session_active", False)))
            return

        # One panel in both engines, open from the start of the session: the
        # settings are what a user reads before their first click, and an
        # offline session that hid them until a mask existed made the mode look
        # like it had fewer of them. What differs between the engines is the
        # two cloud-only toggles inside (_sync_refine_shape_toggles), not
        # whether the panel is there.
        self.refine_group.setVisible(True)

    def _update_export_button_style(self):
        count = self._saved_polygon_count
        if count > 1:
            self.export_button.setText(
                tr("Export {count} polygons to a layer").format(count=count)
            )
        else:
            self.export_button.setText(tr("Export polygon to a layer"))

        # Nothing kept means nothing to export, so the primary goes away
        # instead of standing there greyed. It comes back on the first saved
        # polygon. Hidden for the whole refine handoff too, where committing
        # goes through Back to review -> Finish (a direct manual export would
        # orphan the held Automatic review).
        show = bool(count > 0 and self._segmentation_active
                    and not self._refine_handoff)
        self.export_button.setVisible(show)
        if not show:
            return
        self.export_button.setEnabled(True)
        self.export_button.setToolTip(
            tr("Writes a GeoPackage layer with your {n} kept polygons.").format(
                n=count))

    def set_point_count(self, positive: int, negative: int):
        self._positive_count = positive
        self._negative_count = negative
        total = positive + negative
        has_points = total > 0
        old_has_mask = self._has_mask
        self._has_mask = has_points

        # Undo enabled if: has points OR has saved masks
        can_undo_saved = self._saved_polygon_count > 0
        self.undo_button.setEnabled((has_points or can_undo_saved) and self._segmentation_active)
        self.clear_selection_button.setEnabled(
            has_points and self._segmentation_active)
        self.save_mask_button.setEnabled(has_points)

        if self._segmentation_active:
            self._update_instructions()
            self._update_export_button_style()
            if old_has_mask != self._has_mask:
                self._update_refine_panel_visibility()
                # Opening/closing an object flips whether Save/Undo apply; in a
                # handoff that also flips their visibility, so re-assert it.
                if self._refine_handoff:
                    self._update_button_visibility()

    def _set_instructions_style(self, style: str) -> None:
        """Restyle the instructions label. Three looks, one label:

        - "card": the framed Manual guidance, the resting look;
        - "compact": the muted hint used for refine-handoff status lines;
        - "waiting": the info-tinted line the plugin shows while it reads
          imagery. Same taxonomy kind as the review's Correct step uses for
          the same wait, so a user who learned one reads the other.

        No-op when the label already wears the requested look."""
        if getattr(self, "_instructions_style", "card") == style:
            return
        self._instructions_style = style
        if style == "waiting":
            self.instructions_label.setStyleSheet(_msg_label_qss("info"))
        else:
            self.instructions_label.setStyleSheet(
                _INSTRUCTIONS_HINT_QSS if style == "compact"
                else _INSTRUCTIONS_CARD_QSS)
        self.instructions_label.setMinimumHeight(0 if style == "compact" else 70)

    def set_manual_encoding(self, reading: bool, phase: str = "imagery") -> None:
        """Base Manual is waiting on something the click needs, or has stopped.

        That wait takes seconds and used to show nothing but an hourglass, so
        the instruction card says what it is for. One line, same card, no
        second place to look. ``phase`` names which wait it is: ``imagery``
        for the pixels around the click, ``remote`` for an answer travelling
        back from a machine that may still be starting."""
        want = bool(reading)
        phase = phase if want else "imagery"
        if (getattr(self, "_manual_encoding", False) == want
                and getattr(self, "_manual_encoding_phase", "imagery") == phase):
            return
        self._manual_encoding = want
        self._manual_encoding_phase = phase
        if reading:
            # A new read is the user acting on whatever the last notice asked
            # for, so the notice has stopped being true. Cleared before the
            # flag is read below, or the wait line would land under it.
            self.clear_manual_notice()
        self._update_instructions()

    def _update_instructions(self):
        """Update instruction text based on current segmentation state."""
        total = self._positive_count + self._negative_count

        if self.manual_notice_is_live():
            # A click failed for a reason the user can fix. The notice owns the
            # card until it expires or the next read starts (dock/manual_notice).
            return

        if self._refine_handoff:
            # In-place AI reshape: the Correct card carries the guidance and the
            # Done/Undo buttons (auto_correct_build.py); the Manual instruction
            # label stays out of the way. No separate handoff card any more.
            self.instructions_label.setVisible(False)
            return

        if getattr(self, "_manual_encoding", False):
            # The only state where the panel says what the plugin is doing
            # rather than what to do next: the click has been taken, and what
            # it is waiting on is named. Two waits, two sentences: reading the
            # imagery is the machine's own disk or the tile server, answering
            # is a machine elsewhere that may still be starting up. Pointing at
            # the wrong one sends the user to check the wrong thing.
            self._set_instructions_style("waiting")
            phase = getattr(self, "_manual_encoding_phase", "")
            if phase == "remote":
                line = tr("Sending to the AI...")
            elif phase == "encode":
                line = tr("Preparing the imagery for the AI...")
            else:
                line = tr("Reading the imagery around your click...")
            self.instructions_label.setText(_msg_text("info", line))
            return
        self._set_instructions_style("card")
        if total == 0 and self._saved_polygon_count > 0:
            # Already saved polygon(s), encourage next or export
            text = (
                tr("Polygon saved ({n} total). Click another element, or export "
                   "when done.").format(n=self._saved_polygon_count) + "\n\n"
                + _SIGN_ADD + "  " + tr("Left-click to select")
            )
        elif total == 0:
            text = (
                tr("Click the object you want to segment:") + "\n\n"
                + _SIGN_ADD + "  " + tr("Left-click to select")
            )
        else:
            text = (
                _SIGN_ADD + "  " + tr("Left-click to add more") + "\n"
                + _SIGN_TRIM + "  " + tr("Right-click to exclude from selection")
            )

        self.instructions_label.setText(text)

    def closeEvent(self, event):
        # T12 (decision locked): a paid Automatic run CONTINUES in the
        # background when the dock is closed; a message-bar notice says how
        # to get back to it. Completion while hidden lands in the review
        # (the flags pin the page for the next reopen).
        if self._auto_run_active:
            try:
                from qgis.utils import iface as _iface
                _iface.messageBar().pushInfo(
                    "AI Segmentation",
                    tr("Detection continues in the background. "
                       "Reopen AI Segmentation to follow it."))
            except Exception:
                pass  # nosec B110
        elif bool(getattr(self, "_qgis_bridge_active_ui", False)):
            # A Manual (vertex) fix owns a live QGIS edit session on the layer.
            # Hiding the dock over it leaves the user editing with no panel and
            # no way back, so finish it the way the panel's own Save does.
            try:
                self.auto_qgis_bridge_done_requested.emit()
            except (TypeError, RuntimeError):
                pass
        elif self._refine_handoff:
            # T13: closing mid-reshape folds the AI edits into the held review,
            # then lets the dock hide (the review is there on reopen). The fold
            # also ends the manual session, so the stop-dialog branch below
            # no-ops.
            try:
                self.auto_reshape_done_requested.emit()
            except (TypeError, RuntimeError):
                pass
        # Route the close-button (X) through the existing Stop flow when a
        # session is active, so the user gets the discard-warning dialog
        # instead of silently leaving the map tool armed without a panel.
        if self._segmentation_active:
            self.stop_segmentation_requested.emit()
            if self._segmentation_active:
                event.ignore()
                return
        # Ship any queued telemetry before the dock (and possibly QGIS) goes away.
        try:
            from ...core.telemetry import flush as _telemetry_flush
            _telemetry_flush()
        except Exception:
            pass  # nosec B110
        super().closeEvent(event)

    def reset_session(self):
        self._has_mask = False
        self._segmentation_active = False
        self._segmentation_layer_id = None
        self._saved_polygon_count = 0
        self._positive_count = 0
        self._negative_count = 0
        self._handoff_editing = False
        self._manual_encoding = False
        self._manual_encoding_phase = "imagery"
        self.reset_refine_sliders()
        # The plugin side of these settings resets to the shipped defaults
        # while the panel goes back to what this user last chose, so the reset
        # is only half done until the panel says what it now holds.
        self.publish_refine_settings()
        self._update_button_visibility()
        self._update_ui_state()

    def set_saved_polygon_count(self, count: int):
        self._saved_polygon_count = count
        self._update_refine_panel_visibility()
        self._update_export_button_style()
        # Update undo button state
        if self._segmentation_active:
            has_points = self._positive_count > 0 or self._negative_count > 0
            can_undo_saved = count > 0
            self.undo_button.setEnabled(has_points or can_undo_saved)
        # Keep the instruction copy in sync (the refine-handoff hint depends on
        # the saved/candidate count, e.g. right after the detections import).
        self._update_instructions()

    @staticmethod
    def _is_online_layer(layer) -> bool:
        """Whether this raster is read by rendering it rather than from a file.

        One list, the reader's own (core.raster_provider_kinds). The copy that
        used to sit here left out postgresraster and virtualraster, so the panel
        called a layer local that the crop reader treats like a web service.
        """
        if layer is None or not isinstance(layer, QgsRasterLayer):
            return False
        provider = layer.dataProvider()
        if provider is None:
            return False
        from ...core.raster_provider_kinds import CANVAS_RENDERED_PROVIDERS
        return provider.name() in CANVAS_RENDERED_PROVIDERS

    def _is_layer_georeferenced(self, layer) -> bool:
        """Check if a raster layer is properly georeferenced. Delegates to the
        shared helper (this copy used to lack the deleted-layer RuntimeError
        guards the plugin's had; one owner ends that drift)."""
        from ..plugin.shared import is_layer_georeferenced
        return is_layer_georeferenced(layer)

    def _sync_imagery_hero(self, combo, hero) -> bool:
        """Whether imagery is available for this page, and the hero variant
        that goes with the answer.

        The combo list is a cache of the layer tree; the project is the
        truth. A combo that says "nothing" over a project that holds a
        visible raster is relisted on the spot (the case seen live: an online
        basemap loaded, and the page still asking for imagery). A project
        whose rasters are all unchecked gets the "hidden" card, with one
        button that checks them again, instead of a card asking to load
        what is already there."""
        from ..layer_tree_combobox import project_raster_presence
        from .widgets import set_hero_variant
        try:
            visible, hidden = project_raster_presence()
        except (RuntimeError, AttributeError):
            visible, hidden = 0, 0
        # The list and the tree disagree in either direction (a raster that
        # appeared, or every raster unchecked while the list still names
        # two): relist now rather than trust a debounced signal that may not
        # have fired, the case a live test caught.
        if combo.count_layers() != visible and not getattr(combo, "_frozen", False):
            try:
                combo._refresh()
            except (RuntimeError, AttributeError):
                pass  # nosec B110 - the debounced refresh still comes
        has_rasters = combo.count_layers() > 0
        variant = "hidden" if (not has_rasters and hidden > 0) else "empty"
        try:
            if getattr(hero, "hero_variant", None) != variant:
                set_hero_variant(hero, variant)
            show_btn = getattr(hero, "hero_show_btn", None)
            if show_btn is not None and not getattr(hero, "_show_btn_wired", False):
                hero._show_btn_wired = True
                show_btn.clicked.connect(self._on_reveal_hidden_imagery)
        except (RuntimeError, AttributeError):
            pass  # nosec B110 - a card mid-teardown keeps its old text
        return has_rasters

    def _on_reveal_hidden_imagery(self) -> None:
        """The hero's "Show it on the map" button: check the unchecked rasters
        (and their groups). The tree's visibility signal then relists the
        combos and the page leaves the hero on its own."""
        from ..layer_tree_combobox import reveal_hidden_rasters
        try:
            reveal_hidden_rasters()
        except (RuntimeError, AttributeError):
            pass  # nosec B110

    def _update_ui_state(self):
        if self._mode == Mode.INTERACTIVE:
            self._update_ui_state_interactive()
        else:
            self._update_ui_state_automatic()

    def _update_ui_state_interactive(self):
        """Interactive mode UI state - verbatim original _update_ui_state logic."""
        layer = self.layer_combo.currentLayer()
        has_layer = layer is not None

        has_rasters_available = self._sync_imagery_hero(self.layer_combo, self.no_rasters_widget)
        empty = not has_rasters_available and not self._segmentation_active
        # One info per state: with no raster the Manual page
        # shows ONLY the hero (identical to Automatic), never the header or the
        # Start card behind it. Everything returns once imagery exists. This is
        # the Manual half of the fix that keeps fresh + delete-last-layer paths
        # landing on the same clean empty screen.
        self.no_rasters_widget.setVisible(empty)
        # Same rule one state further on: at zero credits on the cloud lane the
        # credit card IS the page, so the picker and the Start block go with the
        # rest. A raster cannot be picked into a session that cannot open, and
        # leaving them up was what made that screen read as a form with an error
        # stuck to it.
        paywalled = self._manual_credit_gate_owns_page()
        self.layer_combo.setVisible(has_rasters_available and not paywalled)
        self.layer_label.setVisible(not self._segmentation_active
                                    and has_rasters_available
                                    and not paywalled)
        # start_container (Terms + Start button + caption) is a Start-view thing;
        # hide it in the empty state so only the hero shows. Session visibility is
        # owned elsewhere, so only touch it while no segmentation is active.
        if not self._segmentation_active:
            self.start_container.setVisible(has_rasters_available
                                            and not paywalled)

        # The on-device install, or the engine that needs none of it.
        setup_ok = (self._dependencies_ok and self._checkpoint_ok) \
            or self._manual_cloud_route_picked()
        activated = self._plugin_activated
        # Once the ToS lock is set, consent is permanent - skip the accepted check.
        tos_locked, tos_accepted = self._consent_flags()
        tos_ok = tos_locked or tos_accepted
        # A cloud session with an empty account can click but never save, so it
        # is not a session. The credit gate below the button carries the two
        # ways on from here.
        funded = not (self._manual_cloud_route_picked()
                      and self._manual_credits_exhausted())
        can_start = setup_ok and has_layer and activated and tos_ok and funded
        self.start_button.setEnabled(can_start and not self._segmentation_active)
        # What the engine box turns Start into: a session, or the download the
        # cleared box asks for. Runs after the line above, and overrides it.
        self._refresh_manual_engine_card_enabled()

    def _update_ui_state_automatic(self):
        """Automatic mode UI state - update dynamic elements of the auto page."""
        if not self._plugin_activated:
            return
        # Raster-availability gating (no-rasters warning, combo visibility, Start
        # button) is only meaningful BEFORE the flow starts. Once started, the
        # source raster is locked and pinned by id in the run context, independent
        # of layer-tree visibility: toggling a layer's checkbox (e.g. hiding the
        # basemap to peek underneath) must never collapse the header or bounce the
        # user out of a drawn zone / review. So gate only on the Start step.
        # Consent checkbox, shared with Manual (one global state). It lives on
        # the LAST step, right above Detect (friction at the latest possible
        # moment); keep it mirrored (e.g. accepted from
        # Manual) and hide it once consent is sealed.
        try:
            self.auto_tos_checkbox.blockSignals(True)
            tos_locked, tos_accepted = self._consent_flags()
            self.auto_tos_checkbox.setChecked(tos_accepted)
            self.auto_tos_checkbox.blockSignals(False)
            self.auto_tos_container.setVisible(not tos_locked)
        except (RuntimeError, AttributeError):
            pass
        if not self._auto_started:
            has_auto_rasters = self._sync_imagery_hero(
                self.auto_layer_combo, self.auto_no_rasters_widget)
            # One info per state: with no raster the page
            # shows ONLY the hero card. The label and the whole steps stack
            # (Start button + caption) come back once imagery exists.
            self.auto_no_rasters_widget.setVisible(not has_auto_rasters)
            self.auto_layer_combo.setVisible(has_auto_rasters)
            self.auto_layer_label.setVisible(has_auto_rasters)
            self.auto_steps.setVisible(has_auto_rasters)
            # Start only needs a raster to lock; consent is asked at Detect.
            _start_ok = (has_auto_rasters
                         and self.auto_layer_combo.currentLayer() is not None)
            self.auto_start_btn.setEnabled(_start_ok)
            # A dead Start with no reason was the first wall of the flow.
            self.auto_start_btn.setToolTip("" if _start_ok else tr(
                "Load imagery in QGIS, then pick it above to start."))
        self._update_auto_detect_enabled()

    def _update_auto_page_state(self):
        """Update which sub-sections of auto_page are visible based on account state."""
        if not self._plugin_activated:
            self.auto_upsell_card.setVisible(False)
            self.auto_controls_section.setVisible(False)
            return
        # The upsell card replaces the controls only once the lifetime free
        # detections hit zero: at that point the user needs the pricing info
        # to continue, so it earns the space. Before that, the footer ring +
        # Subscribe pill are the only upsell surface.
        # Never over a live run or an open review. The review panel is built
        # INSIDE auto_controls_section, so swapping it for the upsell took away
        # the results of the run that had just spent the last credit: the
        # "your detections are kept below" line, the filters and Export all
        # went, leaving a paywall where paid work had been.
        owns_page = bool(getattr(self, "_auto_run_active", False)
                         or getattr(self, "_auto_review_active", False))
        exhausted = self._is_free_exhausted() and not owns_page
        self.auto_upsell_card.setVisible(exhausted)
        # Every other refusal a control on this page cannot clear takes the
        # page the same way (auto_run_block.py). The free wall above claims
        # the seat first, so the two can never both be up.
        blocked = self._refresh_auto_run_block(suppressed=exhausted)
        self.auto_controls_section.setVisible(not exhausted and not blocked)
        if exhausted:
            try:
                from ...core import telemetry_session_events
                telemetry_session_events.track_pro_upsell_viewed(
                    trigger="free_exhausted", cta_source="upsell_card")
            except Exception:
                pass  # nosec B110
        # Land on the first incomplete step when (re)entering the page; never
        # yank the user backwards while a run is in flight.
        # Start (not started) -> Draw zone (started, no zone) -> Prompt (zone set).
        if not exhausted and not self._auto_run_active:
            if not self._auto_started:
                target = 0
            elif not self._auto_zone_is_set:
                target = 1
            else:
                target = 2
            # Only when it actually moves. A refresh during an open review kept
            # re-running the step machine for the step already on screen, which
            # re-drove the prompt card and the example panel every pass.
            try:
                settled = self.auto_steps.currentIndex() == target
            except (RuntimeError, AttributeError):
                settled = False
            if not settled:
                self._go_to_auto_step(target)
        # The cloud disclosure on the Precision card, which is where the run is
        # priced and the last screen before Detect sends a tile. Read here and
        # nowhere else in this panel: the flag may only change what is drawn.
        try:
            self.auto_privacy_line.setText(cloud_notice_line_html())
            self.auto_privacy_line.setVisible(not cloud_notice_seen())
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- the widget can be gone during teardown
        self._refresh_auto_credits_display()
        self._update_auto_detect_enabled()

    def _is_free_exhausted(self) -> bool:
        """True when a non-subscriber has confirmed zero free Automatic
        allowance left. The km² envelope answers when the account sent it;
        the wallet figure keeps answering for older servers."""
        if self._auto_is_subscriber:
            return False
        env = getattr(self, "_quota_envelopes", None)
        if env is not None and env.has_km2_gauge():
            # Same cap-minus-used fallback as the gauge and the gate, so a
            # server that sends the pair without the remainder does not silently
            # keep offering a run that is already spent.
            left = (env.km2_remaining if env.km2_remaining is not None
                    else max(0.0, env.km2_cap - env.km2_used))
            return left <= 0
        return self._auto_free_left is not None and self._auto_free_left <= 0

    def _envelope_gauge_view(self):
        """(label, tooltip, ring_used, ring_total) for the footer gauge, from
        the two-envelope snapshot, or None to keep the wallet display.

        One headline unit per surface: in Automatic the gauge speaks km², in
        Semi-Auto it counts saved cloud objects. Both plans count down, so the
        figure a user reads first is what is left. Ring values are percent, so
        the arc knows nothing about units.
        """
        env = getattr(self, "_quota_envelopes", None)
        if env is None:
            return None
        if self._mode == Mode.AUTOMATIC:
            if not env.has_km2_gauge():
                return None
            km2_left = (env.km2_remaining if env.km2_remaining is not None
                        else max(0.0, env.km2_cap - env.km2_used))
            left = format_km2_left(km2_left)
            cap = format_km2_left(env.km2_cap)
            pct_used = _gauge_percent_used(env.km2_used, env.km2_cap, km2_left)
            label = tr("{left} / {cap} km²").format(left=left, cap=cap)
            tooltip = tr("{left} of {cap} km² left in Automatic this "
                         "month").format(left=left, cap=cap)
        else:
            if not env.has_objects_gauge():
                return None
            objects_left = (env.objects_remaining
                            if env.objects_remaining is not None
                            else max(0, env.objects_cap - env.objects_used))
            pct_used = _gauge_percent_used(
                env.objects_used, env.objects_cap, objects_left)
            left_text = format_quota_count(objects_left)
            cap_text = format_quota_count(env.objects_cap)
            label = tr("{left} / {cap}").format(left=left_text, cap=cap_text)
            tooltip = tr("{left} of {cap} cloud objects left in Semi-Auto "
                         "this month").format(left=left_text, cap=cap_text)
        reset_day = getattr(self, "_auto_reset_display", "")
        if reset_day:
            tooltip += "\n" + tr("Resets {date}").format(date=reset_day)
        tooltip += "\n" + tr("Click to open your dashboard")
        return label, tooltip, pct_used, 100

    def _set_btn_armed(self, btn, armed: bool) -> None:
        """Toggle the [armed] dynamic property + re-polish so the filled 'armed'
        look in the button's stylesheet applies/clears without a rebuild."""
        btn.setProperty("armed", "true" if armed else "false")
        btn.style().unpolish(btn)
        btn.style().polish(btn)

    def _refresh_exemplar_button_labels(self) -> None:
        """Write both example buttons' text for the state they are in.

        Three states have to be told apart at a glance, and the fill alone
        cannot carry the last one:
          - ready, nothing drawn yet: the action ("Draw on the map");
          - drawing now: the way OUT, because a second click disarms and the
            armed instruction line below already carries the gesture;
          - one example already drawn: "Draw another example", so the button
            stops reading as a step that is still waiting to be done.

        Reads the armed state off ``_auto_exemplar_armed_label`` (1 example,
        0 exclude, None not armed), so a chip refresh landing mid-draw cannot
        overwrite the armed wording.
        """
        armed_label = getattr(self, "_auto_exemplar_armed_label", None)
        drawing = tr("Drawing (click to stop)")
        drawn = getattr(self, "_auto_positive_exemplars", 0)
        try:
            self.auto_ex_inc_btn.setText(
                drawing if armed_label == 1
                else (tr("Draw another example") if drawn
                      else tr("Draw on the map")))
        except (RuntimeError, AttributeError):
            pass
        exc = getattr(self, "auto_ex_exc_btn", None)
        if exc is None:
            return
        try:
            exc.setText(drawing if armed_label == 0
                        else tr("Exclude a look-alike"))
        except (RuntimeError, AttributeError):
            pass

    def _auto_exemplar_line_busy(self) -> bool:
        """True while the example card's message slot actually shows something:
        the amber too-small warning or the armed draw instruction.

        Asks the widgets, not the state flag, on purpose. A user who closed the
        armed instruction leaves the slot free even while a draw is armed, and
        the explainer takes it back instead of the card showing a gap."""
        for name in ("auto_exemplar_size_warning", "auto_exemplar_armed_tip"):
            try:
                if getattr(self, name).isVisible():
                    return True
            except (RuntimeError, AttributeError):
                continue
        return False

    def set_auto_exemplar_armed(self, label) -> None:
        """Reflect the example draw-tool armed state on the draw-example /
        exclude buttons + the instruction line. label 1 = positive example
        armed, 0 = exclude armed, None = not armed. The plugin calls this when
        it arms the draw tool and again when the draw finishes or is cancelled,
        so the 'now draw on the map' feedback always clears."""
        self._auto_exemplar_armed_label = label
        try:
            self._set_btn_armed(self.auto_ex_inc_btn, label == 1)
            exc = getattr(self, "auto_ex_exc_btn", None)
            if exc is not None:
                self._set_btn_armed(exc, label == 0)
            self._refresh_exemplar_button_labels()
        except (RuntimeError, AttributeError):
            return
        if label is None:
            self.auto_exemplar_size_warning.setVisible(False)
            self.auto_exemplar_armed_tip.setVisible(False)
            self._refresh_auto_exemplar_explainer()
            self._auto_exemplar_hint_kind = None
            self._set_exemplar_quality()
            return
        # An armed draw opens the collapsed example section so the armed
        # instruction is actually visible (e.g. armed from a rescue chip).
        try:
            self._set_auto_exemplar_expanded(True)
        except (RuntimeError, AttributeError):
            pass
        # One message at a time: the armed instruction takes the line from the
        # too-small warning. Each wording is its own closable tip, and a user
        # who closed it gets the explainer back rather than an empty gap (the
        # armed button already says the tool is live).
        self.auto_exemplar_size_warning.setVisible(False)
        shown = self.auto_exemplar_armed_tip.set_hint(
            HINT_EXEMPLAR_EXCLUDE_BOX if label == 0 else HINT_EXEMPLAR_DRAW_BOX,
            _msg_text("armed", (
                tr("Click points around one look-alike, then double-click "
                   "to close.")
                if label == 0 else
                tr("Click points around one object, then double-click "
                   "to close."))),
            dense=True)
        self.auto_exemplar_armed_tip.setVisible(shown)
        self._refresh_auto_exemplar_explainer(slot_taken=shown)
        self._auto_exemplar_hint_kind = "armed"
        self._set_exemplar_quality()

    def show_auto_exemplar_size_warning(self, at_max_detail: bool = False) -> None:
        """One calm line while at least one stored example renders below a
        usable pixel size AT THE CURRENT DETAIL LEVEL. Advisory only (the run
        stays available), and never closable: it says why the run will miss
        things. One message at a time on the example card, so it replaces the
        explainer and the next armed draw (or its finish) replaces or clears
        it.

        Dynamic, not one-shot: the caller (the plugin's
        ``_refresh_exemplar_size_warning``) re-evaluates every stored example
        against the CURRENT detail level on every detail change and on every
        exemplar add/remove, so raising the Detail slider clears this once
        every example is large enough (see ``clear_auto_exemplar_size_warning``).

        ``at_max_detail`` (default False, the conservative "can zoom finer"
        wording) drops the "zoom the detail slider finer" suggestion once the
        slider has no finer level left, so the message never tells the user
        to do something the UI already forbids.
        """
        try:
            self.auto_exemplar_armed_tip.setVisible(False)
            self.auto_exemplar_size_warning.setStyleSheet(
                _msg_label_qss("warning"))
            self.auto_exemplar_size_warning.setText(_msg_text("warning", (
                tr("This example is very small even at full precision. "
                   "Draw a larger object, or it may be too small to detect.")
                if at_max_detail else
                tr("This example is very small at this precision. "
                   "Raise the precision or draw a larger object."))))
            self.auto_exemplar_size_warning.setVisible(True)
            self._refresh_auto_exemplar_explainer(slot_taken=True)
            self._auto_exemplar_hint_kind = "warning"
            # The size warning is the more urgent message: make the
            # second-example nudge (a DIFFERENT label) yield to it now.
            self._apply_prompt_hint_on_edit()
            self._set_exemplar_quality()
        except (RuntimeError, AttributeError):
            pass

    def clear_auto_exemplar_size_warning(self) -> None:
        """Hide the too-small warning, but ONLY when it is the message
        currently showing on the example card. The slot is shared with the
        armed-draw instruction (one message at a time), so a blind clear here
        could wipe "drag a box around one object..." out from under an
        in-progress draw; the ``_auto_exemplar_hint_kind`` flag (set by the
        three writers: armed / warning / None) is what makes this safe.
        Mirrors the ``label is None`` branch of ``set_auto_exemplar_armed``."""
        if getattr(self, "_auto_exemplar_hint_kind", None) != "warning":
            return
        try:
            self.auto_exemplar_size_warning.setVisible(False)
            self._refresh_auto_exemplar_explainer()
            self._auto_exemplar_hint_kind = None
            # The warning is gone: let the second-example nudge return if the
            # single-positive state still calls for it.
            self._apply_prompt_hint_on_edit()
            self._set_exemplar_quality()
        except (RuntimeError, AttributeError):
            pass

    def set_exemplars(self, items: list) -> None:
        """Rebuild the reference thumbnail strip. Each item is ``(id, label)`` or
        ``(id, label, thumbnail)`` where thumbnail is a QImage of the drawn crop.
        label 1 = positive (find similar), 0 = exclude. Also refreshes the Detect
        gate + the add-buttons' cap state."""
        layout = self._auto_exemplar_chips_layout
        # Remembered for the in-run receipt, which rebuilds its own read-only
        # copy of these thumbnails rather than borrowing this card's.
        self._auto_exemplar_items = list(items)
        # Clear existing cards (keep the trailing stretch at the end).
        while layout.count() > 1:
            item = layout.takeAt(0)
            w = item.widget()
            if w is not None:
                w.setParent(None)
                w.deleteLater()
        from ...core.detect_gate import exclude_available
        from ...core.exemplar_store import max_exclude, max_positive, max_total
        self._auto_positive_exemplars = sum(1 for it in items if it[1] == 1)
        exclude_count = sum(1 for it in items if it[1] == 0)
        # The first example turns "Draw on the map" into "Draw another
        # example": the step is done, the button is now an optional extra.
        self._refresh_exemplar_button_labels()
        for idx, it in enumerate(items):
            eid, label = it[0], it[1]
            thumb = it[2] if len(it) > 2 else None
            card = self._make_exemplar_chip(eid, label, idx + 1, thumb)
            layout.insertWidget(layout.count() - 1, card)
        # The positive add button disables at the PAID cap, and at the total
        # ceiling. The exclude add button is a refinement of a positive, offered
        # ONLY once one exists; below that it stays HIDDEN so the primary flow
        # is a single green "Draw an example" button. When shown it still
        # disables at its own cap. Hiding, not disabling, keeps one clear
        # affordance at a time. The ceilings come from the store's resolvers,
        # the same ones the store enforces on insert, so the row can never
        # offer a slot the store would refuse (or hide one the server just
        # opened). The FREE plan's smaller ceilings are not applied here on
        # purpose: its buttons stay live, and the click past the free ceiling
        # shows the Pro offer (exemplar_upsell.py) instead of arming.
        total_left = (self._auto_positive_exemplars + exclude_count) < max_total()
        try:
            self.auto_ex_inc_btn.setEnabled(
                total_left and self._auto_positive_exemplars < max_positive())
            exc = getattr(self, "auto_ex_exc_btn", None)
            if exc is not None:
                exc_available = exclude_available(self._auto_positive_exemplars)
                exc.setVisible(exc_available)
                exc.setEnabled(
                    exc_available and total_left and exclude_count < max_exclude())
        except (RuntimeError, AttributeError):
            pass
        # The example set changed, so the offer raised on the previous set is
        # stale: an example removed frees the free slot again.
        try:
            self.hide_auto_exemplar_upsell()
        except (RuntimeError, AttributeError):
            pass
        # A drawn reference speaks for itself: the how/why explainer yields to
        # the thumbnails, and returns if every reference is removed.
        self._auto_exemplar_count = len(items)
        self._refresh_auto_exemplar_explainer(
            slot_taken=self._auto_exemplar_line_busy())
        self._set_exemplar_quality()
        # An existing reference keeps the example section open (it holds the
        # thumbnails); an empty list leaves the collapse state to the user.
        if items:
            try:
                self._set_auto_exemplar_expanded(True)
            except (RuntimeError, AttributeError):
                pass
        # Drawing the first positive example while the prompt is empty should
        # surface the "your examples drive the search" hint (and clear it when
        # every example is removed).
        self._apply_prompt_hint_on_edit()
        self._update_auto_detect_enabled()

    def _set_exemplar_quality(self) -> None:
        """Reflect how close the example set is to the recommended pair (two
        positive references, the model's strongest mode) on the example card:
        the header dots fill lime as they are drawn, and one subtle line
        nudges toward the second, then confirms best quality at two.

        Both hide with no example. The line also yields while a draw is armed
        or the too-small size warning shows (that shared hint is the louder,
        actionable message), so the card never stacks guidance; the dots stay
        (they are a glanceable meter, not a sentence)."""
        positives = (getattr(self, "_auto_positive_exemplars", 0)
                     if getattr(self, "_EXEMPLARS_ENABLED", True) else 0)
        dots = getattr(self, "auto_exemplar_quality_dots", None)
        line = getattr(self, "auto_exemplar_quality_line", None)
        if dots is None or line is None:
            return
        # A known free account gets one example per run, so a meter that aims
        # at two and a line asking for the second would push at a door the
        # plan keeps shut. Both stay off; the offer card answers the click.
        known_free = (getattr(self, "_auto_credits", None) is not None
                      and not getattr(self, "_auto_is_subscriber", False))
        if known_free:
            try:
                dots.setVisible(False)
                line.setVisible(False)
            except (RuntimeError, AttributeError):
                pass
            return
        # Header dots: filled lime up to the drawn count, hollow grey for the
        # rest of the recommended two.
        filled = min(positives, 2)
        empty = "rgba(128, 128, 128, 0.45)"
        marks = []
        for i in range(2):
            color = BRAND_GREEN if i < filled else empty
            marks.append(
                f'<span style="color: {color}; font-size: 13px;">&#9679;</span>')
        try:
            dots.setText("&nbsp;".join(marks))
            dots.setVisible(positives > 0)
        except (RuntimeError, AttributeError):
            pass
        # The nudge/confirm line yields to an armed instruction or a size
        # warning in the card's message slot.
        armed_showing = self._auto_exemplar_line_busy()
        try:
            if positives <= 0 or armed_showing:
                line.setVisible(False)
            elif positives == 1:
                # A boxed neutral message, like every other line on this card.
                # Loose text under a control reads as a stray caption and
                # cannot be told apart from the label above it.
                line.setStyleSheet(_msg_label_qss("neutral"))
                line.setText(tr("Add one more example for the best results."))
                line.setVisible(True)
            else:
                # At two the dots are full, and that is the whole message. A
                # sentence here only congratulated the user for a state the
                # meter above it already shows.
                line.setVisible(False)
        except (RuntimeError, AttributeError):
            pass

    def _make_exemplar_chip(self, exemplar_id: str, label: int,
                            index: int = 1, thumbnail=None,
                            removable: bool = True) -> QWidget:
        """A 52px reference card (AI-Edit _ThumbWidget look): the drawn crop +
        a numbered badge + a remove x. Border green for a positive example, red
        for an exclude box. Falls back to a flat tinted tile when no thumbnail
        was captured. ``removable=False`` drops the x, for the in-run receipt
        where nothing can be taken back."""
        from qgis.PyQt.QtGui import QPixmap
        is_pos = label == 1
        rgba = "67,160,71" if is_pos else "229,57,53"
        side = 52
        card = QFrame()
        card.setObjectName("refCard")
        card.setFixedSize(side, side)
        card.setStyleSheet(
            f"QFrame#refCard {{ border: 1px solid rgba({rgba},0.85);"
            f" border-radius: 4px; background: rgba({rgba},0.12); }}")
        card.setToolTip(tr("Example"))
        # The drawn crop, if captured.
        thumb_lbl = QLabel(card)
        thumb_lbl.setGeometry(1, 1, side - 2, side - 2)
        # Preserve aspect (no stretch) so a long thin reference shows as a thin
        # bar, not distorted into a square. The enlarge popup shows it full size.
        thumb_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
        thumb_lbl.setStyleSheet("border: none; background: transparent;")
        if thumbnail is not None:
            try:
                pm = QPixmap.fromImage(thumbnail)
                if not pm.isNull():
                    # The capture is taken at the screen's own pixel count on
                    # purpose; scaling it back down to 50 flat threw that away
                    # and left a soft chip on every scaled display.
                    ratio = widget_pixel_ratio(thumb_lbl)
                    edge = int(round((side - 2) * ratio))
                    pm = pm.scaled(
                        edge, edge,
                        Qt.AspectRatioMode.KeepAspectRatio,
                        Qt.TransformationMode.SmoothTransformation)
                    pm.setDevicePixelRatio(ratio)
                    thumb_lbl.setPixmap(pm)
                # Click the card (anywhere but the remove x) to see the reference
                # enlarged: it shows what the AI uses (the object with a little
                # of its surroundings).
                card.setCursor(Qt.CursorShape.PointingHandCursor)
                card.setToolTip(tr("Click to enlarge"))
                card.mousePressEvent = (
                    lambda _ev, im=thumbnail, n=index, lb=label: (
                        self._show_exemplar_detail(im, n, lb)))
            except (RuntimeError, TypeError):
                pass
        # Numbered badge, top-left.
        badge = QLabel(str(index), card)
        badge.setStyleSheet(scale_qss_font_px(
            "QLabel { background: rgba(0,0,0,0.6); color: rgba(255,255,255,0.92);"
            " font-size: 9px; font-weight: bold; border: none;"
            " border-top-left-radius: 3px; border-bottom-right-radius: 3px;"
            " padding: 0 3px; }"))
        badge.adjustSize()
        badge.move(1, 1)
        # Remove x, top-right. The receipt's copy of the same chip has none.
        if not removable:
            return card
        remove = QToolButton(card)
        remove.setText("✕")
        remove.setCursor(Qt.CursorShape.PointingHandCursor)
        remove.setToolTip(tr("Remove"))
        remove.setFixedSize(16, 16)
        remove.setStyleSheet(scale_qss_font_px(
            "QToolButton { background: rgba(0,0,0,0.6); color: #fff; border: none;"
            " border-radius: 8px; font-size: 9px; font-weight: bold; }"
            "QToolButton:hover { background: rgba(211,47,47,0.9); }"))
        remove.move(side - 17, 1)
        remove.clicked.connect(
            lambda _checked=False, eid=exemplar_id: self.auto_exemplar_remove_requested.emit(eid))
        return card

    def _show_exemplar_detail(self, image, index: int = 1,
                              label: int = 1) -> None:
        """Popup showing one reference enlarged, at the size the AI reads it.

        The window says which reference it is and what it does, in the title
        and one short line. It used to carry a two-line explanation of the crop
        under the picture, which described the mechanism to someone who is
        looking at the picture and can see it. Best-effort: never fatal.
        """
        if image is None:
            return
        try:
            from qgis.PyQt.QtGui import QPixmap
            from qgis.PyQt.QtWidgets import QDialog, QLabel, QVBoxLayout
            pm = QPixmap.fromImage(image)
            if pm.isNull():
                return
            # This window exists to be looked at closely, so it gets the
            # screen's real pixel count rather than a 320-unit copy stretched
            # back up.
            ratio = widget_pixel_ratio(self)
            edge = int(round(320 * ratio))
            pm = pm.scaled(
                edge, edge,
                Qt.AspectRatioMode.KeepAspectRatio,
                Qt.TransformationMode.SmoothTransformation)
            pm.setDevicePixelRatio(ratio)
            dlg = QDialog(self)
            dlg.setWindowTitle(
                tr("Exclude {n}").format(n=index) if label == 0
                else tr("Reference {n}").format(n=index))
            lay = QVBoxLayout(dlg)
            lay.setContentsMargins(12, 12, 12, 12)
            lay.setSpacing(8)
            img_lbl = QLabel(dlg)
            img_lbl.setPixmap(pm)
            img_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
            lay.addWidget(img_lbl)
            hint = QLabel(
                tr("The AI drops objects that look like this.") if label == 0
                else tr("The AI looks for more objects like this."))
            hint.setWordWrap(True)
            hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
            hint.setStyleSheet("color: rgba(128,128,128,0.9); font-size: 11px;")
            lay.addWidget(hint)
            from .font_scale import apply_font_scale_to_tree

            apply_font_scale_to_tree(dlg)
            dlg.exec()
        except Exception:  # noqa: BLE001 -- preview popup is best-effort  # nosec B110
            pass

    def _update_auto_detect_enabled(self):
        """Enable the Detect button based on current Automatic mode state."""
        # Every gate move ends here, so this is where the takeover card learns
        # whether it owns the page (auto_run_block.py). Before the early
        # returns below: a gate that clears while the review is open still has
        # to put the controls back.
        self._apply_auto_run_block_takeover()
        if not self._plugin_activated or self._auto_review_active:
            self.auto_detect_btn.setEnabled(False)
            # The tooltip belongs to the state the button is in. Left alone it
            # keeps the last reason, so a review opened after a blocked run
            # still explains a zone the user has since replaced.
            self.auto_detect_btn.setToolTip(
                tr("Sign in to run Automatic.") if not self._plugin_activated
                else tr("Finish or close the review first."))
            return
        from ...core.detect_gate import can_detect
        has_layer = self.auto_layer_combo.currentLayer() is not None
        # The big green Detect enables on one thing: a typed prompt. An
        # example sharpens the word, it does not stand in for it, so the
        # example card is marked optional and never lights the button on its
        # own (see core/detect_gate.can_detect).
        has_text = bool(self.auto_prompt_input.text().strip())
        positives = self._auto_positive_exemplars if self._EXEMPLARS_ENABLED else 0
        # An object is named by a word OR by a drawn example, and either one is
        # enough to run (see core/detect_gate.can_detect). The Precision fold
        # waits for the same thing: its default is computed from the object, so
        # a level tuned before there is an object is thrown away by the re-seed.
        has_object = has_text or positives > 0
        can_run = can_detect(has_text, positives)
        self._apply_auto_detail_gate(has_object)
        not_too_large = not self._auto_zone_too_large
        # Known-zero only (None = not yet fetched, fail open). One definition,
        # read here and by the takeover card, so the greyed button and the
        # card that replaces the page can never disagree.
        credits_ok = not self._auto_balance_spent()
        # Monthly surface envelope: the zone is larger than the km² the account
        # has left. Only fires when the server told us both figures; unknown
        # envelopes fail open and the server enforces (set_auto_km2_block).
        km2_ok = not getattr(self, "_auto_km2_exceeded", False)
        # Consent gates DETECT (the moment credits are spent), not Start: the
        # checkbox sits right above this button (see auto_build).
        tos_locked, tos_accepted = self._consent_flags()
        tos_ok = tos_locked or tos_accepted
        # The server can take Automatic off the whole fleet. Until now the run
        # refused at the click, so the button stayed green over a mode that
        # could not run. Fails open, so an unreachable configuration changes
        # nothing.
        auto_available = self._auto_service_available()
        hard_ok = has_layer and not_too_large and credits_ok
        hard_ok = hard_ok and tos_ok and km2_ok and auto_available
        run_allowed = hard_ok and can_run
        self.auto_detect_btn.setEnabled(run_allowed and not self._auto_run_active)
        # A disabled button with no reason reads as broken. Every gate gets a
        # sentence naming the way out: the silent branches covered most of the
        # blocked runs, and a user who cannot see why the button is dead has no
        # move left but to close the panel.
        if not auto_available:
            tip = tr("Automatic is temporarily unavailable. Try again later.")
        elif not tos_ok:
            tip = tr("Accept the Terms and Privacy Policy first.")
        elif not has_layer:
            tip = tr("Pick a raster layer at the top of the panel first.")
        elif self._auto_zone_too_large:
            tip = tr("This zone at this precision is too big for one run. "
                     "Draw a smaller zone, or lower the precision.")
        elif not credits_ok:
            # Semi-Auto is free only on the on-device engine. The default route
            # is the cloud one, which is counted, so the sentence names the
            # engine instead of promising the mode.
            tip = tr("You used your Automatic allowance for this month. "
                     "Semi-Auto on your computer keeps working, free, with no "
                     "counter.")
        elif not km2_ok:
            tip = tr("This zone is larger than the surface you have left this "
                     "month. Draw a smaller zone.")
        elif hard_ok and not can_run:
            tip = tr("Type what to find, or draw an example of it.")
        else:
            tip = ""
        self.auto_detect_btn.setToolTip(tip)
        # detect_blocked telemetry, once per episode, off the same reason the
        # takeover card paints (auto_run_block.py). It no longer waits for a
        # typed prompt: the takeover hides the prompt box, so a refusal the
        # user never got to type into was counting as no refusal at all.
        self._note_detect_blocked(self._auto_run_block_reason())

    def _refresh_auto_credits_display(self):
        """Drive the footer credit gauge (ring + count + Subscribe pill).

        Both modes, because the balance belongs to the account and not to one
        page. Signed out it stays hidden: _show_signed_out_page clears the
        cached numbers, so there is nothing to show there anyway.
        """
        signed_in = self._plugin_activated
        remaining = self._auto_credits
        total = self._auto_credits_total

        # Native two-envelope gauge whenever the account sent the fields:
        # Automatic is an area (km², free counts up, Pro counts down) and
        # Semi-Auto counts saved cloud objects. Old servers send none of it
        # and the wallet figures below keep the gauge exactly as it was.
        envelope_view = self._envelope_gauge_view()
        show_gauge = signed_in and (
            envelope_view is not None or remaining is not None)
        if show_gauge:
            if envelope_view is not None:
                label, tooltip, ring_used, ring_total = envelope_view
                self._footer_credits_label.setText(label)
                if ring_total:
                    self._credit_ring.set_credits(
                        ring_used, ring_total,
                        free_tier=not self._auto_is_subscriber)
                    self._credit_ring.setVisible(True)
                else:
                    self._credit_ring.setVisible(False)
            elif total is not None and total > 0:
                self._footer_credits_label.setText(f"{remaining} / {total}")
                self._credit_ring.set_credits(
                    max(0, total - remaining), total,
                    free_tier=not self._auto_is_subscriber)
                self._credit_ring.setVisible(True)
            else:
                self._footer_credits_label.setText(str(remaining))
                self._credit_ring.setVisible(False)
            if envelope_view is None and self._auto_is_subscriber:
                tooltip = tr("{n} cloud detections remaining").format(n=remaining)
            elif envelope_view is None and total is not None and total > 0:
                tooltip = tr("{n} of {total} free cloud detections left").format(
                    n=remaining, total=total)
            elif envelope_view is None and remaining == 1:
                tooltip = tr("1 free cloud detection remaining")
            elif envelope_view is None:
                tooltip = tr("{n} free cloud detections remaining").format(n=remaining)
            # A balance with no return date cannot be acted on: the quota
            # renews on the sign-up anniversary, which nobody can guess.
            # Pre-formatted by set_auto_credits, empty on servers that send no
            # period_end, and the line simply drops out then.
            reset_day = getattr(self, "_auto_reset_display", "")
            if envelope_view is None:
                # The envelope tooltip already carries its own reset and
                # dashboard lines.
                if reset_day:
                    tooltip += "\n" + tr(
                        "Your cloud detections come back on {date}").format(
                            date=reset_day)
                # The gauge is a link, so the tooltip says where it goes.
                tooltip += "\n" + tr("Click to open your dashboard")
            self._footer_credits_label.setToolTip(tooltip)
            self._credit_ring.setToolTip(tooltip)
            gauge = getattr(self, "_credit_gauge", None)
            if gauge is not None:
                gauge.setToolTip(tooltip)
            self._footer_credits_label.setVisible(True)
        else:
            self._credit_ring.setVisible(False)
            self._footer_credits_label.setVisible(False)

        pill_shown = signed_in and not self._auto_is_subscriber
        self.refresh_subscribe_pill_tooltip()
        self._subscribe_pill.setVisible(pill_shown)
        # The pill reports NO view. It sits in the footer for the whole
        # session, so "shown" says nothing about anyone meeting an offer, and
        # counting it as one makes every view-to-click ratio meaningless. Its
        # clicks are still reported, and their denominator is the session,
        # not a view.
        # Free-tier low-credit nudge on the Start page (driven by the same
        # remaining/total the ring uses).
        self._update_auto_low_credit_note()
        # Keep the exhausted-upsell card title's count in sync with that total.
        self._refresh_auto_upsell_title()

    def _refresh_auto_upsell_title(self):
        """Set the exhausted-upsell card title from the fetched free-detection
        total (the same source the credit ring uses), falling back to a
        number-free line until the total is known."""
        title = getattr(self, "_auto_upsell_title", None)
        if title is None:
            return
        from ...core.server_dials import dial_copy

        wall = getattr(self, "_auto_upsell_wall", None)
        if wall is not None:
            from ...core.pro_ceiling import pro_ceiling_contact_email
            wall.set_contact_email(pro_ceiling_contact_email())

        # Name the free way out beside the paid one, whenever the server told
        # us when the quota renews.
        env = getattr(self, "_quota_envelopes", None)
        reset_line = getattr(self, "_auto_upsell_reset", None)
        if reset_line is not None:
            reset_day = getattr(self, "_auto_reset_display", "")
            if reset_day and env is not None and env.km2_cap:
                # The note under the fact, so it says WHEN and nothing else.
                # It used to add "Semi-Auto on this computer keeps working",
                # which is the free way out the card already names at its foot.
                # Served, and filled with str.replace: format() on a served
                # sentence raises on a stray brace, and this one paints the
                # wall.
                reset_line.setText(dial_copy(
                    "trial.reset_km2",
                    tr("It comes back on {date}."),
                ).replace("{date}", reset_day))
            elif reset_day:
                reset_line.setText(dial_copy(
                    "trial.reset_count",
                    tr("Your free detections come back on {date}."),
                ).replace("{date}", reset_day))
            reset_line.setVisible(bool(reset_day))

        if env is not None and env.km2_cap:
            # The wall opens with what the month covered, in the envelope's
            # own unit. Congratulate, never scold.
            from ...core.quota_envelopes import format_km2_value
            title.setText(dial_copy(
                "trial.exhausted_km2",
                tr("You covered your {n} km² of Automatic this month"),
            ).replace("{n}", format_km2_value(env.km2_cap)))
            return

        total = self._auto_credits_total
        if total and total > 0:
            # A served sentence names the count with {n}. Plain replace, never
            # format(), so a stray brace in served text cannot raise here.
            served = dial_copy(
                "trial.exhausted",
                tr("Your {n} free cloud detections are used up"))
            title.setText(served.replace("{n}", str(int(total))))
        else:
            title.setText(dial_copy(
                "trial.exhausted_no_count",
                tr("Your free cloud detections are used up")))

    def cleanup_signals(self):
        """Disconnect project signals and clean up shortcuts/timers on plugin reload."""
        # The offline-install window is a top-level child of the dock, so a
        # reload with one open would leave it on screen with nothing behind its
        # buttons.
        try:
            self._close_manual_install_window()
        except (TypeError, RuntimeError, AttributeError):
            pass
        try:
            self.layer_combo.cleanup()
        except (TypeError, RuntimeError, AttributeError):
            pass
        # The automatic-mode raster picker registers its own QgsProject signals;
        # clean it up too or project edits fire into a destroyed widget on reload.
        try:
            self.auto_layer_combo.cleanup()
        except (TypeError, RuntimeError, AttributeError):
            pass
        try:
            QgsProject.instance().layersAdded.disconnect(self._on_layers_added)
        except (TypeError, RuntimeError):
            pass
        try:
            QgsProject.instance().layersRemoved.disconnect(self._on_layers_removed)
        except (TypeError, RuntimeError):
            pass
        try:
            QgsProject.instance().layerTreeRoot().visibilityChanged.disconnect(
                self._on_layer_visibility_changed)
        except (TypeError, RuntimeError, AttributeError):
            pass
        # The arming filter sits on the QGIS main window, which outlives the
        # dock. Left there, every key press on a reloaded QGIS walks into the
        # filter of a dock that is gone.
        window = getattr(self, "_shortcut_arming_window", None)
        filt = getattr(self, "_shortcut_arming_filter", None)
        if window is not None and filt is not None:
            try:
                window.removeEventFilter(filt)
            except (RuntimeError, AttributeError):
                pass
        self._shortcut_arming_window = None
        # The filter itself goes too: kept, it is a live reference to a dock
        # that is being torn down.
        self._shortcut_arming_filter = None
        # Clean up every QShortcut to prevent stale callbacks. They are all
        # window-level, so one left connected keeps firing into a dock that is
        # gone. The list is written by the builder (build.py), so a shortcut
        # added there is never missed here.
        for shortcut in getattr(self, "_dock_shortcuts", ()):
            if shortcut is None:
                continue
            try:
                shortcut.activated.disconnect()
                shortcut.deleteLater()
            except (TypeError, RuntimeError, AttributeError):
                pass
        self._dock_shortcuts = []
        # Every timer the dock owns. Stopped first, then disconnected, so a
        # queued tick can never reach a torn-down dock.
        for name in (
                "_progress_timer",
                "_refine_debounce_timer",
                "_auto_review_debounce_timer",
                "_auto_conf_debounce_timer",
                "_auto_conf_preview_timer",
                "_auto_progress_ease_timer",
                "_auto_prompt_focus_timer",
                "_auto_detail_emit_timer",
                "_visibility_debounce_timer",
                "_pairing_anim_timer"):
            timer = getattr(self, name, None)
            if timer is None:
                continue
            try:
                timer.blockSignals(True)
                timer.stop()
                timer.timeout.disconnect()
            except (TypeError, RuntimeError, AttributeError):
                pass
        # Remove the temp dir holding the generated checkbox icons
        if getattr(self, "_checkbox_icon_dir", None):
            import shutil
            shutil.rmtree(self._checkbox_icon_dir, ignore_errors=True)
            self._checkbox_icon_dir = None

    def is_activated(self) -> bool:
        """Check if the plugin is activated."""
        return self._plugin_activated

    def showEvent(self, event):
        """Emit plugin_opened once per session when the dock becomes visible.

        The event is queued; if the plugin is not yet activated it parks in the
        telemetry pre-auth queue and ships on the first authenticated flush, so
        a first-run user who activates later still gets one plugin_opened for
        this session.
        """
        super().showEvent(event)
        # Re-sync the raster combos with the layer tree whenever the dock
        # becomes visible: a project opened or layers changed while the dock
        # was hidden can leave the list stale, and a stale EMPTY list shows
        # the "Load your own imagery" hero over a project that has imagery
        # (observed live; the debounced refresh then repaints the state).
        for combo_name in ("layer_combo", "auto_layer_combo"):
            try:
                getattr(self, combo_name)._schedule_refresh()
            except (RuntimeError, AttributeError):
                pass
        if getattr(self, "_plugin_opened_emitted", False):
            return
        try:
            from ...core.telemetry import is_telemetry_enabled
            from ...core.telemetry_session_events import track_plugin_first_open, track_plugin_opened
            # First-ever open on this machine (self-guarded by a persistent
            # QSettings flag) precedes the per-session plugin_opened, so the
            # install -> first-open -> activation funnel has a clean entry.
            track_plugin_first_open()
            track_plugin_opened()
            # The once-per-session latch is only earned when the events could
            # go out. Setting it while telemetry is off means a user who opts
            # in and reopens the dock never gets an open marker at all.
            self._plugin_opened_emitted = bool(is_telemetry_enabled())
        except Exception:
            pass  # nosec B110
