"""GTK UI for the Docker Console plugin.

A single page with a host picker and six sections — Containers, Stats,
Images, Volumes, Networks, Compose — driven by :class:`DockerClient` over
the local or remote command API. Container logs open from each row as a
dialog (Follow / search / ring buffer). Every Docker call runs on a worker
thread and marshals back with ``ctx.run_on_ui_thread``; streamed/interactive
output (live logs, exec shell) opens a corresponding local or remote
terminal tab when requested.
"""

from __future__ import annotations

import logging
import os
import threading
from collections import deque
from gettext import gettext as _
from typing import Any, Callable, List, Optional

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import GLib, Gtk, Adw, Gdk, GdkPixbuf  # noqa: E402

_MARK_RESOURCE = "/io/github/mfat/sshpilot/icons/scalable/actions/docker-mark-ocean-blue.svg"

from .client import DockerClient  # noqa: E402
from .dialogs import DockerConsoleSettingsDialog  # noqa: E402
from . import widgets as w  # noqa: E402
from .tab_compose import ComposeTabMixin  # noqa: E402
from .tab_containers import ContainersTabMixin  # noqa: E402
from .tab_images import ImagesTabMixin  # noqa: E402
from .tab_listings import ListingsTabMixin  # noqa: E402
from .tab_logs import LogsTabMixin  # noqa: E402
from .tab_stats import StatsTabMixin  # noqa: E402

logger = logging.getLogger(__name__)

_DEFAULT_REFRESH_SECONDS = 10
_MIN_REFRESH_SECONDS = 2
_LOCAL_TARGET = "__local__"
# Status feed under the pulsing Docker mark: keep the last N stage messages.
_STATUS_MAX_LINES = 6


class _LocalDockerTarget:
    nickname = _LOCAL_TARGET
    display_name = "Local"
    host = "localhost"
    hostname = "localhost"
    username = os.environ.get("USER", "")
    protocol = "local"


