from __future__ import annotations

import html
import random

from qgis.PyQt.QtCore import QTimer
from qgis.PyQt.QtGui import QTextCursor
from qgis.PyQt.QtWidgets import QStyle

from ...core import qt_compat as QtC
from ...core import telemetry
from ...core import telemetry_events as te
from ...core.auth.activation_manager import has_seen_privacy_notice
from ...core.i18n import tr
from ...core.paywall_state import classify_paywall_state
from ...core.pro_ceiling import pro_ceiling_enabled, pro_low_threshold
from ...core.prompts.loading_messages import get_phase_messages
from ...core.prompts.prompt_presets import format_template_prompt
from ...core.resolution_labels import DEFAULT_RESOLUTION_CREDIT_COSTS
from ..onboarding_hint import (
    HINT_FIRST_STEPS,
    HINT_GUIDE_AI,
    dismiss_hint,
    is_hint_dismissed,
)
from .style import SUCCESS_TEXT


class DockGenerationStateMixin:
    """State machine transitions (launch / selecting zone / prompt /
    generating / result), progress animation, and status boxes for
    AIEditDockWidget."""

    def set_reference_target_extent(self, extent, crs) -> None:
        """Align reference image renders to the generation zone extent (pushed
        by the plugin when a zone is drawn). (None, None) reverts to the view."""
        if self._reference_widget is not None:
            self._reference_widget.set_target_extent(extent, crs)

    def selected_input_layer(self):
        """The raster the edit starts from, as picked in the layer header, or
        None when the project holds no visible raster."""
        combo = getattr(self, "_layer_combo", None)
        if combo is None:
            return None
        try:
            return combo.currentLayer()
        except RuntimeError:
            return None

    def _set_layer_header_state(self, state: str) -> None:
        """"hidden" on the idle screen, "editable" while the zone is being
        drawn, "frozen" from the zone commit to the result: the list stops
        following the layer tree and the map view, and the combo greys out so
        the user reads which raster the run started from."""
        header = getattr(self, "_layer_header", None)
        combo = getattr(self, "_layer_combo", None)
        if header is None or combo is None:
            return
        if state == "hidden":
            header.setVisible(False)
            combo.set_frozen(False)
            combo.set_view_tracking(True)
            return
        header.setVisible(True)
        frozen = state == "frozen"
        combo.set_frozen(frozen)
        combo.set_view_tracking(not frozen)
        combo.setEnabled(not frozen)
        combo.setToolTip(
            tr("Exit to pick another raster.") if frozen else tr(
                "Pick the raster layer the edit starts from. Everything else on "
                "the map stays out of the input."
            )
        )

    def set_zone_selected(self):
        """Zone drawn: show the prompt section and the Generate/Exit row."""
        self._zone_selected = True
        self._hide_status_box()
        self._layer_saved_label.setVisible(False)
        self._launch_section.setVisible(False)
        self._select_zone_section.setVisible(False)
        self._result_section.setVisible(False)
        self._set_layer_header_state("frozen")
        self._prompt_section.setVisible(True)
        self._prompt_container.set_readonly(False)
        self._place_reference_widget("prompt")
        self._generate_note_box.setVisible(not has_seen_privacy_notice())
        self._generate_btn.setVisible(True)
        self._exit_btn.setVisible(True)
        self._refresh_resolution_triggers()
        self._update_generate_enabled()
        self._update_generate_button_text()
        self._update_guide_ai_tip()
        # Defer focus: the canvas still has it from the just-finished mouse
        # release event. Setting focus synchronously gets clobbered as soon
        # as the canvas finishes its own focus handling. We fire twice
        # (0ms + 50ms) because on some platforms the canvas reclaims focus
        # after the first setFocus call.
        QtC.safe_single_shot(0, self, self._focus_prompt_input)
        QtC.safe_single_shot(50, self, self._focus_prompt_input)

    def _focus_prompt_input(self):
        """Bring the dock forward and put the caret in the prompt textarea."""
        self.raise_()
        self.activateWindow()
        self._prompt_input.setFocus(QtC.OtherFocusReason)
        cursor = self._prompt_input.textCursor()
        cursor.movePosition(QTextCursor.MoveOperation.End)
        self._prompt_input.setTextCursor(cursor)

    def hide_privacy_notice(self):
        """Retire the disclosure line once a generation has completed. The box
        holds only that line, so the whole box goes and leaves no empty frame."""
        self._generate_note_box.setVisible(False)

    def set_zone_cleared(self):
        """Zone removed: return to the SELECTING_ZONE state.

        Called when the user right-clicks → Delete zone, presses Esc on the
        canvas, or clicks the × overlay on the rubber band. We go back to
        the 'Select your zone' invitation rather than all the way to
        LAUNCH - the user is mid-flow, just redrawing.
        """
        self.set_reference_target_extent(None, None)
        self.set_selecting_zone_state()

    def _stop_progress_animation(self):
        """Stop the smooth progress animation timer if running."""
        if hasattr(self, "_progress_timer") and self._progress_timer is not None:
            self._progress_timer.stop()

    def _should_show_first_steps(self) -> bool:
        """First-steps guide banner gate: only when signed in, on the idle
        LAUNCH screen, and not yet dismissed. Hidden while selecting a zone,
        generating, or viewing a result, and inside the tool panels."""
        if not getattr(self, "_activated", False):
            return False
        if is_hint_dismissed(HINT_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._warning_widget.isVisibleTo(self):
                return False
        except (RuntimeError, AttributeError):
            pass
        try:
            # isVisibleTo (not isVisible) so the gate reflects the INTENDED
            # state even if the dock window is not on-screen right now.
            return bool(self._launch_section.isVisibleTo(self))
        except (RuntimeError, AttributeError):
            return False

    def _update_first_steps_visibility(self) -> None:
        """Drive the bottom-pinned first-steps banner from its gate. Called on
        every state transition: the banner is a top-level sibling of the footer,
        so it is not auto-hidden when the flow swaps the content views."""
        hint = getattr(self, "_first_steps_hint", None)
        if hint is not None:
            hint.setVisible(self._should_show_first_steps())

    def _guide_ai_tip_visible(self) -> bool:
        """Visibility gate for the "Guide the AI" tip: retires for the rest of
        the session once the user has used a grounding feature (reference or
        markup), or closed the tip. Read by DismissibleHint.reshow() so a
        guidance reset never flashes the tip back for someone who just used
        the features."""
        return not is_hint_dismissed(HINT_GUIDE_AI)

    def _mark_guide_ai_touched(self) -> None:
        """The user used a grounding feature (a reference got attached, or the
        markup chip or shortcut fired). They know it exists, so retire the tip
        for the rest of the session (it is back next session, see
        SESSION_ONLY_HINTS). This is a use, not a close, so it skips the close
        telemetry."""
        if is_hint_dismissed(HINT_GUIDE_AI):
            return
        hint = getattr(self, "_guide_ai_hint", None)
        if hint is not None:
            hint.hide()
        dismiss_hint(HINT_GUIDE_AI)

    def _on_guide_ai_dismissed(self) -> None:
        telemetry.track(te.GUIDANCE_TIP_DISMISSED)

    def _update_guide_ai_tip(self) -> None:
        """Show the tip for a freshly drawn zone (a new edit group), unless
        the user has already used a grounding feature or closed the tip for
        good."""
        hint = getattr(self, "_guide_ai_hint", None)
        if hint is None:
            return
        if is_hint_dismissed(HINT_GUIDE_AI):
            hint.hide()
            return
        hint.show()
        telemetry.track(te.GUIDANCE_TIP_SHOWN)

    def set_launch_state(self):
        """LAUNCH: show the entry screen with the 'Launch AI Edit' button.

        Used after activation and whenever the user clicks Exit. The selection
        tool is expected to be inactive in this state (managed by the plugin).
        """
        self._stop_progress_animation()
        self._hide_status_box()
        self._zone_selected = False
        # Leaving the flow voids any onboarding imagery gate, so a mid-load
        # Exit or zone-clear can't strand Generate disabled for the next zone.
        self._imagery_loading = False

        if self._reference_widget is not None:
            self._reference_widget.clear()
            self._reference_widget.setVisible(False)

        self._launch_section.setVisible(True)
        self._set_layer_header_state("hidden")
        self._select_zone_section.setVisible(False)
        self._prompt_section.setVisible(False)
        self._progress_widget.setVisible(False)
        self._result_section.setVisible(False)
        self._layer_saved_label.setVisible(False)
        self._generate_note_box.setVisible(False)
        self._generate_btn.setVisible(False)
        self.set_generate_block_reason(None)
        self._exit_btn.setVisible(False)

        self._prompt_container.set_readonly(False)
        self._prompt_input.clear()
        self._prompt_input.setFixedHeight(60)
        self._result_prompt_input.clear()
        self._active_template_id = None
        self._active_template_name = None
        # Dirty or never-synced cache on the home screen: the last history
        # fetch failed or has not landed (the post-generation one included).
        # Retry so the rows self-heal on the exact screen that shows them.
        # Visibility gate: set_activated calls this from initGui too, where
        # the dock may be closed, and an idle install must stay network-free
        # (the deferred bootstrap covers the first real show).
        if self.isVisible() and (
            self._library_history_dirty or not self._library_history_loaded
        ):
            self.conversations_refresh_requested.emit()
        # Idle screen: reveal the first-steps guide banner (gate-checked).
        self._update_first_steps_visibility()

    def clear_active_template(self) -> None:
        """Drop the armed template so a new zone doesn't reuse a preset that
        was picked for the previous zone. Called from plugin._on_zone_selected."""
        self._active_template_id = None
        self._active_template_name = None
        # Resolution persists for the QGIS session - the paid "2K" default is
        # applied when set_credits confirms the tier, and coerced to "1K" by
        # _refresh_resolution_triggers when free tier is confirmed.
        self._refresh_resolution_triggers()
        self._update_layer_warning()
        # Re-surface the upsell banner on free-tier-exhausted accounts (or the
        # lighter pre-wall nudge one step before that): state transitions
        # otherwise hide both via set_status() side effects.
        if self._is_free_tier_exhausted() and self._trial_info_url:
            self._trial_info_box.setVisible(True)
        elif (self._is_free_tier_prewall() or self._is_pro_low()) and self._prewall_url:
            self._prewall_banner.setVisible(True)

    def set_zone_step_notice(self, text: str = "") -> None:
        """Answer a refused zone inside the draw step. Empty text clears it."""
        label = getattr(self, "_select_zone_notice", None)
        if label is None:
            return
        label.setText(text)
        label.setVisible(bool(text))

    def _scroll_panel_to_top(self) -> None:
        """Put the top of the panel back in view on a step change.

        Launch used to leave a scrolled dock where it was, so the step it
        opened could sit below the fold and the click read as a no-op.
        """
        area = getattr(self, "_scroll_area", None)
        if area is None:
            return
        try:
            area.verticalScrollBar().setValue(0)
        except (RuntimeError, AttributeError):
            pass

    def set_selecting_zone_state(self):
        """SELECTING_ZONE: invite the user to draw a zone on the canvas.

        Entered after Launch is clicked, or after the user clears their zone.
        The selection tool should be active (managed by the plugin).
        """
        self._stop_progress_animation()
        self._hide_status_box()
        self._zone_selected = False
        # Leaving the flow voids any onboarding imagery gate, so a mid-load
        # Exit or zone-clear can't strand Generate disabled for the next zone.
        self._imagery_loading = False

        if self._reference_widget is not None:
            self._reference_widget.setVisible(False)

        self._launch_section.setVisible(False)
        self._set_layer_header_state("editable")
        self._select_zone_section.setVisible(True)
        self._prompt_section.setVisible(False)
        self._progress_widget.setVisible(False)
        self._result_section.setVisible(False)
        self._layer_saved_label.setVisible(False)
        self._generate_note_box.setVisible(False)
        self._generate_btn.setVisible(False)
        self.set_generate_block_reason(None)
        # No Exit in this state - the screen is just the draw invitation.
        self._exit_btn.setVisible(False)
        self.set_zone_step_notice()
        self._update_layer_warning()
        # Left the idle screen: hide the first-steps guide banner.
        self._update_first_steps_visibility()
        # The step always shows itself: see _scroll_panel_to_top.
        self._scroll_panel_to_top()

    def set_generating(self, generating: bool):
        """Toggle generation state -- keep prompt visible but grayed out.

        Wrapped in setUpdatesEnabled(False)/(True) so Qt batches the many
        setVisible() calls below into a single repaint. Without this batch,
        the panel reflows piecewise on Generate click and the user sees the
        dock go blank for ~1s before the progress UI lands.
        """
        self.setUpdatesEnabled(False)
        try:
            self._progress_widget.setVisible(generating)
            self._result_section.setVisible(False)
            self._warning_widget.setVisible(False)
            # A run owns the panel: the update card steps aside until it ends.
            self.sync_update_banner()

            if generating:
                self._progress_bar.setRange(0, 100)
                # Start at 1% so the bar is visible immediately on click. The
                # prep ticker animates 1->10% during canvas+upload phases, then
                # the worker's first real progress signal (>=5%) takes over.
                self._progress_bar.setValue(1)
                self._progress_target = 1
                self._hide_status_box()
                self._launch_section.setVisible(False)
                self._select_zone_section.setVisible(False)
                self._prompt_section.setVisible(True)
                self._prompt_container.set_readonly(True)
                # On regenerate the refs widget lives in the result container, so
                # hiding result_section above would also hide the thumbnails.
                # Move it back into the visible prompt container before locking.
                self._place_reference_widget("prompt")
                if self._reference_widget is not None:
                    self._reference_widget.set_readonly(True)
                # The Reference panel is a second view over the same store:
                # lock it in step with the strip.
                if getattr(self, "_reference_panel", None) is not None:
                    self._reference_panel.set_readonly(True)
                # Keep the version lineage visible under the progress bar while
                # the next edit renders, but locked (no base switch mid-run).
                self._place_version_strip("generating")
                self._version_strip.set_readonly(True)
                self._generate_note_box.setVisible(False)
                self._generate_btn.setVisible(False)
                self.set_generate_block_reason(None)
                # Hide Exit during generation: the user shouldn't be tempted to
                # cancel mid-run from this row. The title-bar X still works as
                # an escape hatch.
                self._exit_btn.setVisible(False)
                self._start_prep_ticker("canvas")
            else:
                self._stop_prep_ticker()
                self._prompt_container.set_readonly(False)
                if self._reference_widget is not None:
                    self._reference_widget.set_readonly(False)
                if getattr(self, "_reference_panel", None) is not None:
                    self._reference_panel.set_readonly(False)
                self._generate_note_box.setVisible(not has_seen_privacy_notice())
                self._generate_btn.setVisible(True)
                self._exit_btn.setVisible(True)
                self._refresh_resolution_triggers()
                self._prompt_section.setVisible(True)
                # Cancelled / errored run: bring the strip back to its home and
                # unlock it (the result screen may re-appear with it).
                self._place_version_strip("result")
                self._version_strip.set_readonly(False)
        finally:
            self.setUpdatesEnabled(True)

    # Prep ticker: animates the bar 1->10% during canvas (export) and upload
    # phases, rotating playful messages so the user gets visible feedback
    # instead of a static "Preparing..." until the worker's first poll.
    def _start_prep_ticker(self, phase: str) -> None:
        self._prep_phase = phase
        self._prep_messages_pool = get_phase_messages(phase) or [tr("Preparing...")]
        random.shuffle(self._prep_messages_pool)
        self._prep_idx = 0
        # Set first message right away so the user sees something immediately.
        self._progress_label.setText(self._prep_messages_pool[0])
        if not hasattr(self, "_prep_ticker") or self._prep_ticker is None:
            self._prep_ticker = QTimer(self)
            self._prep_ticker.setInterval(1300)
            self._prep_ticker.timeout.connect(self._tick_prep)
        if not self._prep_ticker.isActive():
            self._prep_ticker.start()

    def _stop_prep_ticker(self) -> None:
        if hasattr(self, "_prep_ticker") and self._prep_ticker is not None and self._prep_ticker.isActive():
            self._prep_ticker.stop()

    def prep_advance_phase(self, phase: str) -> None:
        """Switch the prep ticker to a new message pool mid-flight.
        Called by plugin.py when canvas export finishes -> upload phase starts.
        """
        if not hasattr(self, "_prep_ticker") or self._prep_ticker is None or not self._prep_ticker.isActive():
            return
        self._start_prep_ticker(phase)

    def _tick_prep(self) -> None:
        # Cycle messages
        if self._prep_messages_pool:
            self._prep_idx = (self._prep_idx + 1) % len(self._prep_messages_pool)
            self._progress_label.setText(self._prep_messages_pool[self._prep_idx])
        # Advance the bar by 1% per tick, capped at the phase ceiling. Stops
        # naturally when the worker emits a real progress signal (>=5%) since
        # set_progress_message stops the prep ticker.
        cap = 5 if self._prep_phase == "canvas" else 10
        current = self._progress_bar.value()
        if current < cap:
            self._progress_target = min(cap, current + 1)
            if not hasattr(self, "_progress_timer") or self._progress_timer is None:
                self._progress_timer = QTimer(self)
                self._progress_timer.setInterval(30)
                self._progress_timer.timeout.connect(self._animate_progress)
            if not self._progress_timer.isActive():
                self._progress_timer.start()

    def set_progress_message(self, message: str, percentage: int = -1):
        """Update the progress label and bar during generation with smooth animation."""
        # First real worker progress signal -> stop the prep ticker so it stops
        # competing for the label + bar with the worker's own messages.
        self._stop_prep_ticker()
        self._progress_label.setText(message)
        if percentage >= 0:
            self._progress_bar.setRange(0, 100)
            self._progress_target = percentage
            if not hasattr(self, "_progress_timer") or self._progress_timer is None:
                self._progress_timer = QTimer(self)
                self._progress_timer.setInterval(30)
                self._progress_timer.timeout.connect(self._animate_progress)
            if not self._progress_timer.isActive():
                self._progress_timer.start()

    def _animate_progress(self):
        """Smoothly animate progress bar toward target value."""
        current = self._progress_bar.value()
        target = getattr(self, "_progress_target", current)
        if current < target:
            self._progress_bar.setValue(current + 1)
        else:
            if hasattr(self, "_progress_timer") and self._progress_timer is not None:
                self._progress_timer.stop()

    def _show_status_box(self, message: str, box_type: str = "info"):
        """Show a styled status message box (AI Segmentation style)."""
        styles = {
            "error": (
                "QWidget { background-color: rgba(211, 47, 47, 0.25); "
                "border: 1px solid rgba(211, 47, 47, 0.6); border-radius: 4px; }"
                "QLabel { background: transparent; border: none; color: #ef5350; }",
                QStyle.StandardPixmap.SP_MessageBoxCritical,
            ),
            "success": (
                "QWidget { background-color: rgba(139, 172, 39, 0.25); "
                "border: 1px solid rgba(139, 172, 39, 0.6); border-radius: 4px; }"
                "QLabel { background: transparent; border: none; color: #66bb6a; }",
                QStyle.StandardPixmap.SP_DialogApplyButton,
            ),
            "warning": (
                "QWidget { background-color: rgba(245, 166, 35, 0.12); "
                "border: 1px solid rgba(245, 166, 35, 0.45); border-radius: 4px; }"
                "QLabel { background: transparent; border: none; color: palette(text); }",
                QStyle.StandardPixmap.SP_MessageBoxWarning,
            ),
            "info": (
                "QWidget { background-color: rgba(30, 136, 229, 0.08); "
                "border: 1px solid rgba(30, 136, 229, 0.2); border-radius: 4px; }"
                "QLabel { background: transparent; border: none; }",
                QStyle.StandardPixmap.SP_MessageBoxInformation,
            ),
        }
        style_str, icon_enum = styles.get(box_type, styles["error"])
        self._status_widget.setStyleSheet(style_str)
        icon = self._status_widget.style().standardIcon(icon_enum)
        self._status_icon.setPixmap(icon.pixmap(self._status_icon_size, self._status_icon_size))
        self._status_label.setText(message)
        self._status_widget.setVisible(True)

    def _hide_status_box(self):
        self._status_widget.setVisible(False)
        self._status_label.setText("")
        self.clear_status_action()
        self._hide_limit_cta()

    def clear_status_action(self) -> None:
        """Drop the action button and its handler."""
        button = getattr(self, "_status_action_btn", None)
        if button is not None:
            button.setVisible(False)
            button.setText("")
        self._status_action_handler = None

    def set_status_action(self, label: str, handler) -> None:
        """Put ONE next step beside the current status message.

        Call it right after set_status: set_status clears any previous action,
        so a message can never inherit the button of the one before it.
        """
        button = getattr(self, "_status_action_btn", None)
        if button is None or not label:
            return
        self._status_action_handler = handler
        button.setText(label)
        button.setVisible(True)

    def _on_status_action_clicked(self) -> None:
        handler = getattr(self, "_status_action_handler", None)
        if handler is None:
            return
        # Retire the offer before running it: the handler usually replaces the
        # message, and a stale button under a new one points nowhere.
        self.clear_status_action()
        handler()

    def set_status(self, message: str, is_error: bool = False):
        self._hide_limit_cta()
        self.clear_status_action()
        if not message:
            self._hide_status_box()
        else:
            self._show_status_box(message, "error")
        # Only hide the trial-exhausted upsell (or the pre-wall banner) if it's
        # no longer applicable; otherwise transient status updates would
        # clobber it.
        if not self._is_free_tier_exhausted():
            self._trial_info_box.setVisible(False)
        if not (self._is_free_tier_prewall() or self._is_pro_low()):
            self._prewall_banner.setVisible(False)

    def _paywall_state(self) -> str:
        """"wall" | "prewall" | "normal" for the last confirmed free-tier
        balance. See core.paywall_state.classify_paywall_state for the
        threshold semantics (wall wins on overlap)."""
        if self._cached_used is None or self._cached_limit is None:
            return "normal"
        if not self._is_free_tier:
            return self._pro_paywall_state()
        if self._cached_limit <= 0:
            return "normal"
        remaining = max(0, self._cached_limit - self._cached_used)
        unit_cost = self._resolution_credit_costs.get(
            "1K", DEFAULT_RESOLUTION_CREDIT_COSTS["1K"]
        )
        return classify_paywall_state(remaining, unit_cost)

    def _pro_paywall_state(self) -> str:
        """"pro_low" | "normal" for a paid balance. Generation stays
        allowed either way: the state only drives the contact banner."""
        if self._cached_limit <= 0 or not pro_ceiling_enabled():
            return "normal"
        remaining = max(0, self._cached_limit - self._cached_used)
        if 0 < remaining <= pro_low_threshold(self._cached_limit):
            return "pro_low"
        return "normal"

    def _is_free_tier_exhausted(self) -> bool:
        return self._paywall_state() == "wall"

    def _is_free_tier_prewall(self) -> bool:
        return self._paywall_state() == "prewall"

    def set_generation_complete(self, layer_name: str, layer_id: str | None = None):
        """Show RESULT state with iteration options (retry / done)."""
        self._stop_progress_animation()
        self._progress_bar.setValue(100)
        self._progress_widget.setVisible(False)
        self._hide_status_box()

        # Clear any stale Vectorize suggestion from a previous generation;
        # the plugin re-arms it for this run only if the template carries
        # a vector_color in the catalog.
        self._vectorize_cta_section.setVisible(False)
        self._vectorize_cta_pending = None

        self._launch_section.setVisible(False)
        self._select_zone_section.setVisible(False)
        self._prompt_section.setVisible(False)
        self._generate_btn.setVisible(False)
        self.set_generate_block_reason(None)
        # The result section has its own Exit button, so suppress the prompt
        # row's Exit to avoid duplication.
        self._exit_btn.setVisible(False)
        self._generate_note_box.setVisible(False)

        # Keep the prompt that produced this result. A blank field asked the
        # user to retype what they had just typed, and 110 of the 214 people
        # whose first generation succeeded left within a minute of seeing it.
        # Running the same instruction again is now one click.
        self._result_prompt_input.setPlainText(self.get_prompt())
        self._update_result_generate_enabled()
        self._result_prompt_container.set_readonly(False)
        # Generation is done: clear the (now hidden) prompt container's readonly
        # flag too. set_generating(True) set it and the success path never calls
        # set_generating(False), so without this the prompt library stays in
        # view-only mode (browse_only) and template clicks are ignored.
        self._prompt_container.set_readonly(False)
        self._result_section.setVisible(True)
        # Single prompt screen: the version strip below it carries the base
        # choice, so there is no separate choice step to land on first. Bring the
        # strip back from the progress area (success skips set_generating(False)).
        self._result_prompt_widget.setVisible(True)
        self._place_version_strip("result")
        self._version_strip.set_readonly(False)
        self._refresh_resolution_triggers()

        self._place_reference_widget("result")

        self._saved_layer_id = layer_id
        escaped_name = html.escape(layer_name)
        if layer_id:
            link_html = (
                f'<a href="terralab:focus-layer" '
                f'style="color: {SUCCESS_TEXT}; text-decoration: underline;">'
                f'{escaped_name}</a>'
            )
        else:
            link_html = escaped_name
        self._layer_saved_label.setText(tr("Saved as {name}").format(name=link_html))
        self._layer_saved_label.setVisible(True)

        # Result screen (not idle): keep the first-steps guide banner hidden.
        self._update_first_steps_visibility()

    def _enter_iteration_state(self) -> None:
        """Show the RESULT/iterate UI (prompt + version strip above the Generate
        row) without the post-generation 'Saved as' line.

        Restoring a past generation means 'resume iterating on this image', so it
        lands in the same layout a fresh result does. This keeps the version
        strip in its proper home above the action row instead of falling below
        it (the old restore path used the in-flight 'generating' slot, which sits
        under the Generate/Exit row in the prompt state)."""
        self._stop_progress_animation()
        self._progress_widget.setVisible(False)
        self._hide_status_box()
        self._vectorize_cta_section.setVisible(False)
        self._vectorize_cta_pending = None
        self._launch_section.setVisible(False)
        self._select_zone_section.setVisible(False)
        self._prompt_section.setVisible(False)
        self._generate_btn.setVisible(False)
        self.set_generate_block_reason(None)
        self._exit_btn.setVisible(False)
        self._generate_note_box.setVisible(False)
        self._prompt_container.set_readonly(False)
        self._result_section.setVisible(True)
        self._result_prompt_widget.setVisible(True)
        self._result_prompt_container.set_readonly(False)
        self._place_version_strip("result")
        self._version_strip.set_readonly(False)
        self._place_reference_widget("result")
        # Nothing was just saved on restore: keep the success line hidden.
        self._layer_saved_label.setVisible(False)
        self._refresh_resolution_triggers()
        # Restore can jump straight here from the idle screen: hide the banner.
        self._update_first_steps_visibility()

    def restore_generation_context(
        self, prompt_text: str, template_id=None, template_name=None
    ) -> None:
        """Reproduce a past generation: enter the iterate state and fill its
        prompt. The plugin has already restored the zone."""
        self._active_template_id = str(template_id or "") or None
        self._active_template_name = str(template_name or "") or None
        self._enter_iteration_state()
        self._result_prompt_input.blockSignals(True)
        self._result_prompt_input.setPlainText(format_template_prompt(prompt_text or ""))
        self._result_prompt_input.blockSignals(False)
        self._result_prompt_input.moveCursor(QtC.CursorEnd)
        self._update_result_generate_enabled()
        self._adjust_result_prompt_height()
