"""Which AI answers a Semi-Auto click, and the switch that picks it.

Two option cards, Cloud AI against the machine, over ONE line saying where the
picked one runs and what it costs.

It touches the Start button, above the caption that says what a click does.
Moving it to the foot of the panel was tried and failed: with the page stretch
between them, nobody read the picker and the button as one thing.

Two earlier builds are the reason it looks like this. A full chooser screen
stopped the user before the Start button and spent a whole state on one
decision. A single "Cloud AI" checkbox then hid the alternative: a tick names
one option, so a user who does not want the cloud cannot see what they would
get instead, and the choice reads as a guess. Both halves stay on screen.

Cloud is the half the mode opens on. Moving to the other half is one click,
always, and no wording shames it.

It stays readable on the install gate, because picking Cloud AI is exactly what
removes the download. It disappears the moment a session opens, because the
engine is fixed at Start and a control that cannot change anything is a lie.

Server withdrawal is the safe direction. With ``manual_cloud_route_offered()``
false there is no second engine, so nothing here is ever shown and the mode
behaves as it always did, on the machine, free.

Part of AISegmentationDockWidget (see ai_segmentation_dockwidget.py); methods
here are plain mixin members and widgets live on the dock instance.
"""
from __future__ import annotations

from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import (
    QLabel,
    QVBoxLayout,
    QWidget,
)

from ...core.cloud_notice_seen import cloud_notice_seen
from ...core.i18n import tr
from ...core.manual_cloud_route import (
    manual_cloud_route_enabled,
    manual_cloud_route_offered,
    set_manual_cloud_route_enabled,
)
from ...core.server_dials import dial_copy
from .cloud_notice_line import build_cloud_notice_line, cloud_notice_line_html
from .manual_local_install_dialog import local_install_minutes
from .styles import (
    _CARD_CHILD_BTN_RESET_QSS,
    _SUBCARD_MARGINS,
    _SUBCARD_QSS,
)
from .ui_refresh import format_quota_count
from .upsell_card import UpsellCard
from .widgets import Mode, _EngineSwitch

# Splits the note into where it runs and what it costs. A middle dot rather
# than a full stop, so the two halves read as one line at a glance instead of
# as a paragraph of two sentences.
_DOT = "·"


def _fill_engine_line(text: str, **parts: str) -> str:
    """Fill the placeholders of one engine line, shipped or served.

    ``str.replace``, never ``str.format``: a served sentence may carry a stray
    brace, and these lines are written on a paint path. Filling after the
    reader rather than before is what lets served copy move the separator, the
    disk figure and the minutes wherever the sentence needs them.
    """
    out = text.replace("{dot}", _DOT)
    for name, value in parts.items():
        out = out.replace("{" + name + "}", value)
    return out