class DockerConsolePage(
    ContainersTabMixin,
    LogsTabMixin,
    StatsTabMixin,
    ImagesTabMixin,
    ListingsTabMixin,
    ComposeTabMixin,
    Gtk.Box,
):
    """The Docker Console page, composed from one mixin per tab.

    The tab mixins are NOT independent units — they all operate on the same
    ``self`` and rely on shared state/methods defined here (``_client``,
    ``_run_async``, ``_on_action``, ``_confirm``, ``_toast``, the placeholder
    helpers, ``_containers``/``_cached_images``/``_busy_cids``/``_sudo_passwords``,
    …) and on each other (e.g. Containers' ``_follow_logs`` reads Logs' tail/
    timestamp widgets).
    They are a file-level split for readability, not reusable in isolation.
    """

    def __init__(self, ctx: Any, initial_host: Optional[str] = None) -> None:
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.ctx = ctx
        self._connections: List[Any] = []
        self._containers: List[dict] = []
        self._cached_images: List[dict] = []  # last images() result (for create dialog)
        self._logs_raw: str = ""              # last snapshot, for search/errors-only
        self._selected_cid: Optional[str] = None
        self._selected_name: Optional[str] = None
        self._selection_gen = 0
        self._events_stream = None
        self._events_gen = 0
        self._refresh_source: Optional[int] = None
        # In-flight guards so auto-refresh can't pile up overlapping SSH
        # calls when a host is slow.
        self._containers_busy = False
        self._stats_busy = False
        # "Loaded once for this host" flags: the pulsing logo (and the activity
        # revealer keyed on it) only shows on the first load after a host
        # switch — auto-refresh of an already-loaded view updates silently.
        self._containers_loaded = False
        self._stats_loaded = False
        # Bumped on every host switch; async loads carry the gen they started
        # with and a stale (previous-host) result is dropped on arrival.
        self._load_gen = 0
        # Host chosen at construction (e.g. the right-clicked connection) so the
        # single map-time load targets it directly — no default-vs-target race.
        self._initial_host = initial_host
        self._initial_loaded = False
        # Nickname whose SSH ControlMaster we're currently keeping warm (if any).
        self._selected_nick: Optional[str] = None
        # Auto-refresh paused (session-only) and container ids with an in-flight
        # lifecycle op (double-fire guard).
        self._paused = False
        self._busy_cids: set[str] = set()
        # Verified sudo passwords per host (session cache). Populated by the
        # access probe (keyring autofill) or the GUI prompt; fed to ``sudo -S``
        # on captured commands and auto-typed into the PTY for interactive ones.
        self._sudo_passwords: dict[str, str] = {}
        # True while the host runtime/sudo probe is in flight (or a sudo-password
        # dialog is open). Auto-refresh must not list containers until this
        # clears — otherwise sudo-on hosts race with ``sudo -n`` before the
        # session password is resolved and the first load fails until Refresh.
        self._probe_pending = False
        # Hosts whose SSH login-password prompt was cancelled.  Captured Docker
        # commands have no TTY, so polling must stay stopped until the user
        # explicitly retries instead of failing invisibly in the background.
        self._ssh_auth_blocked: set[str] = set()
        # Container list filter text (search box).
        self._container_query = ""
        # Guards the runtime dropdown against feedback loops when synced in code.
        self._syncing_runtime = False
        # Same guard for the sudo checkbox: programmatic set_active() must not
        # re-enter the probe/prompt flow via the "toggled" handler.
        self._syncing_sudo = False
        # Pulsing Docker-mark loading indicators (stopped on unmap).
        self._pulse_widgets: List[Gtk.Image] = []
        # Human-readable stage messages shown under the pulsing mark while a
        # host loads ("Connecting…", "Docker found", …). One shared feed,
        # mirrored into a status label inside every loading placeholder.
        self._status_lines: deque = deque(maxlen=_STATUS_MAX_LINES)
        self._status_labels: List[Gtk.Label] = []
        # Open destructive confirm (AlertDialog/MessageDialog); dismissed on host
        # change so a prune/remove can't run against the newly selected host.
        self._active_confirm_dialog: Optional[Any] = None
        self._confirm_gen = 0

        self.set_margin_top(12)
        self.set_margin_bottom(12)
        self.set_margin_start(12)
        self.set_margin_end(12)

        self.append(self._build_host_bar())

        self._stack = Gtk.Stack()
        self._stack.set_vexpand(True)
        switcher = Gtk.StackSwitcher(stack=self._stack)
        switcher.set_halign(Gtk.Align.CENTER)
        self.append(switcher)
        self.append(self._stack)

        self._stack.add_titled(self._build_containers_section(), "containers", "Containers")
        self._stack.add_titled(self._build_stats_section(), "stats", "Stats")
        self._stack.add_titled(self._build_images_section(), "images", "Images")
        self._stack.add_titled(self._build_volumes_section(), "volumes", "Volumes")
        self._stack.add_titled(self._build_networks_section(), "networks", "Networks")
        self._stack.add_titled(self._build_compose_section(), "compose", "Compose")
        # Container logs live in a dialog opened from each row (not a top tab).
        self._ensure_logs_window()
        # Lazy-load the newly shown tab (e.g. Images doesn't load until viewed).
        self._stack.connect("notify::visible-child", self._on_tab_switched)

        self.connect("map", self._on_map)
        self.connect("unmap", self._on_unmap)

    def _on_tab_switched(self, *_a) -> None:
        if self.get_mapped():
            self._refresh_visible()

    # ================================================================
    # infrastructure
    # ================================================================
    def _run_async(self, fn: Callable[[], Any], on_done: Callable[[Any, Optional[Exception]], None]) -> None:
        """Run ``fn`` on a worker thread; deliver ``(result, error)`` on the UI thread."""
        def worker() -> None:
            try:
                result, err = fn(), None
            except Exception as exc:  # noqa: BLE001 - reported to the UI
                result, err = None, exc
            self.ctx.run_on_ui_thread(on_done, result, err)

        threading.Thread(target=worker, daemon=True).start()

    # --- pulsing Docker-mark loading indicator -------------------------
    def _make_docker_mark(self, size: int = 56) -> Gtk.Image:
        """The full-colour Docker mark used (pulsing) as the loading indicator.

        Loaded as a texture straight from the resource SVG rather than via the
        icon theme: the theme path renders nothing for this non-square,
        non-symbolic brand icon, whereas a texture rasterises reliably."""
        img = Gtk.Image()
        img.set_pixel_size(size)
        img.set_halign(Gtk.Align.CENTER)
        img.set_valign(Gtk.Align.CENTER)
        try:
            pixbuf = GdkPixbuf.Pixbuf.new_from_resource_at_scale(
                _MARK_RESOURCE, size, size, True)
            img.set_from_paintable(Gdk.Texture.new_for_pixbuf(pixbuf))
        except Exception:
            img.set_from_icon_name("docker-mark-ocean-blue")  # fallback
        self._pulse_widgets.append(img)
        return img

    def _is_shutting_down(self) -> bool:
        """True once the toplevel started closing. Timer callbacks bail on this:
        touching widgets now (especially opacity, which renders via
        gsk_renderer_render_texture) draws against a disposed GSK renderer."""
        return bool(getattr(self.get_root(), "_is_quitting", False))

    def _pulse_start(self, img: Gtk.Image) -> None:
        """Pulse the mark's opacity with an Adw.TimedAnimation — frame-clock
        driven and it honours the global "reduce animations" setting (skipped
        animations jump to the *to* value, hence 1.0 there: a static full-
        opacity logo)."""
        if getattr(img, "_pulse_anim", None) is None:
            anim = Adw.TimedAnimation.new(
                img, 0.35, 1.0, 900,
                Adw.PropertyAnimationTarget.new(img, "opacity"))
            anim.set_easing(Adw.Easing.EASE_IN_OUT_SINE)
            anim.set_alternate(True)
            anim.set_repeat_count(0)  # endless
            img._pulse_anim = anim    # type: ignore[attr-defined]
            anim.play()

    def _pulse_stop(self, img: Gtk.Image) -> None:
        anim = getattr(img, "_pulse_anim", None)
        if anim is not None:
            anim.pause()
            img._pulse_anim = None  # type: ignore[attr-defined]
        img.set_opacity(1.0)

    # --- status feed under the pulsing mark ------------------------------
    def _status(self, message: str) -> None:
        """Append a human-readable stage message to the loading status feed
        (safe from worker threads). Messages reflect real pipeline stages and
        parsed command output — never cosmetic filler. Also logged at DEBUG so
        ``--verbose`` captures the same pipeline in sshpilot.log."""
        nick = self._current_nickname() or "?"
        logger.debug("docker-console[%s]: %s", nick, message)
        try:
            self.ctx.run_on_ui_thread(self._append_status, message)
        except Exception:
            logger.debug("status update failed: %s", message)

    def _append_status(self, message: str) -> None:
        self._status_lines.append(message)
        self._refresh_status_labels()

    def _clear_status(self) -> None:
        self._status_lines.clear()
        self._refresh_status_labels()

    def _refresh_status_labels(self) -> None:
        text = "\n".join(self._status_lines)
        for lbl in self._status_labels:
            lbl.set_text(text)

    # --- in-content loading placeholders --------------------------------
    def _mark_paintable(self) -> Optional[Gdk.Texture]:
        """The Docker mark rasterised as a texture (Adw.StatusPage paintable)."""
        if not hasattr(self, "_mark_texture_cache"):
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_resource_at_scale(
                    _MARK_RESOURCE, 256, 256, True)
                self._mark_texture_cache = Gdk.Texture.new_for_pixbuf(pixbuf)
            except Exception:
                self._mark_texture_cache = None
        return self._mark_texture_cache

    def _make_loading_placeholder(self, text: str = "Loading…") -> Adw.StatusPage:
        """Full-bleed opaque placeholder over a list/grid: an Adw.StatusPage.

        Must paint an opaque background: as a Gtk.Overlay child it otherwise
        floats its text over the window with no backing surface. Loading shows
        a spinner (Adw.SpinnerPaintable) with the live status feed; failures
        show the Docker mark, a human-readable summary as the title, and the
        raw output inline behind a "Show detailed log" toggle."""
        w.ensure_placeholder_css()
        sp = Adw.StatusPage()
        sp.add_css_class("docker-console-placeholder")
        sp.set_title(text)
        if self._mark_paintable() is not None:
            sp.set_paintable(self._mark_paintable())
        else:
            sp.set_icon_name("docker-mark-ocean-blue")

        # Live stage messages ("Connecting…", "Docker found", …) under the
        # title — a plain selectable label: no frame, no background.
        status = Gtk.Label()
        status.set_wrap(True)
        status.set_selectable(True)
        status.set_justify(Gtk.Justification.CENTER)
        status.set_halign(Gtk.Align.FILL)
        status.add_css_class("dim-label")
        status.set_visible(False)
        status.set_text("\n".join(self._status_lines))
        self._status_labels.append(status)

        # Raw log, inline below the human summary — hidden until the user
        # toggles "Show detailed log". Left-aligned text inside a Gtk.Frame so
        # it reads naturally without stretching to the window edge (the
        # StatusPage clamp caps the width; long output scrolls with the page).
        detail_lbl = Gtk.Label()
        detail_lbl.set_wrap(True)
        detail_lbl.set_selectable(True)
        detail_lbl.set_xalign(0)
        detail_lbl.add_css_class("dim-label")
        detail_lbl.add_css_class("monospace")
        detail_lbl.set_margin_top(10)
        detail_lbl.set_margin_bottom(10)
        detail_lbl.set_margin_start(12)
        detail_lbl.set_margin_end(12)
        detail_frame = Gtk.Frame()
        detail_frame.set_child(detail_lbl)
        detail_frame.set_visible(False)

        details = Gtk.ToggleButton(label=_("Show detailed log"))
        details.add_css_class("flat")
        details.set_halign(Gtk.Align.CENTER)
        details.set_visible(False)

        def _on_details_toggled(btn: Gtk.ToggleButton) -> None:
            show = btn.get_active()
            detail_frame.set_visible(show)
            btn.set_label(_("Hide detailed log") if show else _("Show detailed log"))

        details.connect("toggled", _on_details_toggled)

        child = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        child.append(status)
        child.append(details)
        child.append(detail_frame)
        sp.set_child(child)

        # Loading spinner (libadwaita ≥ 1.6); older versions keep the static mark.
        sp._spinner = (Adw.SpinnerPaintable.new(sp)  # type: ignore[attr-defined]
                       if hasattr(Adw, "SpinnerPaintable") else None)
        sp._status_lbl = status        # type: ignore[attr-defined]
        sp._details_btn = details      # type: ignore[attr-defined]
        sp._detail_lbl = detail_lbl    # type: ignore[attr-defined]
        sp._detail_frame = detail_frame  # type: ignore[attr-defined]
        sp._full_text = ""             # type: ignore[attr-defined]
        sp._idle_key = None            # type: ignore[attr-defined]
        return sp

    @staticmethod
    def _set_placeholder_targetable(ph: Adw.StatusPage, on: bool) -> None:
        """Toggle whether the placeholder (and its revealer wrapper, which
        spans the overlay even when unrevealed) receives pointer events."""
        ph.set_can_target(on)
        rev = getattr(ph, "_revealer", None)
        if rev is not None:
            rev.set_can_target(on)

    @staticmethod
    def _reveal_placeholder(ph: Adw.StatusPage, shown: bool) -> None:
        """Fade the placeholder in/out via its crossfade revealer (smooth
        transition to the loaded list) — falls back to plain visibility."""
        rev = getattr(ph, "_revealer", None)
        if rev is not None:
            rev.set_reveal_child(shown)
        else:
            ph.set_visible(shown)

    def _set_placeholder_loading(self, ph: Adw.StatusPage, text: str = "Loading…") -> None:
        self._reveal_placeholder(ph, True)
        # Targetable so the status feed is mouse-selectable; the opaque fill
        # hides the (empty or stale) list underneath anyway.
        self._set_placeholder_targetable(ph, True)
        ph._idle_key = None            # type: ignore[attr-defined]
        ph.set_title(text)
        ph.remove_css_class("docker-error")
        spinner = getattr(ph, "_spinner", None)
        if spinner is not None:
            ph.set_paintable(spinner)
        ph._status_lbl.set_visible(True)  # type: ignore[attr-defined]
        self._reset_placeholder_details(ph)

    def _reset_placeholder_details(self, ph: Adw.StatusPage, raw: str = "") -> None:
        """(Re)arm the inline detailed log: collapsed, button shown iff there
        is raw output beyond the human summary."""
        ph._full_text = raw             # type: ignore[attr-defined]
        ph._detail_lbl.set_text(        # type: ignore[attr-defined]
            w.truncate_message(raw, 4000)[0])
        ph._details_btn.set_active(False)   # type: ignore[attr-defined]
        ph._details_btn.set_visible(bool(raw))  # type: ignore[attr-defined]

    def _set_placeholder_idle(self, ph: Adw.StatusPage, text: str, *,
                              error: bool = False) -> None:
        full = (text or "").strip()
        # Auto-refresh repaints with the same outcome must be no-ops —
        # otherwise every tick collapses the expanded log / drops selection.
        key = (full, error)
        if ph.get_visible() and ph._idle_key == key:  # type: ignore[attr-defined]
            return
        ph._idle_key = key              # type: ignore[attr-defined]
        self._reveal_placeholder(ph, True)
        ph._status_lbl.set_visible(False)  # type: ignore[attr-defined]
        if self._mark_paintable() is not None:
            ph.set_paintable(self._mark_paintable())
        if error:
            # Human-readable summary parsed from the real output; the raw
            # log expands inline below it via "Show detailed log".
            ph.set_title(w.describe_docker_failure(full))
            ph.add_css_class("docker-error")
            self._reset_placeholder_details(
                ph, full if full != ph.get_title() else "")
            self._set_placeholder_targetable(ph, True)  # detailed log is mouse-selectable
        else:
            ph.set_title(w.truncate_message(full, w._PLACEHOLDER_MAX_CHARS)[0])
            ph.remove_css_class("docker-error")
            self._reset_placeholder_details(ph)
            self._set_placeholder_targetable(ph, False)

    def _hide_placeholder(self, ph: Adw.StatusPage) -> None:
        ph._idle_key = None             # type: ignore[attr-defined]
        ph._status_lbl.set_visible(False)  # type: ignore[attr-defined]
        self._reset_placeholder_details(ph)
        self._set_placeholder_targetable(ph, False)
        self._reveal_placeholder(ph, False)

    def _current_nickname(self) -> Optional[str]:
        return self._selected_nick

    def _runtime_for(self, nickname: str) -> str:
        return self.ctx.settings.get(f"runtime:{nickname}", "docker") or "docker"

    def _runtime_mode(self, nickname: Optional[str]) -> str:
        """User's runtime override for this host: 'Auto' | 'docker' | 'podman'."""
        if not nickname:
            return "Auto"
        return self.ctx.settings.get(f"runtime_mode:{nickname}", "Auto") or "Auto"

    def _runtime_mode_index(self, nickname: Optional[str]) -> int:
        try:
            return self._RUNTIME_MODES.index(self._runtime_mode(nickname))
        except (ValueError, AttributeError):
            return 0

    def _on_runtime_mode_changed(self, *_a) -> None:
        if self._syncing_runtime:
            return
        nick = self._current_nickname()
        if not nick:
            return
        mode = self._RUNTIME_MODES[self._runtime_drop.get_selected()]
        self.ctx.settings.set(f"runtime_mode:{nick}", mode)
        if mode != "Auto":
            self.ctx.settings.set(f"runtime:{nick}", mode)
        self._on_host_changed()  # re-probe / reload under the new runtime

    def _use_sudo_for(self, nickname: str) -> bool:
        return bool(self.ctx.settings.get(f"sudo:{nickname}", False))

    def _sudo_password_for(self, nickname: str) -> Optional[str]:
        """Verified sudo password for this host, if one is needed/known."""
        return self._sudo_passwords.get(nickname) if nickname else None

    def _connection_for(self, nickname: str) -> Optional[Any]:
        if nickname == _LOCAL_TARGET:
            return next(
                (target for target in self._connections
                 if getattr(target, "nickname", None) == _LOCAL_TARGET),
                _LocalDockerTarget(),
            )
        # Plugin list entries are ConnectionInfo snapshots and intentionally do
        # not expose SSH-only fields such as ``auth_method`` or ``password``.
        # Prefer the authoritative Connection object for authentication checks.
        try:
            connection = self.ctx.connection_manager.find_connection_by_nickname(
                nickname)
            if connection is not None:
                return connection
        except Exception:
            pass
        for connection in self._connections:
            if getattr(connection, "nickname", None) == nickname:
                return connection
        return None

    def _client(self) -> Optional[DockerClient]:
        nick = self._current_nickname()
        if not nick:
            return None
        return DockerClient(self._run_command_for(nick), nick, self._runtime_for(nick),
                            use_sudo=self._use_sudo_for(nick),
                            sudo_password=self._sudo_password_for(nick))

    @staticmethod
    def _is_local(nickname: Optional[str]) -> bool:
        return nickname == _LOCAL_TARGET

    def _run_command_for(self, nickname: str) -> Callable[..., Any]:
        if not self._is_local(nickname):
            return self.ctx.run_command

        def run_local(_nickname: str, command: str, **kwargs):
            return self.ctx.run_local_command(command, **kwargs)

        return run_local

    def _open_command_terminal(self, nick: str, cmd: str,
                               title: Optional[str] = None) -> bool:
        """Open a terminal tab for an interactive Docker command, arming the
        PTY auto-fill when this host's sudo needs a password (so the prompt the
        ``sudo -p`` sentinel produces is answered without the user typing it)."""
        pw = self._sudo_password_for(nick) if self._use_sudo_for(nick) else None
        if self._is_local(nick):
            return self.ctx.open_local_command_terminal(
                cmd, title=title,
                pty_prompt=DockerClient.SUDO_PROMPT if pw else None,
                pty_response=pw,
            )
        if pw:
            return self.ctx.open_command_terminal(
                nick, cmd, title=title,
                pty_prompt=DockerClient.SUDO_PROMPT, pty_response=pw)
        return self.ctx.open_command_terminal(nick, cmd, title=title)

    def _start_command_stream(
            self, nick: str, cmd: str, *,
            on_line: Callable[[str], None],
            on_done: Optional[Callable[[int], None]] = None,
            input: Optional[str] = None):
        """Start a local or remote line stream over the plugin streaming APIs."""
        if self._is_local(nick):
            return self.ctx.run_local_command_stream(
                cmd, on_line=on_line, on_done=on_done, input=input)
        return self.ctx.run_command_stream(
            nick, cmd, on_line=on_line, on_done=on_done, input=input)

    def _toast(self, message: str) -> None:
        try:
            self.ctx.ui.notify(w.truncate_toast(message))
        except Exception:
            logger.debug("notify failed: %s", message)

    def _window(self) -> Optional[Gtk.Window]:
        root = self.get_root()
        return root if isinstance(root, Gtk.Window) else None

    # ================================================================
    # host bar
    # ================================================================
    def _build_host_bar(self) -> Gtk.Widget:
        # CenterBox keeps the host picker visually centered; runtime/sudo/
        # refresh controls sit on the end without shifting the selector.
        bar = Gtk.CenterBox()

        self._connections = self._list_hosts()
        target = self._initial_host or self.ctx.settings.get("last_host", None)
        if target and any(c.nickname == target for c in self._connections):
            self._selected_nick = target
        elif self._connections:
            self._selected_nick = self._connections[0].nickname

        self._host_btn = Gtk.Button()
        # Suggested (accent) style so the host picker reads as the primary
        # control — same treatment as empty-state "Select host" elsewhere.
        # Must not be flat: flat hides the suggested-action background.
        self._host_btn.set_has_frame(True)
        self._host_btn.add_css_class("suggested-action")
        self._host_btn.set_tooltip_text(_("Choose Docker host"))
        host_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        host_icon = Gtk.Image.new_from_icon_name("computer-symbolic")
        host_icon.set_pixel_size(16)
        host_box.append(host_icon)
        self._host_label = Gtk.Label()
        host_box.append(self._host_label)
        caret = Gtk.Image.new_from_icon_name("pan-down-symbolic")
        caret.set_pixel_size(12)
        host_box.append(caret)
        self._host_btn.set_child(host_box)
        self._host_btn.connect("clicked", self._show_host_picker)
        self._update_host_button_label()
        bar.set_center_widget(self._host_btn)

        end = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)

        runtime_label = Gtk.Label(label=_("Runtime"))
        runtime_label.add_css_class("dim-label")
        runtime_label.add_css_class("caption")
        end.append(runtime_label)

        # Auto-detect (default) or force docker/podman per host.
        self._RUNTIME_MODES = ("Auto", "docker", "podman")
        self._runtime_drop = Gtk.DropDown.new_from_strings(list(self._RUNTIME_MODES))
        self._runtime_drop.set_tooltip_text(_("Container runtime (Auto detects docker/podman)"))
        self._runtime_drop.set_selected(self._runtime_mode_index(self._current_nickname()))
        self._runtime_drop.connect("notify::selected", self._on_runtime_mode_changed)
        end.append(self._runtime_drop)

        self._sudo_check = Gtk.CheckButton(label="sudo")
        self._sudo_check.set_tooltip_text(
            _("Run docker with sudo (for users not in the 'docker' group)")
        )
        nick0 = self._current_nickname()
        if nick0:
            self._sudo_check.set_active(self._use_sudo_for(nick0))
        self._sudo_check.connect("toggled", self._on_sudo_toggled)
        end.append(self._sudo_check)

        self._pause_btn = Gtk.ToggleButton(icon_name="media-playback-pause-symbolic")
        self._pause_btn.set_tooltip_text(_("Pause auto-refresh"))
        self._pause_btn.connect("toggled", self._on_pause_toggled)
        end.append(self._pause_btn)

        settings = Gtk.Button(icon_name="settings-symbolic")
        settings.set_tooltip_text(_("Docker Console settings"))
        settings.connect("clicked", lambda _b: self._open_settings())
        end.append(settings)

        refresh = Gtk.Button(icon_name="view-refresh-symbolic")
        refresh.set_tooltip_text(_("Refresh"))
        refresh.connect("clicked", lambda _b: self._manual_refresh())
        end.append(refresh)

        bar.set_end_widget(end)
        return bar

    def _set_selected_container(
            self, cid: Optional[str], name: Optional[str], *,
            source: str = "") -> None:
        """Shared selection used by Containers list and the logs dialog."""
        prev = self._selected_cid
        self._selected_cid = cid or None
        self._selected_name = name if cid else None
        if prev != self._selected_cid:
            self._selection_gen += 1
            # Restart in-pane follow when the target changes while the logs
            # dialog is open (except when the dialog itself owns the change).
            if source != "logs":
                logs_open = getattr(self, "_logs_window", None) is not None \
                    and self._logs_window.get_visible()
                if logs_open and getattr(self, "_logs_follow", None) is not None \
                        and self._logs_follow.get_active() and self._selected_cid:
                    self._update_logs_target_label()
                    self._start_logs_follow()
                elif getattr(self, "_logs_follow", None) is not None \
                        and self._logs_follow.get_active() and not self._selected_cid:
                    self._stop_logs_follow()
                elif self._selected_cid:
                    self._update_logs_target_label()

    def _stop_events_stream(self) -> None:
        self._events_gen += 1
        handle = self._events_stream
        self._events_stream = None
        if handle is not None:
            try:
                handle.stop()
            except Exception:  # noqa: BLE001
                pass

    def _start_events_stream(self) -> None:
        """Watch ``docker events`` to patch the container list without full polls."""
        self._stop_events_stream()
        client = self._client()
        nick = self._current_nickname()
        if client is None or not nick or not self.get_mapped():
            return
        gen = self._events_gen
        cmd = client.events_command()
        stdin = None
        if client._password_mode:  # noqa: SLF001
            stdin = f"{client.sudo_password}\n"

        def on_line(line: str) -> None:
            if gen != self._events_gen:
                return
            self._on_docker_event_line(line)

        def on_done(_code: int) -> None:
            if gen != self._events_gen:
                return
            self._events_stream = None

        self._events_stream = self._start_command_stream(
            nick, cmd, on_line=on_line, on_done=on_done, input=stdin)

    def _on_docker_event_line(self, line: str) -> None:
        import json as _json
        text = (line or "").strip()
        if not text:
            return
        try:
            event = _json.loads(text)
        except _json.JSONDecodeError:
            return
        if event.get("Type") not in (None, "container"):
            return
        status = (event.get("status") or event.get("Action") or "").lower()
        attrs = (event.get("Actor") or {}).get("Attributes") or {}
        name = attrs.get("name") or ""
        if not name and isinstance(event.get("Actor"), dict):
            name = event["Actor"].get("ID") or ""
        if not name:
            return
        if status in ("create", "destroy", "die", "kill", "oom", "rename",
                      "start", "stop", "pause", "unpause", "restart"):
            if not self._containers_busy:
                self._refresh_containers()

    def _on_pause_toggled(self, btn: Gtk.ToggleButton) -> None:
        self._paused = btn.get_active()
        btn.set_tooltip_text(_("Resume auto-refresh") if self._paused
                             else _("Pause auto-refresh"))

    # --- auto-refresh interval / timer ---------------------------------
    def _refresh_interval(self) -> int:
        try:
            val = int(self.ctx.settings.get("refresh_interval", _DEFAULT_REFRESH_SECONDS))
        except (TypeError, ValueError):
            val = _DEFAULT_REFRESH_SECONDS
        return max(_MIN_REFRESH_SECONDS, val)

    def _start_timer(self) -> None:
        if self._refresh_source is None:
            self._refresh_source = GLib.timeout_add_seconds(
                self._refresh_interval(), self._tick)

    def _restart_timer(self) -> None:
        if self._refresh_source is not None:
            GLib.source_remove(self._refresh_source)
            self._refresh_source = None
        if self.get_mapped():
            self._start_timer()

    def _on_refresh_interval_changed(self, _seconds: int) -> None:
        self.ctx.settings.set("refresh_interval", int(_seconds))
        self._restart_timer()

    def _update_host_button_label(self) -> None:
        nick = self._selected_nick
        if nick:
            target = next(
                (item for item in self._connections
                 if getattr(item, "nickname", None) == nick),
                None,
            )
            self._host_label.set_label(
                getattr(target, "display_name", None) or nick)
            self._host_btn.set_sensitive(True)
        else:
            self._host_label.set_label("(no connections)")
            self._host_btn.set_sensitive(False)

    def _show_host_picker(self, _btn: Gtk.Button) -> None:
        from ....host_picker import show_host_picker  # noqa: PLC0415

        self._connections = self._list_hosts()
        if not self._connections:
            self._toast("No SSH connections")
            return
        show_host_picker(
            self._window(),
            self._host_btn,
            self._on_host_picked,
            toast=self._toast,
            connections=self._connections,
        )

    def _on_host_picked(self, conn: Any) -> None:
        nick = getattr(conn, "nickname", None)
        if not nick or nick == self._selected_nick:
            return
        self._selected_nick = nick
        self._update_host_button_label()
        self._on_host_changed()

    def _set_sudo_check(self, active: bool) -> None:
        """Set the sudo checkbox programmatically without firing ``_on_sudo_toggled``."""
        self._syncing_sudo = True
        try:
            self._sudo_check.set_active(bool(active))
        finally:
            self._syncing_sudo = False

    def _on_sudo_toggled(self, check: Gtk.CheckButton) -> None:
        if self._syncing_sudo:
            return
        nick = self._current_nickname()
        if not nick:
            self._refresh_visible()
            return
        active = bool(check.get_active())
        self.ctx.settings.set(f"sudo:{nick}", active)
        if active:
            # Re-probe so a password-required host prompts via the GUI dialog
            # instead of silently failing every captured `sudo -n` command.
            self._on_host_changed()
        else:
            self._sudo_passwords.pop(nick, None)
            self._refresh_visible()

    def _open_settings(self) -> None:
        DockerConsoleSettingsDialog(
            self._window(),
            refresh_interval=self._refresh_interval(),
            on_refresh_interval_changed=self._on_refresh_interval_changed,
            log_tail=self._log_tail_setting(),
            on_log_tail_changed=self._on_log_tail_changed,
            max_log_lines=self._max_log_lines_setting(),
            on_max_log_lines_changed=self._on_max_log_lines_changed,
        ).present()

    def _on_log_tail_changed(self, value: int) -> None:
        self.ctx.settings.set("log_tail", int(value))
        if hasattr(self, "_tail_spin"):
            self._tail_spin.set_value(int(value))

    def _on_max_log_lines_changed(self, value: int) -> None:
        self.ctx.settings.set("max_log_lines", int(value))
        if hasattr(self, "_resize_logs_ring"):
            self._resize_logs_ring()

    def _list_hosts(self) -> List[Any]:
        try:
            conns = self.ctx.list_connections()
        except Exception:
            conns = []
        remote = [
            connection for connection in conns
            if getattr(connection, "protocol", "ssh") in ("ssh", "", None)
        ]
        return [_LocalDockerTarget(), *remote]

    def _ensure_ssh_password(self, nickname: str) -> bool:
        """Collect a missing SSH login password before captured Docker calls.

        ``ctx.run_command`` is deliberately non-interactive, so password-mode
        connections cannot fall back to the terminal prompt used by a normal
        SSH tab. Prompt here on the GTK thread and store the value through the
        protected daemon API; the shared native auth resolver then feeds it
        through the normal sshpass FIFO path.
        """
        if self._is_local(nickname):
            self._ssh_auth_blocked.discard(nickname)
            return True

        client = self.ctx.daemon_client()
        if client is None:
            self._ssh_auth_blocked.add(nickname)
            return False
        try:
            from ....api.capabilities import Capability
            if not client.get_capabilities().supports(
                Capability.CONNECTIONS_SECRETS_STATUS_READ
            ) or not client.get_capabilities().supports(
                Capability.CONNECTIONS_SECRETS_WRITE
            ):
                raise RuntimeError("daemon secret capability is unavailable")
            summary = next(
                (item for item in client.list_connections()
                 if getattr(item, "nickname", None) == nickname),
                None,
            )
            if summary is None:
                raise RuntimeError(f"connection {nickname!r} is unavailable")
            details = client.get_connection(summary.id)
            if getattr(details, "authentication_method", None).value != "password":
                self._ssh_auth_blocked.discard(nickname)
                return True
            if client.has_connection_password(details.id):
                self._ssh_auth_blocked.discard(nickname)
                return True
        except Exception:
            logger.debug("Docker Console daemon password status failed", exc_info=True)
            self._ssh_auth_blocked.add(nickname)
            return False

        from ....window import show_ssh_password_dialog

        persistent = {"stored": False, "failed": False}

        def _store_if_consented(value: str) -> None:
            try:
                from ....api.models.connections import StoreConnectionPasswordRequest

                stored = client.store_connection_password(
                    StoreConnectionPasswordRequest(
                        connection_id=details.id,
                        password=value,
                    )
                )
                if not stored:
                    raise RuntimeError("daemon rejected persistent SSH password")
            except Exception:
                persistent["failed"] = True
                logger.debug(
                    "Docker Console persistent password storage failed",
                    exc_info=True,
                )
                return
            persistent["stored"] = True

        password = show_ssh_password_dialog(
            from_widget=self,
            display_name=getattr(details, "display_name", nickname),
            host=getattr(details, "hostname", None) or getattr(details, "host", None),
            username=getattr(details, "username", None),
            heading=_("SSH password required"),
            body=_("“{name}” uses password authentication.\n\n"
                   "Enter the SSH login password to open Docker Console:").format(name=nickname),
            allow_store=True,
            on_store=_store_if_consented,
        )
        if not password:
            self._ssh_auth_blocked.add(nickname)
            return False
        if persistent["failed"]:
            self._ssh_auth_blocked.add(nickname)
            return False
        if persistent["stored"]:
            self._ssh_auth_blocked.discard(nickname)
            return True
        try:
            from ....api.models.connections import SetSessionConnectionPasswordRequest
            if not client.set_session_connection_password(
                SetSessionConnectionPasswordRequest(connection_id=details.id),
                bytearray(password.encode("utf-8")),
            ):
                raise RuntimeError("daemon rejected the session SSH password")
        except Exception:
            logger.debug("Docker Console daemon session password failed", exc_info=True)
            self._ssh_auth_blocked.add(nickname)
            return False
        self._ssh_auth_blocked.discard(nickname)
        return True

    def _clear_daemon_session_password(self, nickname: str) -> None:
        """Drop a failed temporary SSH credential without deleting persistence."""
        try:
            client = self.ctx.daemon_client()
            if client is None:
                return
            from ....api.models.connections import SetSessionConnectionPasswordRequest
            summary = next(
                (item for item in client.list_connections()
                 if getattr(item, "nickname", None) == nickname),
                None,
            )
            if summary is not None:
                client.clear_session_connection_password(
                    SetSessionConnectionPasswordRequest(connection_id=summary.id)
                )
        except Exception:
            logger.debug("Docker Console could not clear temporary SSH credential")

    def _show_ssh_auth_required(self) -> None:
        for placeholder in (
            self._containers_placeholder,
            self._images_placeholder,
            self._volumes_placeholder,
            self._networks_placeholder,
            self._compose_placeholder,
        ):
            self._set_placeholder_idle(
                placeholder, "SSH password required — click Refresh to retry")
        self._pulse_stop(self._stats_pulse)
        self._stats_pulse.set_visible(False)
        self._toast("SSH password required to open Docker Console.")

    def _manual_refresh(self) -> None:
        # The Refresh button always re-runs the full host load (probe →
        # pulsing placeholder → status feed → fresh lists). A silent
        # _refresh_visible() would be invisible after a failure: the loaded
        # flags suppress the pulse and an unchanged error looks like a no-op.
        self._on_host_changed()

    def _on_host_changed(self, *_a) -> None:
        nick = self._current_nickname()
        if not nick:
            return
        logger.debug("docker-console: loading host %r (local=%s)",
                     nick, self._is_local(nick))
        self._dismiss_active_confirm()
        self.ctx.settings.set("last_host", nick)
        self._close_logs_window()
        self._stop_logs_follow()
        self._stop_events_stream()
        self._set_selected_container(None, None, source="host")
        if hasattr(self, "_logs_lines"):
            self._logs_lines.clear()
            self._logs_raw = ""
        self._set_sudo_check(self._use_sudo_for(nick))
        self._syncing_runtime = True
        self._runtime_drop.set_selected(self._runtime_mode_index(nick))
        self._syncing_runtime = False
        # Invalidate any in-flight probe/list for the previous host before the
        # SSH prompt (which can block) so a late ``done`` cannot clear the new
        # host's probe gate or paint stale rows.
        self._load_gen += 1
        self._probe_pending = False
        self._containers_busy = False
        self._stats_busy = False
        self._containers_loaded = False
        self._stats_loaded = False
        if not self._ensure_ssh_password(nick):
            self._show_ssh_auth_required()
            return
        load_gen = self._load_gen

        # Fresh status feed for this host; the first real stage is the SSH
        # connection the probe's first command will open.
        self._clear_status()
        if not self._is_local(nick):
            self._status(f"Connecting to {nick}…")

        # Drop the previous host's rows and show a spinner immediately (clearing
        # makes each list's placeholder visible) for the whole probe + load.
        self._containers = []
        for lst, ph, text in (
            (self._containers_list, self._containers_placeholder, "Loading containers…"),
            (self._images_list, self._images_placeholder, "Loading images…"),
            (self._compose_list, self._compose_placeholder, "Loading compose projects…"),
        ):
            w.clear_listbox(lst)
            self._set_placeholder_loading(ph, text)
        w.clear_grid(self._stats_grid)
        self._stats_pulse.set_visible(True)
        self._pulse_start(self._stats_pulse)

        rc = self._run_command_for(nick)

        mode = self._runtime_mode(nick)

        # Detect docker vs podman, then probe access: if a plain `ps` is denied by
        # the daemon socket but `sudo -n ps` works, switch this host to sudo.
        # If even `sudo -n` is denied (password required), try a keyring-stored
        # sudo password; if none works, ask the user (handled in ``done``).
        # An explicit runtime override skips auto-detect.
        want_sudo = self._use_sudo_for(nick)
        # Snapshot session password on the UI thread so the worker probe does not
        # read ``_sudo_passwords`` while another callback may be writing it.
        session_pw = self._sudo_passwords.get(nick)

        # Each stage reports a status line under the pulsing mark — real
        # pipeline steps and parsed command output, not cosmetic filler.
        def probe():
            if mode in ("docker", "podman"):
                runtime = mode
                self._status(f"Using {runtime} (set for this host)")
            else:
                self._status("Detecting container runtime…")
                detected = DockerClient(rc, nick).detect_runtime()
                if detected:
                    self._status(f"{detected.capitalize()} found")
                else:
                    self._status("Neither Docker nor Podman found on this host")
                runtime = detected or "docker"
            # The user explicitly asked for sudo: don't second-guess it on a plain
            # `ps` — the only open question is whether sudo needs a password.
            if want_sudo:
                return (runtime, *self._resolve_sudo(
                    rc, nick, runtime, session_pw=session_pw))
            # sudo not requested: try plain, and fall back to sudo only on a
            # docker-socket *permission* error (user not in the 'docker' group).
            self._status(f"Checking {runtime} access…")
            plain = DockerClient(rc, nick, runtime, use_sudo=False).ping()
            if getattr(plain, "exit_code", 0) == 0:
                self._status(f"{runtime.capitalize()} is reachable")
                return runtime, False, None, None
            text = (getattr(plain, "stderr", "") or "") + (getattr(plain, "stdout", "") or "")
            if DockerClient.is_permission_error(text):
                self._status("Permission denied — the user may not be in the docker group")
                return (runtime, *self._resolve_sudo(
                    rc, nick, runtime, session_pw=session_pw))
            self._status(w.describe_docker_failure(text))
            return runtime, False, None, None  # other error — refresh surfaces it

        def done(result, _err: Optional[Exception]) -> None:
            if load_gen != self._load_gen:
                return  # superseded by a newer host load
            if not result:
                if _err is not None:
                    # The probe itself failed (e.g. SSH could not connect).
                    self._clear_daemon_session_password(nick)
                    self._status(w.describe_docker_failure(str(_err)))
                self._finish_probe_and_refresh()
                return
            runtime, use_sudo, status, pw = result
            self.ctx.settings.set(f"runtime:{nick}", runtime)
            if status == "not_sudoers":
                self._disable_sudo(nick)
                self._toast("Your user isn't allowed to run Docker with sudo on this host.")
                self._finish_probe_and_refresh()
                return
            # A password is required and we don't have a working one — keep sudo
            # on and ask the user (handles both the explicit opt-in and the
            # permission-denied auto-enable). Stay probe-pending until the
            # dialog finishes so auto-refresh cannot race with ``sudo -n``.
            if status == "needs_password":
                self.ctx.settings.set(f"sudo:{nick}", True)
                self._set_sudo_check(True)
                self._prompt_for_sudo_password(nick, runtime, load_gen=load_gen)
                return
            if use_sudo:
                self.ctx.settings.set(f"sudo:{nick}", True)
                self._set_sudo_check(True)
                if status == "password" and pw is not None:
                    self._sudo_passwords[nick] = pw
            self._finish_probe_and_refresh()

        # Block auto-refresh until the probe (and any sudo-password prompt) has
        # finished — the map-time timer would otherwise list with ``sudo -n``
        # before a required password is resolved.
        self._probe_pending = True
        self._run_async(probe, done)

    def _resolve_sudo(self, rc: Any, nick: str, runtime: str, *,
                      session_pw: Optional[str] = None):
        """Decide how sudo authenticates on this host (runs on a worker thread):
        ``(use_sudo, status, password)`` — passwordless, a verified cached/stored
        password, ``needs_password`` (prompt the user), or ``not_sudoers``."""
        self._status("Checking sudo access…")
        sudo = DockerClient(rc, nick, runtime, use_sudo=True).ping()  # sudo -n
        if getattr(sudo, "exit_code", 1) == 0:
            self._status("Sudo works without a password")
            return True, None, None  # passwordless sudo
        sudo_text = ((getattr(sudo, "stderr", "") or "")
                     + (getattr(sudo, "stdout", "") or ""))
        if DockerClient.is_sudo_denied_error(sudo_text):
            self._status("This user isn't allowed to use sudo on this host")
            return True, "not_sudoers", None
        # Sudo credentials are session-only plugin state. The plugin never
        # reads or writes a frontend secret store; a daemon operation may still
        # use the verified value for the current command/session.
        pw = session_pw
        if pw and self._verify_sudo(rc, nick, runtime, pw):
            self._status("Sudo password accepted")
            return True, "password", pw
        self._status("Sudo needs a password")
        return True, "needs_password", None

    @staticmethod
    def _check_sudo(rc: Any, nick: str, runtime: str, password: str):
        """Single ``sudo -S`` ping → ``(ok, status)``: ``status`` is ``None`` when
        it succeeds, else ``not_sudoers`` or ``wrong_password``. One ping so a
        wrong password isn't tried twice against PAM faillock."""
        chk = DockerClient(rc, nick, runtime, use_sudo=True,
                           sudo_password=password).ping()
        if getattr(chk, "exit_code", 1) == 0:
            return True, None
        text = ((getattr(chk, "stderr", "") or "")
                + (getattr(chk, "stdout", "") or ""))
        if DockerClient.is_sudo_denied_error(text):
            return False, "not_sudoers"
        return False, "wrong_password"

    @staticmethod
    def _verify_sudo(rc: Any, nick: str, runtime: str, password: str) -> bool:
        """True if ``password`` lets ``sudo -S docker ps`` succeed on the host."""
        ok, _unused = DockerConsolePage._check_sudo(rc, nick, runtime, password)
        return ok

    def _prompt_for_sudo_password(self, nick: str, runtime: str,
                                  *, load_gen: Optional[int] = None) -> None:
        """Ask for the host's sudo password (GUI), verify it, then enable sudo.
        Runs on the UI thread; verification is offloaded to a worker."""
        from ....window import show_ssh_password_dialog
        password = show_ssh_password_dialog(
            from_widget=self,
            display_name=nick,
            heading=_("Sudo password required"),
            body=_("“{name}” needs a sudo password to access Docker.\n\n"
                   "Enter your sudo password:").format(name=nick),
            allow_store=False,
        )
        if load_gen is not None and load_gen != self._load_gen:
            return  # host changed while the dialog was open
        if not password:
            # Cancelled — back off sudo so a host that works without it (or just
            # shows the real permission error) isn't stuck looping on `sudo -n`.
            self._disable_sudo(nick)
            self._finish_probe_and_refresh()
            return

        rc = self._run_command_for(nick)

        def verify():
            ok, kind = self._check_sudo(rc, nick, runtime, password)
            return "ok" if ok else kind

        def after(result: Optional[str], _err: Optional[Exception]) -> None:
            if load_gen is not None and load_gen != self._load_gen:
                return
            if result == "ok":
                self._sudo_passwords[nick] = password
                self.ctx.settings.set(f"sudo:{nick}", True)
                self._set_sudo_check(True)
            elif result == "not_sudoers":
                self._disable_sudo(nick)
                self._toast("Your user isn't allowed to run Docker with sudo on this host.")
            else:
                # Wrong password — drop the session value and back off sudo.
                self._sudo_passwords.pop(nick, None)
                self._disable_sudo(nick)
                self._toast("Sudo password incorrect.")
            self._finish_probe_and_refresh()

        self._run_async(verify, after)

    def _finish_probe_and_refresh(self) -> None:
        """Clear the probe gate and load the visible tab (password now resolved)."""
        self._probe_pending = False
        self._refresh_visible()
        self._start_events_stream()

    def _disable_sudo(self, nick: str) -> None:
        """Turn sudo off for a host and drop its cached password (used when the
        user cancels or the password is wrong)."""
        self._sudo_passwords.pop(nick, None)
        self.ctx.settings.set(f"sudo:{nick}", False)
        self._set_sudo_check(False)

    # ================================================================
    # auto-refresh lifecycle
    # ================================================================
    def _on_map(self, *_a) -> None:
        # Do the full host load (runtime/sudo probe → pulse → refresh) exactly
        # once, on first map — the single load both open paths rely on. Later
        # maps just refresh the visible view.
        if not self._initial_loaded:
            self._initial_loaded = True
            self._on_host_changed()
        else:
            self._refresh_visible()
        self._start_timer()

    def _on_unmap(self, *_a) -> None:
        self._dismiss_active_confirm()
        self._close_logs_window()
        self._stop_logs_follow()
        self._stop_events_stream()
        if self._refresh_source is not None:
            GLib.source_remove(self._refresh_source)
            self._refresh_source = None
        for img in self._pulse_widgets:
            self._pulse_stop(img)

    def _tick(self) -> bool:
        if self._is_shutting_down():
            self._refresh_source = None
            return False
        if self._paused:
            return True  # keep the timer; just skip this round
        if self._probe_pending:
            return True  # probe / sudo prompt still resolving auth
        if self._current_nickname() in self._ssh_auth_blocked:
            return True
        # Only auto-refresh the live views; images is manual. In-pane Follow
        # owns the Logs buffer when active (no snapshot poll).
        name = self._stack.get_visible_child_name()
        if name == "containers":
            self._refresh_containers()
        elif name == "stats":
            self._refresh_stats()
        return True  # keep ticking

    def _refresh_visible(self) -> None:
        if self._probe_pending:
            return
        name = self._stack.get_visible_child_name()
        if name == "containers":
            self._refresh_containers()
        elif name == "stats":
            self._refresh_stats()
        elif name == "images":
            self._refresh_images()
        elif name == "volumes":
            self._refresh_volumes()
        elif name == "networks":
            self._refresh_networks()
        elif name == "compose":
            self._refresh_compose()


    # ================================================================
    # shared helpers
    # ================================================================
    def _on_action(self, label: str, res: Any, err: Optional[Exception],
                   refresh: Callable[[], None]) -> None:
        if err is not None or (res is not None and getattr(res, "exit_code", 0) != 0):
            detail = err or (getattr(res, "stderr", "") or "").strip()
            self._toast(f"{label} failed: {detail}")
        else:
            self._toast(f"{label} done")
        refresh()

    def _dismiss_active_confirm(self) -> None:
        """Close any open destructive confirm and invalidate its response handler.

        Called when the host changes or the page is hidden so a pending prune /
        remove cannot run against a different host than the one shown when the
        dialog was opened."""
        self._confirm_gen += 1
        dlg = self._active_confirm_dialog
        self._active_confirm_dialog = None
        if dlg is None:
            return
        try:
            dlg.close()
        except Exception:
            logger.debug("dismiss confirm dialog failed", exc_info=True)

    def _confirm(self, *, heading: str, body: str, destructive_label: str,
                 on_confirm: Callable[[bool], None],
                 force_label: Optional[str] = None,
                 confirm_word: Optional[str] = None) -> None:
        """Destructive-action confirm. ``force_label`` adds a Force checkbox;
        ``confirm_word`` (mutually exclusive) requires the user to type the word
        before the destructive button enables — for high-blast-radius prunes."""
        self._dismiss_active_confirm()
        gen = self._confirm_gen
        dialog = w.build_alert(heading, body)
        self._active_confirm_dialog = dialog
        force_check: Optional[Gtk.CheckButton] = None
        if force_label:
            force_check = Gtk.CheckButton(label=force_label)
            dialog.set_extra_child(force_check)
        elif confirm_word:
            entry = Gtk.Entry(placeholder_text=_("Type {word} to confirm").format(word=confirm_word))
            dialog.set_extra_child(entry)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("ok", destructive_label)
        dialog.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.set_default_response("cancel")
        dialog.set_close_response("cancel")
        if confirm_word and not force_label:
            dialog.set_response_enabled("ok", False)
            entry.connect("changed", lambda e: dialog.set_response_enabled(
                "ok", e.get_text().strip() == confirm_word))

        def on_response(_d: Any, response: str) -> None:
            if gen != self._confirm_gen:
                return  # host changed or a newer confirm superseded this one
            self._active_confirm_dialog = None
            if response == "ok":
                on_confirm(bool(force_check.get_active()) if force_check else False)

        dialog.connect("response", on_response)
        w.present_alert(dialog, self._window())
