"""Panel construction: title bar, welcome/setup/activation sections, pairing.

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

import os

from qgis.PyQt.QtCore import Qt, QTimer
from qgis.PyQt.QtGui import QKeySequence
from qgis.PyQt.QtWidgets import (
    QCheckBox,
    QFrame,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QProgressBar,
    QPushButton,
    QSizePolicy,
    QStyle,
    QToolButton,
    QVBoxLayout,
    QWidget,
)

from ...core.activation_manager import (
    get_consent_terms_url,
    get_privacy_url,
    has_tos_accepted,
    has_tos_locked,
)
from ...core.cache_paths import PLUGIN_CACHE_DIR
from ...core.i18n import tr
from ...core.model_config import _IS_MACOS_X86, USE_SAM2
from ...core.qt_compat import QShortcut
from ..layer_tree_combobox import LayerTreeComboBox
from .guidance import (
    BLUE_TINT,
    HINT_PREVIEW_ZOOM,
    DismissibleHint,
)
from .styles import (
    _BTN_BLUE,
    _BTN_EXPORT_READY,
    _BTN_GRAY,
    _BTN_GREEN,
    _BTN_GREEN_AUTH,
    _BTN_PAIR_CANCEL,
    _BTN_PAIR_NEUTRAL,
    _BTN_RED,
    _CARD_MARGINS,
    _CARD_QSS,
    _INSTRUCTIONS_CARD_QSS,
    _PROGRESS_THIN_QSS,
    BRAND_BLUE,
    BTN_GREEN,
    _btn_start_qss,
    _msg_card_qss,
)
from .widgets import (
    Mode,
    _ModeSwitch,
    _ShortcutArmingFilter,
    _Spinner,
    build_no_imagery_hero,
)


class DockBuildMixin:
    """Panel construction: title bar, welcome/setup/activation sections, pairing."""

    def _setup_title_bar(self):
        """Custom title bar with clickable TerraLab link and native buttons."""
        title_widget = QWidget()
        title_outer = QVBoxLayout(title_widget)
        title_outer.setContentsMargins(0, 0, 0, 0)
        title_outer.setSpacing(0)

        title_row = QHBoxLayout()
        title_row.setContentsMargins(4, 0, 0, 0)
        title_row.setSpacing(0)

        title_label = QLabel(
            'AI Segmentation by '
            '<a href="https://terra-lab.ai?utm_source=qgis&utm_medium=plugin'
            '&utm_campaign=ai-segmentation&utm_content=title_link"'
            f' style="color: {BRAND_BLUE}; text-decoration: none;">TerraLab</a>'
        )
        title_label.setOpenExternalLinks(True)
        title_row.addWidget(title_label)
        title_row.addStretch()

        icon_size = self.style().pixelMetric(QStyle.PixelMetric.PM_SmallIconSize)

        float_btn = QToolButton()
        float_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarNormalButton))
        float_btn.setFixedSize(icon_size + 4, icon_size + 4)
        float_btn.setAutoRaise(True)
        float_btn.clicked.connect(lambda: self.setFloating(not self.isFloating()))
        title_row.addWidget(float_btn)

        close_btn = QToolButton()
        close_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton))
        close_btn.setFixedSize(icon_size + 4, icon_size + 4)
        close_btn.setAutoRaise(True)
        close_btn.clicked.connect(self.close)
        title_row.addWidget(close_btn)

        title_outer.addLayout(title_row)

        separator = QFrame()
        separator.setFrameShape(getattr(QFrame, "Shape", QFrame).HLine)
        separator.setFrameShadow(getattr(QFrame, "Shadow", QFrame).Sunken)
        title_outer.addWidget(separator)

        self.setTitleBarWidget(title_widget)

    def _setup_ui(self):
        self._setup_mode_switch()
        self._setup_welcome_section()
        self._setup_setup_section()
        self._setup_activation_section()
        self._setup_segmentation_section()
        # Runs after the section above, because it inserts itself into that
        # section's start_container, touching the Start button.
        self._setup_manual_engine()
        self._setup_manual_credit_gate()
        self._setup_automatic_page()
        self.main_layout.addStretch()
        self._setup_low_credit_slot()
        # After the stretch, pinned to the dock bottom just above the footer
        # icons: a guide banner belongs at the end of the page, not in the
        # middle of the flow. It is a main_layout item, so its show and hide
        # run through _update_auto_tutorial_banner_visibility.
        self.main_layout.addWidget(self.auto_tutorial_banner)
        self._setup_update_notification()
        self._setup_update_recommendation()
        self._setup_about_section()
        # A configuration left on disk by an earlier session is already
        # readable here, so the switches apply before the dock is ever shown.
        self.apply_server_feature_switches()

    def _setup_low_credit_slot(self):
        """One bottom slot for both modes' running-low warnings.

        The warning used to sit where the balance ran out: under the engine
        cards in Semi-Auto, on the Start page in Automatic. That put an amber
        card in the middle of the decision the user was making, and it pushed
        everything under it down the panel. It reads the same at the foot of
        the dock, next to the credit ring it is talking about, and the space it
        was taking goes back to the flow.

        A holder rather than a bare layout, so the slot collapses to nothing
        when neither mode has anything to say. Both modes still own their own
        line: the wording, the visibility and the click count stay where they
        were, only the seat moved.
        """
        holder = QWidget()
        holder.setObjectName("lowCreditSlot")
        slot = QVBoxLayout(holder)
        slot.setContentsMargins(0, 0, 0, 0)
        slot.setSpacing(6)
        self.low_credit_slot = slot
        self.main_layout.addWidget(holder)
        # Semi-Auto builds its line with the engine card, which runs before
        # this; Automatic builds its own lazily and finds the slot waiting.
        line = getattr(self, "manual_engine_low_line", None)
        if line is not None:
            slot.addWidget(line)

    def _setup_mode_switch(self):
        """Create the Interactive / Automatic segmented control at the dock top."""
        self.mode_switch = _ModeSwitch(self._mode, self)
        self.mode_switch.mode_selected.connect(self._on_mode_selected)
        self.main_layout.addWidget(self.mode_switch)

    def _on_mode_selected(self, mode: Mode) -> bool:
        """Handle mode-switch toggle. Blocks the switch while a run is going.

        True when the mode changed, False when the request was refused, so a
        programmatic caller can report the refusal instead of claiming the
        mode it asked for.
        """
        if mode == Mode.AUTOMATIC and self._segmentation_active:
            self.mode_switch.blockSignals(True)
            self.mode_switch.set_mode(Mode.INTERACTIVE)
            self.mode_switch.blockSignals(False)
            msg = tr("Stop the active segmentation before switching modes.")
            self.mode_switch.setToolTip(msg)
            # T4: tooltips are hover-only, so also flash the reason as a 3 s
            # status line where the user is already looking.
            try:
                # The card look is what the label wears at rest; a blocked
                # switch must not leave it in whatever the last state set.
                self._set_instructions_style("card")
                self.instructions_label.setText(msg)
                QTimer.singleShot(3000, self._update_instructions)
            except (RuntimeError, AttributeError):
                pass
            return False
        if mode == Mode.INTERACTIVE and self._auto_run_active:
            self.mode_switch.blockSignals(True)
            self.mode_switch.set_mode(Mode.AUTOMATIC)
            self.mode_switch.blockSignals(False)
            msg = tr("Cancel the active detection before switching modes.")
            self.mode_switch.setToolTip(msg)
            # T4: same 3 s status line on the Automatic side; the restore
            # brings the live progress bar back if the run is still going.
            try:
                self.set_auto_status("info", msg)
                QTimer.singleShot(3000, self._restore_auto_run_status)
            except (RuntimeError, AttributeError):
                pass
            return False
        self.mode_switch.setToolTip("")
        self._mode = mode
        # B1: programmatic callers (MCP set_mode, headless auto runs) enter
        # here directly, bypassing the segmented control's own toggle, so sync
        # its checked segment explicitly. set_mode blocks the button-group
        # signals and is a no-op for a real user click (already checked).
        self.mode_switch.set_mode(mode)
        self._auto_zone_too_large = False
        # No last-mode persistence: the dock always reopens on Automatic (D1).
        self.mode_changed.emit(mode)
        self._update_full_ui()
        return True

    def _on_manual_try_example(self) -> None:
        """Manual first-run hero 'Try it on an example'. The demo simply loads
        a ready-made basemap and frames a rich scene; Automatic is the lead
        mode, so switch there and load it. The demo only selects the layer and
        leaves the user on the Start step (they draw the zone and click Start
        themselves). The basemap persists across the switch, so the user can
        flip back to Manual and click on the same imagery. Deferred a tick so
        the mode switch settles (mode_changed resets the Automatic flow to
        Start) before the demo loads."""
        self._on_mode_selected(Mode.AUTOMATIC)
        QTimer.singleShot(0, self.auto_demo_requested.emit)

    def _restore_auto_run_status(self) -> None:
        """Drop the 3 s blocked-mode-switch notice (T4): back to the live
        progress bar while a run is still in flight, else a clean idle line."""
        try:
            if self._auto_run_active:
                self.set_auto_status("progress")
                self._refresh_auto_progress_readout()
                self._set_auto_progress_visible(True)
            else:
                self.set_auto_status("idle")
        except (RuntimeError, AttributeError):
            pass

    def _setup_welcome_section(self):
        """Setup the welcome section."""
        self.welcome_widget = QWidget()
        self.welcome_widget.setObjectName("welcomeCard")
        self.welcome_widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
        self.welcome_widget.setStyleSheet(_msg_card_qss("welcomeCard", "info"))
        layout = QVBoxLayout(self.welcome_widget)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(4)

        self.welcome_title = QLabel(tr("Click Install to set up AI Segmentation"))
        self.welcome_title.setStyleSheet("font-weight: bold; font-size: 12px; color: palette(text);")
        layout.addWidget(self.welcome_title)

        # Hidden until the startup dependency check concludes an install is
        # actually needed (_update_full_ui gates it on the setup section being
        # on display). Showing it during the silent re-check made "Click
        # Install..." flash for half a second on every open of an already
        # installed plugin.
        self.welcome_widget.setVisible(False)
        self.main_layout.addWidget(self.welcome_widget)

    def _setup_setup_section(self):
        """Unified setup section: deps install + model download in one flow."""
        # Untitled, unframed QGroupBox used purely as a layout container (no
        # visible title, no frame): the design-system card look does not apply
        # here by design, so this is an intentional exception, not an
        # oversight.
        self.setup_group = QGroupBox("")
        self.setup_group.setStyleSheet(
            "QGroupBox { border: none; margin: 0; padding: 0; }"
        )
        layout = QVBoxLayout(self.setup_group)

        self.setup_status_label = QLabel(tr("Checking..."))
        self.setup_status_label.setStyleSheet("color: palette(text);")
        layout.addWidget(self.setup_status_label)

        self.setup_progress = QProgressBar()
        self.setup_progress.setRange(0, 100)
        self.setup_progress.setStyleSheet(_PROGRESS_THIN_QSS)
        self.setup_progress.setTextVisible(False)
        self.setup_progress.setVisible(False)
        layout.addWidget(self.setup_progress)

        self.setup_progress_label = QLabel("")
        self.setup_progress_label.setStyleSheet("color: palette(text); font-size: 11px;")
        self.setup_progress_label.setVisible(False)
        layout.addWidget(self.setup_progress_label)

        self.install_button = QPushButton(tr("Install"))
        self.install_button.clicked.connect(self._on_install_clicked)
        self.install_button.setVisible(False)
        self.install_button.setMinimumHeight(34)
        self.install_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self.install_button.setStyleSheet(_BTN_GREEN)
        layout.addWidget(self.install_button)

        self.install_path_label = QLabel(
            tr("Install path: {}").format(PLUGIN_CACHE_DIR))
        self.install_path_label.setWordWrap(True)
        # Qt wraps a path at "/" and not at "\", so on Windows the whole path
        # is one unbreakable token, and a wrapped label reports its longest
        # token as its minimum width. That pushed the dock past its 260px
        # minimum and took the space out of the canvas. Same fix as the layer
        # combo below: the label may be squeezed, its content no longer sets
        # the floor. The tooltip keeps the path readable in full.
        self.install_path_label.setSizePolicy(
            QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
        self.install_path_label.setToolTip(PLUGIN_CACHE_DIR)
        self.install_path_label.setStyleSheet(
            "color: palette(text);"
            "font-size: 11px;"
            "padding: 4px 6px;"
            "background-color: palette(base);"
            "border: 1px solid rgba(128, 128, 128, 0.35);"
            "border-radius: 3px;"
        )
        layout.addWidget(self.install_path_label)

        self.cancel_toggle = QToolButton()
        self.cancel_toggle.setText(tr("Cancel installation"))
        self.cancel_toggle.setArrowType(Qt.ArrowType.RightArrow)
        self.cancel_toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
        self.cancel_toggle.setStyleSheet(
            "color: palette(text); font-size: 11px; border: none;")
        self.cancel_toggle.setVisible(False)
        self.cancel_toggle.clicked.connect(self._toggle_cancel_button)
        layout.addWidget(self.cancel_toggle)

        self.cancel_button = QPushButton(tr("Cancel"))
        self.cancel_button.clicked.connect(self._on_cancel_clicked)
        self.cancel_button.setVisible(False)
        self.cancel_button.setStyleSheet(_BTN_RED)
        layout.addWidget(self.cancel_button)

        if not USE_SAM2:
            if _IS_MACOS_X86:
                sam1_text = tr("Intel Mac: using the older AI model.")
            else:
                sam1_text = tr("Update QGIS to 3.34+ for the latest AI model")
            sam1_info = QLabel(sam1_text)
            sam1_info.setStyleSheet("color: palette(text); font-size: 11px;")
            layout.addWidget(sam1_info)

        # Hidden until a state that needs it: set_dependency_status(ok=False)
        # and the model-download path show it; set_checkpoint_status(ok=True)
        # hides it. Starting visible made the "Checking..." / "Install path:"
        # lines flash on every open of an already installed plugin (the
        # startup quick-check hides them ~0.5 s later).
        self.setup_group.setVisible(False)
        self.main_layout.addWidget(self.setup_group)

    def _setup_activation_section(self):
        """One-click browser sign-in (mirrors AI Edit), with a discreet
        paste-a-key fallback for admins and browserless machines."""
        self.activation_group = QGroupBox()
        self.activation_group.setStyleSheet(
            "QGroupBox { border: none; margin: 0; padding: 0; }"
        )
        layout = QVBoxLayout(self.activation_group)
        layout.setSpacing(8)
        layout.setContentsMargins(4, 4, 4, 4)

        layout.addSpacing(6)
        # Header row: copy + brand logo at the end (AI Edit ends its header
        # with the banana glyph; ours is the AI Segmentation mark).
        header_row = QHBoxLayout()
        header_row.setSpacing(7)
        header_row.addStretch(1)
        self._setup_header = QLabel(tr("Segment your map with AI"))
        self._setup_header.setStyleSheet(
            "font-weight: 600; font-size: 14px; color: palette(text);")
        header_row.addWidget(self._setup_header, 0, Qt.AlignmentFlag.AlignVCenter)
        from qgis.PyQt.QtGui import QPixmap
        _logo_path = os.path.join(
            os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
                os.path.abspath(__file__))))),
            "resources", "icons", "logo_title.png")
        _logo_pm = QPixmap(_logo_path)
        if not _logo_pm.isNull():
            _dpr = self.devicePixelRatioF()
            _logo_pm = _logo_pm.scaledToHeight(
                int(18 * _dpr), Qt.TransformationMode.SmoothTransformation)
            _logo_pm.setDevicePixelRatio(_dpr)
            _logo_lbl = QLabel()
            _logo_lbl.setPixmap(_logo_pm)
            header_row.addWidget(_logo_lbl, 0, Qt.AlignmentFlag.AlignVCenter)
        header_row.addStretch(1)
        layout.addLayout(header_row)

        layout.addSpacing(14)

        # --- Primary: one tap to sign in (browser handoff, no copy-paste) ---
        self._connect_section = QWidget()
        connect_layout = QVBoxLayout(self._connect_section)
        connect_layout.setContentsMargins(0, 0, 0, 0)
        connect_layout.setSpacing(6)

        self._connect_btn = QPushButton(tr("Sign in / Sign up to start"))
        self._connect_btn.setToolTip(
            tr("Sign in via your browser to start using AI Segmentation"))
        self._connect_btn.setMinimumHeight(38)
        self._connect_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        self._connect_btn.setStyleSheet(_BTN_GREEN_AUTH)
        self._connect_btn.clicked.connect(self._on_connect_clicked)
        connect_layout.addWidget(self._connect_btn)

        # Signed-out is one neutral first step (before any mode concept), so the
        # hint stays mode-agnostic: no price, no local/cloud wording. Framed
        # card with two check rows.
        hint_card = QFrame()
        hint_card.setObjectName("signinHintCard")
        hint_card.setStyleSheet(_CARD_QSS.format(name="signinHintCard"))
        hint_card_layout = QVBoxLayout(hint_card)
        hint_card_layout.setContentsMargins(*_CARD_MARGINS)
        hint_card_layout.setSpacing(5)

        def _hint_row(text):
            row = QHBoxLayout()
            row.setSpacing(7)
            check = QLabel("✓")
            check.setStyleSheet(
                f"font-size: 11px; font-weight: 600; color: {BTN_GREEN};"
                " border: none; background: transparent;")
            row.addWidget(check, 0, Qt.AlignmentFlag.AlignTop)
            label = QLabel(text)
            label.setWordWrap(True)
            label.setStyleSheet(
                "font-size: 11px; color: palette(text);"
                " border: none; background: transparent;")
            row.addWidget(label, 1)
            hint_card_layout.addLayout(row)
            return label

        self._connect_hint_label = _hint_row(
            tr("Free account - sign up takes 15 seconds in your browser."))
        _hint_row(
            tr("Then segment any imagery: point and click, or fully automatic."))
        connect_layout.addWidget(hint_card)

        layout.addWidget(self._connect_section)

        # --- Waiting state: shown while the browser handoff is in progress ---
        self._pairing_wait_section = QWidget()
        wait_layout = QVBoxLayout(self._pairing_wait_section)
        wait_layout.setContentsMargins(0, 4, 0, 0)
        wait_layout.setSpacing(12)

        status_row = QHBoxLayout()
        status_row.setSpacing(8)
        status_row.addStretch(1)
        self._pairing_spinner = _Spinner(16)
        status_row.addWidget(self._pairing_spinner, 0, Qt.AlignmentFlag.AlignVCenter)
        self._pairing_status = QLabel(tr("Waiting for your browser sign-in..."))
        self._pairing_status.setWordWrap(True)
        self._pairing_status.setStyleSheet("font-size: 12px; color: palette(text);")
        status_row.addWidget(self._pairing_status, 0, Qt.AlignmentFlag.AlignVCenter)
        status_row.addStretch(1)
        wait_layout.addLayout(status_row)

        btn_row = QHBoxLayout()
        btn_row.setSpacing(6)
        self._pairing_reopen_btn = QPushButton(tr("Open again"))
        self._pairing_reopen_btn.setToolTip(tr("Didn't open? Open the page again"))
        self._pairing_reopen_btn.setMinimumHeight(28)
        self._pairing_reopen_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        self._pairing_reopen_btn.setStyleSheet(_BTN_PAIR_NEUTRAL)
        self._pairing_reopen_btn.clicked.connect(self._on_pairing_reopen_clicked)
        btn_row.addWidget(self._pairing_reopen_btn)

        self._pairing_cancel_btn = QPushButton(tr("Cancel"))
        self._pairing_cancel_btn.setMinimumHeight(28)
        self._pairing_cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        self._pairing_cancel_btn.setStyleSheet(_BTN_PAIR_CANCEL)
        self._pairing_cancel_btn.clicked.connect(self._on_pairing_cancel_clicked)
        btn_row.addWidget(self._pairing_cancel_btn)
        wait_layout.addLayout(btn_row)

        self._pairing_wait_section.setVisible(False)
        layout.addWidget(self._pairing_wait_section)

        # One timer rotates the spinner while waiting. Parented to the dock
        # (segfault-safe) and stopped the moment the wait section hides.
        self._pairing_anim_timer = QTimer(self)
        self._pairing_anim_timer.setInterval(80)
        self._pairing_anim_timer.timeout.connect(self._pairing_spinner.advance)
        self._pending_pairing_code = ""

        # Browser pairing is the only sign-in path. (The old "Use an activation
        # key" manual-entry fallback was removed; keys still back the client
        # auth, but a human never types one into the UI.)

        self.activation_message_label = QLabel("")
        self.activation_message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.activation_message_label.setWordWrap(True)
        self.activation_message_label.setVisible(False)
        self.activation_message_label.setStyleSheet("font-size: 11px;")
        layout.addWidget(self.activation_message_label)

        self.activation_group.setVisible(False)
        self.main_layout.addWidget(self.activation_group)

    # --- One-click connect (browser pairing handoff) ----------------------

    def _on_connect_clicked(self):
        """Start the one-click browser handoff. Mints a high-entropy pairing
        code; the plugin opens the browser and polls until it gets the key."""
        import secrets
        self._pending_pairing_code = secrets.token_urlsafe(32)
        self.show_pairing_waiting()
        self.pairing_requested.emit(self._pending_pairing_code)

    def _on_pairing_reopen_clicked(self):
        """Re-open the browser with the SAME code (do not mint a new one)."""
        if self._pending_pairing_code:
            self.pairing_requested.emit(self._pending_pairing_code)

    def _on_pairing_cancel_clicked(self):
        self.pairing_cancel_requested.emit(self._pending_pairing_code)
        self._pending_pairing_code = ""
        self.show_pairing_idle()

    def show_pairing_waiting(self):
        """Switch the onboarding into the 'waiting for browser' state."""
        self._connect_section.setVisible(False)
        self.activation_message_label.setVisible(False)
        self._pairing_wait_section.setVisible(True)
        self._pairing_anim_timer.start()

    def _stop_pairing_wait(self):
        """Hide the waiting section and stop its animation timer."""
        self._pairing_anim_timer.stop()
        self._pairing_wait_section.setVisible(False)

    def show_pairing_idle(self):
        """Return to the idle onboarding (Connect button visible)."""
        self._stop_pairing_wait()
        self._connect_section.setVisible(True)

    def _on_start_shortcut_key(self):
        """G starts a session, unless the user is picking from a list.

        The shortcut is scoped to the dock and its children, so it also fires
        while the layer combo holds focus, where typing a letter is how a user
        jumps to a layer by name. Returning from here was not enough: the
        shortcut had already taken the letter, so the combo never got it and
        the type-ahead did nothing. The shortcut stands DOWN for a list now
        (refresh_auto_shortcut_arming, re-run on the press itself by
        _ShortcutArmingFilter), and this check stays as the belt for a machine
        where that filter never installed.
        """
        if self._dock_combo_has_focus():
            return
        self._on_start_shortcut()

    def _setup_segmentation_section(self):
        self.seg_widget = QWidget()
        layout = QVBoxLayout(self.seg_widget)
        layout.setContentsMargins(0, 8, 0, 0)
        layout.setSpacing(8)

        layer_label = QLabel(tr("Select a raster layer to segment:"))
        layer_label.setStyleSheet("font-weight: bold; color: palette(text);")
        layout.addWidget(layer_label)
        self.layer_label = layer_label

        self.layer_combo = LayerTreeComboBox()
        self.layer_combo.layerChanged.connect(self._on_layer_changed)
        self.layer_combo.setToolTip(tr("Select a raster layer (GeoTIFF, WMS, XYZ tiles, etc.)"))
        self.layer_combo.setStyleSheet("QComboBox { color: palette(text); }")
        self.layer_combo.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
        self.layer_combo.setMinimumWidth(0)
        layout.addWidget(self.layer_combo)

        # Refine-handoff header (title + Back to review + kept progress),
        # shown only while Manual mode refines the Automatic results. It sits
        # UNDER the locked layer combo so the page reads top-down as
        # "this raster, this task, this state" (see dock/handoff.py).
        self._setup_handoff_header(layout)

        # No-rasters state = the SAME first-run hero the Automatic page shows
        # (shared helper), so both modes read identically: the imagery is the
        # user's to bring, with the demo place as the reassurance fallback. This
        # replaces the old yellow "No raster layer found" alarm. The Manual
        # example reuses the Automatic showcase (see _on_manual_try_example).
        self.no_rasters_widget, self.manual_demo_btn = build_no_imagery_hero(
            on_demo=self._on_manual_try_example,
        )
        self.no_rasters_widget.setVisible(False)
        # No layout stretch factor here (unlike the Automatic page, whose hero
        # lives inside a QStackedWidget that absorbs the slack). A stretch factor
        # counts even while the widget is hidden, so it would make this FLAT
        # seg_widget layout report itself as vertically expanding on the Start
        # view and inflate the bold "Select a raster" label to fill the void.
        # The hero wrapper is Expanding on its own, so it still fills the empty
        # state when it becomes visible.
        layout.addWidget(self.no_rasters_widget)

        # Dynamic instruction label - styled as a card (slightly darker gray than refine panel)
        self.instructions_label = QLabel("")
        self.instructions_label.setWordWrap(True)
        self.instructions_label.setMinimumHeight(70)
        self.instructions_label.setAlignment(
            Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
        self.instructions_label.setStyleSheet(_INSTRUCTIONS_CARD_QSS)
        # Memo read by _set_instructions_style: the label starts on the card.
        self._instructions_style = "card"
        self.instructions_label.setVisible(False)
        layout.addWidget(self.instructions_label)

        # The one aiming tip of the live preview: the zoom at the click decides
        # the outline's precision, and zooming in is the lever. Under the
        # instructions and above the refine panel, because it changes how the
        # user frames the object BEFORE judging the ghost's shape. Shown only in
        # a cloud session with the preview on (_preview_zoom_tip_wanted); an
        # offline session never sees this cloud-only affordance.
        self.preview_zoom_hint = DismissibleHint(
            HINT_PREVIEW_ZOOM,
            tr("Zoom in for a finer outline. The AI reads the image at "
               "your current zoom."),
            tint=BLUE_TINT,
            visibility_gate=self._preview_zoom_tip_wanted,
        )
        self.preview_zoom_hint.setVisible(False)
        layout.addWidget(self.preview_zoom_hint)

        # Refine-handoff state card (replaces the instructions label while a
        # handoff is active; see dock/handoff.py).
        self._setup_handoff_state_card(layout)

        # Container for start button
        self.start_container = QWidget()
        start_layout = QVBoxLayout(self.start_container)
        start_layout.setContentsMargins(0, 8, 0, 0)
        start_layout.setSpacing(6)

        # Terms + Privacy consent - required to run a segmentation.
        # Placed here (not on activation) so the user only sees it when they
        # are about to actually use the service. Hidden permanently once the
        # user has clicked Start for the first time (see lock_tos): at that
        # point consent is considered irrevocably given.
        # Served addresses, read when the row is built; the UTM names the
        # consent row as the touchpoint.
        _tos_terms_url = get_consent_terms_url("consent_terms")
        _tos_privacy_url = get_privacy_url("consent_privacy")
        self.tos_container = QWidget()
        tos_row = QHBoxLayout(self.tos_container)
        tos_row.setContentsMargins(0, 0, 0, 0)
        tos_row.setSpacing(4)
        # Use Qt's native QCheckBox rendering - the previous custom stylesheet
        # only toggled the indicator's background colour without rendering a
        # checkmark, so users couldn't tell at a glance whether the box was
        # checked. Native rendering matches QGIS's own checkbox conventions.
        self.tos_checkbox = QCheckBox()
        self.tos_checkbox.setChecked(has_tos_accepted())
        self.tos_checkbox.toggled.connect(self._on_tos_toggled)
        tos_row.addWidget(self.tos_checkbox, 0)
        self.tos_label = QLabel(
            tr('I agree to the <a href="{terms}">Terms</a> '
               'and <a href="{privacy}">Privacy Policy</a>').format(
                terms=_tos_terms_url, privacy=_tos_privacy_url
            )
        )
        self.tos_label.setOpenExternalLinks(True)
        self.tos_label.setWordWrap(True)
        self.tos_label.setStyleSheet("font-size: 11px; color: palette(text);")
        tos_row.addWidget(self.tos_label, 1)
        # If the user has already started a segmentation in the past, the
        # consent is sealed and the row disappears forever.
        if has_tos_locked():
            self.tos_container.setVisible(False)
        start_layout.addWidget(self.tos_container)

        self.start_button = QPushButton(tr("Start Semi-Auto AI Segmentation"))
        self.start_button.setEnabled(False)
        self.start_button.clicked.connect(self._on_start_clicked)
        self.start_button.setCursor(Qt.CursorShape.PointingHandCursor)
        # Same prominence as AI Edit's "Launch AI Edit" button.
        self.start_button.setMinimumHeight(40)
        self.start_button.setStyleSheet(_btn_start_qss(_BTN_GREEN))
        self.start_button.setToolTip(
            tr("Accept the Terms and Privacy Policy to enable segmentation.")
        )
        start_layout.addWidget(self.start_button)

        # Three things used to live here and no longer do. Two filled the panel
        # after the work was over: the export recap said what the last session
        # produced, which the saved layer in the legend already says, and the
        # Try-Automatic band sold the other mode to somebody who had just
        # finished using this one.
        #
        # The third was a dismissible caption describing the mode ("click an
        # object and the AI outlines it, one at a time"). It went because the
        # engine cards now sit here and carry the screen: with them above it,
        # the caption was a fourth block of prose explaining a gesture the user
        # cannot perform until they press Start, and it hid the moment they
        # did. The instructions card takes over the live guidance.
        #
        # Do not bring any of the three back without a reason that is not "the
        # space is empty".

        # Keyboard shortcut G to start segmentation (scoped to dock +
        # children). Armed like the others, from refresh_auto_shortcut_arming:
        # it stands down while a drop-down holds focus, where the letter is the
        # user jumping to a layer by name.
        self.start_shortcut = QShortcut(QKeySequence("G"), self)
        self.start_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
        self.start_shortcut.activated.connect(self._on_start_shortcut_key)

        # Automatic-flow keyboard parity (mirrors AI Edit): Escape exits the
        # selection view back to Start; Enter detects. WindowShortcut so they
        # work whether focus is in the dock or on the canvas, and each one is
        # armed only while the flow owns its key (refresh_auto_shortcut_arming):
        # a window shortcut eats its key on the match, so a guard inside the
        # handler leaves QGIS's own Escape and Undo silent all the same.
        self.auto_escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
        self.auto_escape_shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
        self.auto_escape_shortcut.activated.connect(self._on_auto_escape_shortcut)
        self.auto_enter_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Return), self)
        self.auto_enter_shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
        self.auto_enter_shortcut.activated.connect(self._on_auto_enter_shortcut)
        self.auto_enter_shortcut_kp = QShortcut(QKeySequence(Qt.Key.Key_Enter), self)
        self.auto_enter_shortcut_kp.setContext(Qt.ShortcutContext.WindowShortcut)
        self.auto_enter_shortcut_kp.activated.connect(self._on_auto_enter_shortcut)
        # Correct-step shortcuts. Delete (and Ctrl+Backspace) remove the
        # selected polygon, and Ctrl+Z undoes the last change. All three are
        # ARMED on their state, not merely gated on it inside the handler: a
        # shortcut left enabled with its gate down still eats the key, and two
        # enabled Ctrl+Z in one window make Qt fire neither. Both predicates
        # stand down while the QGIS edit bridge is armed (_qgis_bridge_active_ui)
        # or an AI fix session is running (_refine_handoff), which are exactly
        # the states where the vertex tool wants Delete for the picked CORNER
        # and QGIS wants Ctrl+Z for its own edit. While the on-device session
        # tool is armed, ShortcutFilter accepts the ShortcutOverride for these
        # keys, so they reach the session (delete the open object, undo the
        # last gesture).
        self.auto_correct_remove_backspace_shortcut = QShortcut(
            QKeySequence("Ctrl+Backspace"), self)
        self.auto_correct_remove_backspace_shortcut.setContext(
            Qt.ShortcutContext.WindowShortcut)
        self.auto_correct_remove_backspace_shortcut.activated.connect(
            self._on_auto_correct_remove_shortcut)
        self.auto_correct_remove_delete_shortcut = QShortcut(
            QKeySequence(Qt.Key.Key_Delete), self)
        self.auto_correct_remove_delete_shortcut.setContext(
            Qt.ShortcutContext.WindowShortcut)
        self.auto_correct_remove_delete_shortcut.activated.connect(
            self._on_auto_correct_remove_shortcut)
        self.auto_correct_undo_shortcut = QShortcut(
            QKeySequence.StandardKey.Undo, self)
        self.auto_correct_undo_shortcut.setContext(
            Qt.ShortcutContext.WindowShortcut)
        self.auto_correct_undo_shortcut.activated.connect(
            self._on_auto_correct_undo_shortcut)

        # The list cleanup_signals tears down. Written here, beside the
        # builders, so a shortcut added above is never left connected to a dock
        # that is gone.
        self._dock_shortcuts = [
            self.start_shortcut,
            self.auto_escape_shortcut,
            self.auto_enter_shortcut,
            self.auto_enter_shortcut_kp,
            self.auto_correct_remove_backspace_shortcut,
            self.auto_correct_remove_delete_shortcut,
            self.auto_correct_undo_shortcut,
        ]

        # Nothing is armed at rest, and the states that arm each key are
        # written from half the dock, so the filter re-arms them on the press
        # itself (see _ShortcutArmingFilter). Parented to the dock: Qt drops
        # the filter with it, so unload leaves nothing behind on the window.
        self.refresh_auto_shortcut_arming()
        self._shortcut_arming_filter = _ShortcutArmingFilter(self, self)
        # On the dock too, for the floating case: a key pressed in a floating
        # dock never walks up to the main window.
        self.installEventFilter(self._shortcut_arming_filter)
        # Kept so cleanup_signals can take the filter off the window again: a
        # reload builds a new dock, and the old filter would stay on the window.
        self._shortcut_arming_window = None
        try:
            from qgis.utils import iface

            window = iface.mainWindow() if iface is not None else None
            if window is not None:
                window.installEventFilter(self._shortcut_arming_filter)
                self._shortcut_arming_window = window
        except (ImportError, AttributeError, RuntimeError):
            pass  # nosec B110 - step changes still re-arm without the filter

        layout.addWidget(self.start_container)

        # Collapsible Refine mask panel
        self._setup_refine_panel(layout)

        # Refine-handoff exit: the full-width "Done, back to review" primary
        # (see dock/handoff.py). Placed after the Shape settings so the page
        # reads top-down as edit, then leave.
        self._setup_handoff_footer(layout)

        # Primary action buttons (reduced height)
        self.save_mask_button = QPushButton(tr("Save polygon (S)"))
        self.save_mask_button.clicked.connect(self._on_save_polygon_clicked)
        self.save_mask_button.setVisible(False)
        self.save_mask_button.setEnabled(False)
        self.save_mask_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self.save_mask_button.setMinimumHeight(34)
        self.save_mask_button.setStyleSheet(_BTN_BLUE)
        self.save_mask_button.setToolTip(
            tr("Click the object first. Save polygon keeps it in your "
               "session; Export writes all kept polygons to a layer.")
        )
        layout.addWidget(self.save_mask_button)

        self.export_button = QPushButton(tr("Export polygon to a layer"))
        self.export_button.clicked.connect(self._on_export_clicked)
        self.export_button.setVisible(False)
        self.export_button.setEnabled(False)
        self.export_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self.export_button.setMinimumHeight(34)
        # One look, written once: the primary is HIDDEN while there is
        # nothing to export, so it never wears a disabled style.
        self.export_button.setStyleSheet(_BTN_EXPORT_READY)
        layout.addWidget(self.export_button)

        # Secondary action buttons (small, horizontal)
        secondary_layout = QHBoxLayout()
        secondary_layout.setSpacing(8)

        self.undo_button = QPushButton(tr("Undo last point"))
        self.undo_button.setEnabled(False)
        self.undo_button.setToolTip(
            tr("Removes the last point you placed on the object."))
        self.undo_button.clicked.connect(self._on_undo_clicked)
        self.undo_button.setVisible(False)  # Hidden until segmentation starts
        self.undo_button.setMinimumHeight(30)
        self.undo_button.setStyleSheet(_BTN_GRAY)
        secondary_layout.addWidget(self.undo_button, 1)  # stretch factor 1

        self.clear_selection_button = QPushButton(tr("Clear selection (C)"))
        self.clear_selection_button.setEnabled(False)
        self.clear_selection_button.setToolTip(
            tr("Removes the points and the shape you are working on. "
               "Saved polygons stay."))
        self.clear_selection_button.clicked.connect(
            self._on_clear_selection_clicked)
        self.clear_selection_button.setVisible(False)
        self.clear_selection_button.setMinimumHeight(30)
        self.clear_selection_button.setStyleSheet(_BTN_GRAY)
        secondary_layout.addWidget(self.clear_selection_button, 1)

        self.stop_button = QPushButton(tr("Stop segmentation"))
        self.stop_button.clicked.connect(self._on_stop_clicked)
        self.stop_button.setVisible(False)  # Hidden until segmentation starts
        self.stop_button.setMinimumHeight(30)
        self.stop_button.setStyleSheet(_BTN_GRAY)
        secondary_layout.addWidget(self.stop_button, 1)  # stretch factor 1 for same width

        self.secondary_buttons_widget = QWidget()
        self.secondary_buttons_widget.setLayout(secondary_layout)
        self.secondary_buttons_widget.setVisible(False)
        layout.addWidget(self.secondary_buttons_widget)

        self.main_layout.addWidget(self.seg_widget)