class DockManualEngineMixin:
    """Builds and drives the Semi-Auto engine switch and the note under it."""

    # -- construction -------------------------------------------------------

    def _setup_manual_engine(self) -> None:
        """Build the picker. Hidden until the page asks for it.

        No frame of its own. The two option cards already carry borders and
        the note carries one, so a third box around them added a line and no
        meaning: three nested rectangles read as a form, not as a choice.
        """
        card = QWidget()
        layout = QVBoxLayout(card)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(6)

        # Names the button above, because adjacency alone did not carry it: the
        # cards sat right under Start and were still read as an unrelated
        # setting. It borrows the word "segmentation" off the button on purpose,
        # so the eye joins the two without a line or an arrow between them.
        engine_header = QLabel(dial_copy(
            "engine.header", tr("Where the segmentation runs:")))
        engine_header.setStyleSheet(
            "font-size: 11px; color: rgba(128, 128, 128, 0.95);"
            " background: transparent;")
        layout.addWidget(engine_header)

        # Built on the recommended half. The stored answer lands on the first
        # refresh, before the card is ever shown, so nothing flashes.
        self.manual_engine_switch = _EngineSwitch(cloud=True)
        self.manual_engine_switch.engine_selected.connect(
            self._on_manual_engine_picked)
        layout.addWidget(self.manual_engine_switch)

        # The note gets its own box. Loose text under a control is the one
        # thing this design system never allows.
        #
        # ONE line, and never a headline over a paragraph. That was tried and
        # it put the same sentence on the screen three times: the card gloss,
        # the headline repeating it, and the paragraph repeating the headline.
        # The gloss on the card says what the engine gives. This line says the
        # two things the gloss cannot fit, where it runs and what it costs, and
        # it says them once.
        note_box = QWidget()
        note_box.setObjectName("manualEngineNote")
        note_box.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
        note_layout = QVBoxLayout(note_box)
        note_layout.setContentsMargins(*_SUBCARD_MARGINS)
        note_layout.setSpacing(4)

        self.manual_engine_note = QLabel("")
        self.manual_engine_note.setWordWrap(True)
        self.manual_engine_note.setTextFormat(Qt.TextFormat.RichText)
        self.manual_engine_note.setStyleSheet(
            "font-size: 11px; color: palette(text); background: transparent;")
        note_layout.addWidget(self.manual_engine_note)

        # The data disclosure, permanently on the page instead of in a modal at
        # the first cloud Start, so it is visible before the click instead of
        # blocking it. Inside this box and not under it, because loose text is
        # banned here. Same widget and same sentence as the Automatic panel.
        self.manual_engine_privacy_line = build_cloud_notice_line()
        note_layout.addWidget(self.manual_engine_privacy_line)

        self.manual_engine_note_box = note_box
        layout.addWidget(note_box)

        # The running-low nudge, in the shared offer card rather than an amber
        # sentence with a link in it. Compact variant: the count and the button
        # on one row, because the user can still work and may ignore this.
        #
        # Beside the description, never over it. The balance used to REPLACE the
        # engine copy, so the one card that says what Cloud AI does and what a
        # click costs disappeared exactly when the user was being asked to pay.
        self.manual_engine_low_line = UpsellCard(
            "manualEngineLowCard", "compact",
            on_cta=self._on_manual_low_credit_cta)
        self.manual_engine_low_line.setVisible(False)
        # NOT added here. It is seated at the foot of the dock, next to the
        # credit ring, by _setup_low_credit_slot. Leaving it in this card put an
        # amber warning in the middle of the engine decision and pushed the rest
        # of the page down. Out of the card it no longer hides with it, so
        # _update_manual_engine_card_visible has to put it away itself.

        card.setVisible(False)
        self.manual_engine_card = card
        # Which tint the note box is wearing, so a repaint only runs on a
        # change.
        self._manual_engine_card_tinted: str | None = None
        # Touching the Start button, and now the last thing in that container.
        # It was moved to the foot of the panel once, under the stretch, and
        # that failed on contact: 600 px of empty grey between the button and
        # the picker, and nobody read the two as one thing. Adjacency plus the
        # header above the cards is what says "this is where that button will
        # run".
        #
        # Inside start_container, which is what gets it hidden for free the
        # moment a session opens. The install gate no longer hides that
        # container while the picker exists (see _update_full_ui_interactive),
        # so picking Cloud AI is still one click away on a machine with
        # nothing installed.
        self.start_container.layout().addWidget(card)

    # -- questions the rest of the dock asks --------------------------------

    def _manual_cloud_route_picked(self) -> bool:
        """Which half of the switch the user is on.

        What the panel describes, what the next Start is about to open, and
        what a click is billed on. Nothing else stands between the switch and
        the wire: the data notice is a line in the panel now, not a gate.

        The account is part of the answer: the route needs one, so with no
        account the on-device install is still what a session depends on.
        """
        try:
            return bool(
                self._plugin_activated
                and manual_cloud_route_enabled()
                and manual_cloud_route_offered()
            )
        except (RuntimeError, AttributeError):
            return False

    def _manual_engine_offered(self) -> bool:
        """Whether this install has a second engine to pick at all.

        Fail-closed, like the switch it reads: an install that has never heard
        from a server behaves exactly as it did before the engine card existed.
        """
        try:
            return bool(manual_cloud_route_offered())
        except Exception:  # noqa: BLE001 -- no configuration means no choice
            return False

    def _manual_engine_local_ready(self) -> bool:
        """Whether the offline AI is installed and usable right now."""
        return bool(getattr(self, "_dependencies_ok", False)
                    and getattr(self, "_checkpoint_ok", False))

    def _manual_install_running(self) -> bool:
        """Whether a download carrying the on-device AI is on the wire.

        Moving to Cloud AI mid-install starts a cloud session and leaves the
        download running, which is the right behaviour and the wrong thing to
        do silently: without this the setup section vanishes the instant cloud
        makes the page complete, and minutes of downloading disappear with it.
        The progress timer is the one thing that only runs while it runs.

        The timer alone answers a wider question than this one. EVERY install
        starts it, including the light one Automatic asks for, which fetches
        no model at all: the offline-install window then followed that bar to
        100%, closed, and nothing it exists for had been installed. So the
        timer AND an install this dock knows carries the model.
        """
        try:
            if not self._progress_timer.isActive():
                return False
        except (RuntimeError, AttributeError):
            return False
        return bool(getattr(self, "_manual_install_wants_model", False)
                    or getattr(self, "_auto_review_installing", False))

    # -- refresh ------------------------------------------------------------

    def _refresh_manual_engine_ui(self) -> None:
        """Show the card on the Semi-Auto Start view, and nowhere else."""
        card = getattr(self, "manual_engine_card", None)
        if card is None:
            return
        try:
            on_start_view = bool(
                self._plugin_activated
                and self._mode == Mode.INTERACTIVE
                and manual_cloud_route_offered()
                and not self._segmentation_active
                # Empty state shows the hero and nothing else. With no imagery
                # there is no session to pick an engine for.
                and self.layer_combo.count_layers() > 0
                # At zero the credit gate owns the page. It already names the
                # engine, the price and both ways on, and a card above it
                # still selling the cloud reads as a second offer arguing with
                # the refusal.
                and not (self._manual_cloud_route_picked()
                         and self._manual_credits_exhausted())
            )
            card.setVisible(on_start_view)
            if on_start_view:
                self._refresh_manual_engine_card_text()
            else:
                # The warning lives at the foot of the dock now, so hiding the
                # card no longer takes it away. Without this it survives into a
                # running session and into Automatic, which shows its own.
                self._write_manual_low_credit_line(False)
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- the widget can be gone during teardown

    def _refresh_manual_engine_card_text(self) -> None:
        """Write the box, its tint, its line and its offer for the state now."""
        cloud = self._manual_cloud_route_picked()
        low = cloud and self._manual_engine_credits_low()
        pro_contact = cloud and self._manual_pro_ceiling_objects_low()
        try:
            # Written from the stored answer, never from a user click, so the
            # write must not come back as one.
            self.manual_engine_switch.set_cloud(cloud)
            self.manual_engine_switch.set_cloud_gloss(
                self._manual_engine_cloud_gloss())
            self._paint_manual_engine_card()
            note = self._manual_engine_copy(cloud)
            self.manual_engine_note.setText(note)
            self.manual_engine_note.setVisible(bool(note))
            self.manual_engine_privacy_line.setText(cloud_notice_line_html())
            # Cloud half only, and only until a cloud run has completed. The
            # flag reaches nothing but this setVisible.
            privacy = cloud and not cloud_notice_seen()
            self.manual_engine_privacy_line.setVisible(privacy)
            # An empty box is still a bordered rectangle under the cards. Both
            # of its children can be gone now, so the box goes with them.
            self.manual_engine_note_box.setVisible(bool(note) or privacy)
            self._write_manual_low_credit_line(low, pro_contact)
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- teardown
        if low:
            self._note_manual_low_credit_offer()

    def _manual_engine_cloud_gloss(self) -> str:
        """The cloud card's second line.

        One line for both states. A preview-aware variant led on the ghost
        outline, which is a real difference but not the reason to pick this
        side: size and accuracy are, and the preview is named on the wider
        line under the cards, where it has room to read.

        Served, and capped at 40 characters so a long override is cut instead
        of widening the card. The fallback fits the one-line budget, about 31
        characters at the dock's minimum width.
        """
        return dial_copy("engine.cloud_gloss",
                         tr("Bigger model, more accurate"),
                         max_chars=40)

    def _note_manual_low_credit_offer(self) -> None:
        """One impression per session, so the top-up click has a denominator.

        Counted apart from the out-of-credits card: this one is read by a user
        who can still work, and the two convert nothing alike.
        """
        if getattr(self, "_manual_low_credit_seen", False):
            return
        self._manual_low_credit_seen = True
        try:
            from ...core import telemetry_session_events

            telemetry_session_events.track_pro_upsell_viewed(
                trigger="manual_credits_low")
        except Exception:  # noqa: BLE001 -- telemetry never blocks the UI  # nosec B110
            pass

    def _manual_engine_credits_low(self) -> bool:
        """Whether the balance has dropped into the warning band.

        Zero is not low, it is empty, and the credit gate owns that state with
        the full offer. This is only the stretch before it, where the user is
        still working and can still act. Same threshold as the footer ring,
        read from one place.

        A subscriber never sees it. Their balance refills on a plan they are
        already paying for, so an amber card and a top-up button would sell
        them what they bought.
        """
        try:
            from ...core.credit_gate import (
                low_credit_ceiling,
                low_credit_threshold,
            )

            if getattr(self, "_auto_is_subscriber", False):
                return False
            env = getattr(self, "_quota_envelopes", None)
            if env is not None and env.objects_remaining is not None:
                # Saves spend the objects envelope; the wallet figure can be
                # the km² gauge in tile terms and would warn at the wrong time.
                left, total = env.objects_remaining, env.objects_cap
            else:
                left = getattr(self, "_auto_credits", None)
                total = getattr(self, "_auto_credits_total", None)
            if left is None or not total or int(total) <= 0:
                return False
            if int(left) > low_credit_ceiling():
                return False
            return 0 < int(left) <= int(total) * low_credit_threshold()
        except Exception:  # noqa: BLE001 -- a tint must never break a paint
            return False

    def _manual_pro_ceiling_objects_low(self) -> bool:
        """True when a subscriber's Semi-Auto objects are inside the served
        low share of the month, and still above zero (zero is the wall).

        The subscriber twin of _manual_engine_credits_low: the same seat,
        and a mail to us instead of an offer for the plan they already pay
        for. Off with the served switch, or on a legacy count.
        """
        try:
            from ...core.pro_ceiling import (
                pro_ceiling_enabled,
                pro_ceiling_low_fraction,
            )

            if not getattr(self, "_auto_is_subscriber", False):
                return False
            if not pro_ceiling_enabled():
                return False
            env = getattr(self, "_quota_envelopes", None)
            if env is None or not env.has_objects_gauge():
                return False
            left = env.objects_remaining
            if left is None:
                left = max(0, int(env.objects_cap) - int(env.objects_used))
            cap = int(env.objects_cap)
            if cap <= 0:
                return False
            return 0 < int(left) <= cap * pro_ceiling_low_fraction()
        except Exception:  # noqa: BLE001 -- a tint must never break a paint
            return False

    def _paint_manual_engine_card(self) -> None:
        """The note box, quiet in every state.

        Deliberately no tint, for the picked engine or for a low balance. The
        premium fill is byte-identical to the armed one, so a box wearing it
        while nothing is happening reads as a tool waiting to be clicked. The
        push toward the cloud is carried by the picked card and its star.

        The amber used to live here, which put it on the description of what
        the mode does and left the user reading a warning where the price used
        to be. It has its own offer card at the foot of the dock now
        (`manual_engine_low_line`), so the balance and the description stop
        competing for one box.
        """
        if self._manual_engine_card_tinted == "":
            return
        self._manual_engine_card_tinted = ""
        qss = (_SUBCARD_QSS.format(name="manualEngineNote")
               + "QLabel { background: transparent; border: none; }")
        try:
            self.manual_engine_note_box.setStyleSheet(
                qss + _CARD_CHILD_BTN_RESET_QSS)
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- teardown

    def _manual_engine_copy(self, cloud: bool) -> str:
        """The line under the cards, and empty whenever the engine is ready.

        Only the states that ask the user for something still speak here: the
        install to run, the install running, the install that failed. A ready
        engine says nothing, because the card above already says which one is
        picked and what it gives, and the sentence under it repeated that in
        different words.

        The balance is NOT one of the states. It used to take this line over on
        the way to empty, which deleted the only sentence saying what a click
        costs at the exact moment the user was being asked to pay. It has its
        own line under this box now.
        """
        if cloud:
            return ""
        if not self._manual_engine_local_ready():
            if self._manual_install_running():
                # Do not promise "runs on your computer" while it is still
                # being put there. The install window is modal and holds QGIS
                # until it ends, so this line cannot offer an action: the one
                # way to Cloud AI from here is the window's own Stop.
                return _fill_engine_line(dial_copy(
                    "engine.installing_line",
                    tr("Setting up on your computer {dot} <b>wait for it "
                       "to finish</b>")))
            if getattr(self, "_manual_install_failed", False):
                # The card under this one says the install failed. Selling the
                # ten-minute install over it read as if nothing had happened.
                return _fill_engine_line(dial_copy(
                    "engine.install_failed_line",
                    tr("The install did not finish {dot} <b>retry it, or "
                       "pick Cloud AI</b>")))
            return self._manual_engine_install_copy()
        return ""

    def _on_manual_low_credit_link(self, url: str) -> None:
        """The Pro link inside this mode's running-low line.

        Same destination as Automatic's, counted under its own source. The two
        modes now show the same sentence, but a click here is a Semi auto user
        reaching the end of a balance one saved object at a time, and folding
        it into the other would leave the impression this mode already tracks
        with nothing to divide it by.
        """
        from ..external_links import open_external_url
        try:
            from ...core import telemetry_session_events
            telemetry_session_events.track_pro_upsell_clicked(
                source="manual_credits_low")
        except Exception:  # noqa: BLE001 -- telemetry never blocks a click
            pass  # nosec B110
        open_external_url(url, parent=self)

    def _on_manual_low_credit_cta(self) -> None:
        """The card button, one hop to the link handler above.

        The offer moved from a sentence with a link inside it to a button, so
        the destination has to be built here instead of being written into the
        text. Same cta source and same count as before.
        """
        try:
            url = self._build_upgrade_url("plugin_objects_low_note")
        except (RuntimeError, AttributeError):
            return
        self._on_manual_low_credit_link(url)

    def _on_pro_contact_objects_low(self) -> None:
        """The subscriber's button on the running-low card: copy our address."""
        line = getattr(self, "manual_engine_low_line", None)
        self._on_pro_contact_clicked(
            "objects_low", getattr(line, "button", None))

    def _write_manual_low_credit_line(self, low: bool,
                                      pro_contact: bool = False) -> None:
        """The running-low card under the engine note. Never raises.

        The fact carries the numbers and nothing else: how many are left, out
        of how many, and when they come back. The button names Pro rather than
        offering to "get more credits", because what is on the other side is a
        paid plan and the wording that hid it was the one thing users had to
        click to find out.

        ``pro_contact`` is the subscriber's version of the same card: the
        count, our address, and a button that copies it, the way the
        Automatic surface note does.
        """
        line = getattr(self, "manual_engine_low_line", None)
        if line is None:
            return
        try:
            if not low and not pro_contact:
                line.setVisible(False)
                return
            if pro_contact:
                env = self._quota_envelopes
                objects_left = (env.objects_remaining
                                if env.objects_remaining is not None
                                else max(0, env.objects_cap - env.objects_used))
                title = dial_copy(
                    "pro_ceiling.low_title_objects",
                    tr("{left} of {cap} Semi-Auto objects left this month"))
                title = (title.replace("{left}", format_quota_count(objects_left))
                              .replace("{cap}", format_quota_count(env.objects_cap)))
                body, cta = self._pro_ceiling_copy()
                line.route_cta(self._on_pro_contact_objects_low)
                line.set_text(title, body, cta, detail=self._pro_ceiling_detail())
                line.setVisible(True)
                return
            line.route_cta(self._on_manual_low_credit_cta)
            reset_day = getattr(self, "_auto_reset_display", "")
            cta = dial_copy("upsell.cta", tr("Upgrade to Pro"))
            env = getattr(self, "_quota_envelopes", None)
            if env is not None and env.has_objects_gauge():
                # Remaining, counting down: the same sense as every other
                # gauge in the plugin and the dashboard.
                # Served, like the offer line under it. Placeholders are filled
                # with str.replace, never format(), so a stray brace in a
                # served sentence cannot raise on a refresh.
                if reset_day:
                    title = dial_copy(
                        "upsell.low_title_objects_reset",
                        tr("{n} of {total} cloud objects left in Semi-Auto, back on "
                           "{date}."))
                else:
                    title = dial_copy(
                        "upsell.low_title_objects",
                        tr("{n} of {total} cloud objects left in Semi-Auto this month."))
                # The gauge answers with cap-minus-used when the server sent no
                # remainder, so the card never prints the word None, and both
                # figures group their thousands the way the locale does.
                objects_left = (env.objects_remaining
                                if env.objects_remaining is not None
                                else max(0, env.objects_cap - env.objects_used))
                title = (title.replace("{n}", format_quota_count(objects_left))
                              .replace("{total}", format_quota_count(env.objects_cap))
                              .replace("{date}", reset_day))
            else:
                left = int(getattr(self, "_auto_credits", 0) or 0)
                if reset_day:
                    title = dial_copy(
                        "upsell.low_title_detections_reset",
                        tr("{n} cloud detections left, back on {date}."))
                else:
                    title = dial_copy(
                        "upsell.low_title_detections",
                        tr("{n} cloud detections left."))
                title = (title.replace("{n}", str(left))
                              .replace("{date}", reset_day))
            # The count alone was a warning, not an offer: it said what was
            # running out and never what the other side holds. The figure is
            # served, because the Pro allowance is a commercial number that
            # moves without waiting for a plugin release.
            body = dial_copy(
                "upsell.low_body_objects",
                tr("Pro gives you 500 cloud objects a month in Semi-Auto."))
            line.set_text(title, body, cta)
            line.setVisible(True)
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- teardown

    def _manual_engine_install_copy(self) -> str:
        """What the offline AI asks for before Start is worth pressing.

        Time and disk, together. The install already refuses below a free-space
        floor, so a user who reads only the minutes can lose them to a wall this
        could have warned about. The floor is the served one, the same number
        the install itself checks.
        """
        # The minutes come from the served figure through {n}, so one deploy
        # changes what every installed version says. A served sentence may
        # carry the placeholder or a literal of its own.
        minutes = str(local_install_minutes())
        fallback = _fill_engine_line(
            dial_copy("engine.install_line_no_disk",
                      tr("Everything stays on this computer {dot} <b>about "
                         "{n} minutes to install</b>")),
            n=minutes)
        # `resolved_min_free_gb_full`, not `min_free_gb_full`: the second name
        # belongs to install_config and never existed here, so this import
        # raised on every refresh and the disk figure never once reached a
        # user. A memory read, no disk call: this runs on every redraw of the
        # Start view.
        try:
            from ...core.venv_manager import resolved_min_free_gb_full

            gb = float(resolved_min_free_gb_full())
        except Exception:  # noqa: BLE001 -- an unknown floor drops the figure
            return fallback
        if gb <= 0:
            return fallback
        return _fill_engine_line(
            dial_copy("engine.install_line",
                      tr("Everything stays on this computer {dot} <b>{gb} GB "
                         "and about {n} minutes to install</b>")),
            gb=f"{gb:g}", n=minutes)

    def _refresh_manual_engine_card_enabled(self) -> None:
        """Keep the Start button honest about the engine the switch is on.

        The switch itself is never disabled. Picking where the work runs does
        not depend on a raster or on the Terms, and greying the one control a
        card exists for is the wall of dead controls the design system bans.
        """
        try:
            from ...core.activation_manager import has_tos_accepted, has_tos_locked

            ready = bool(
                self.layer_combo.currentLayer() is not None
                and (has_tos_locked() or has_tos_accepted())
            )
            self._refresh_manual_start_action(ready)
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- teardown

    def _refresh_manual_start_action(self, ready: bool) -> None:
        """Make the Start button say what the picked engine turns it into.

        With the box cleared and nothing installed, pressing Start starts a
        download, not a session. Saying "Start" there and greying it out is the
        wall of dead controls the design system bans: the button keeps working
        and names the download instead.
        """
        button = getattr(self, "start_button", None)
        if button is None:
            return
        # A download on the wire this second, NOT "a download is needed".
        # _setup_section_wanted means the second one: it is set the moment the
        # panel learns the pieces are missing, so reading it here made
        # needs_install and not installing dead, and the button kept saying
        # Start on the one machine that cannot start.
        installing = self._manual_install_running()
        needs_install = bool(
            manual_cloud_route_offered()
            and not self._manual_cloud_route_picked()
            and not self._manual_engine_local_ready()
        )
        try:
            if needs_install and not installing:
                button.setText(tr("Install the offline AI"))
                button.setEnabled(ready and not self._segmentation_active)
            else:
                button.setText(tr("Start Semi-Auto AI Segmentation"))
            # Set from here, not once at build time. A tooltip that names the
            # Terms sits on an enabled button for the whole rest of the
            # session otherwise, and it is the only thing on screen that would
            # explain a disabled one. It also has to name the blocker it is
            # actually looking at: a user mid-install has a raster and has
            # accepted the Terms, and being told to go and do both reads as the
            # panel having lost track of them.
            button.setToolTip("" if button.isEnabled()
                              else self._manual_start_blocked_reason(ready))
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- teardown

    def _manual_start_blocked_reason(self, ready: bool) -> str:
        """Why Start is grey, in the order the user can act on.

        The install states come first: a user watching a download already has
        a raster and has accepted the Terms, so naming those two there sends
        them to look for something that is not missing.
        """
        if self._manual_install_running():
            return tr("The offline AI is still downloading.")
        if getattr(self, "_manual_install_failed", False):
            return tr("The install did not finish. Retry it, or pick Cloud AI.")
        if self._segmentation_active:
            return tr("A session is already running.")
        if not ready:
            return tr("Pick a raster layer and accept the Terms to start.")
        return tr("The offline AI is not installed yet.")

    def set_manual_cloud_session_spend(self, saved: int) -> None:
        """Record how many objects this cloud session has charged for.

        Called by the plugin on every billed save. The footer gauge is what
        shows the balance during a session; this only keeps the count the
        session recap reads.
        """
        self._manual_cloud_objects_saved = max(0, int(saved))

    # -- the clicks ---------------------------------------------------------

    def _track_manual_engine(self, what: str,
                             from_install_gate: bool = False) -> None:
        """Report a choice. Never raises: telemetry does not gate a click."""
        try:
            from ...core import telemetry_session_events

            telemetry_session_events.track_manual_engine_chosen(
                what,
                local_installed=self._manual_engine_local_ready(),
                from_install_gate=from_install_gate,
            )
        except Exception:  # noqa: BLE001 -- best effort  # nosec B110
            pass

    def _on_manual_engine_picked(self, cloud: bool) -> None:
        """The switch IS the choice, unless the picked half is not on the disk.

        Moving to the machine used to store the choice and let the panel grow
        an install card underneath, which is a download starting from a control
        that said "My computer". The window asks first (see
        manual_local_install.py) and stores the choice only on a yes, so the
        cards never sit on an engine that cannot answer a click.
        """
        if not cloud and not self._manual_engine_local_ready():
            self._open_manual_local_install()
            return
        self._set_manual_engine_cloud(bool(cloud))

    def _set_manual_engine_cloud(self, on: bool) -> None:
        """Move the route and tell the whole page about it."""
        # Worth telling apart: cloud picked while a download is running is a
        # user who did not want to wait it out, not a first choice.
        from_gate = bool(on and not self._manual_engine_local_ready())
        set_manual_cloud_route_enabled(on)
        self._track_manual_engine("cloud" if on else "offline",
                                  from_install_gate=from_gate)
        # Picking the cloud card is the earliest honest sign a click is coming,
        # and a machine takes about half a minute to start. Told here, the
        # plugin can ask for one while the user is still framing their object,
        # rather than at Start, which measured as too late: the click itself was
        # what started the machine in nearly every Semi-Auto session.
        try:
            self.manual_engine_changed.emit(bool(on))
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- teardown
        # The refine panel carries two cloud-only controls, and the switch is
        # only reachable before Start, where the full refresh does not reach
        # the panel. Settle it here, or it describes the other engine.
        try:
            self._sync_refine_shape_toggles()
        except (RuntimeError, AttributeError):
            pass  # nosec B110 -- panel not built yet
        self._update_full_ui()

    def _manual_engine_gate_start(self) -> bool:
        """Answer the Start button before a Semi-Auto session opens.

        True means start now. False means the click was spent on something
        else and no session may open: the offline AI has to be downloaded
        before it can answer anything.

        The cloud half asks nothing here any more. The disclosure it used to
        put in a modal is a permanent line in the engine card, so the first
        click starts a session instead of a question.
        """
        try:
            if self._mode != Mode.INTERACTIVE or not manual_cloud_route_offered():
                return True
            cloud = self._manual_cloud_route_picked()
        except (RuntimeError, AttributeError):
            return True

        if cloud:
            return True

        if self._manual_engine_local_ready():
            return True
        # One door to the download, the same one the engine cards use. Start
        # only reaches it after an install was stopped or failed and left the
        # switch on a half with nothing behind it.
        self._open_manual_local_install()
        return False
