# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Repeater management view — shown in the content panel for repeater contacts."""

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
import contextlib
import copy

from gi.repository import Adw, Gio, GLib, Gtk
from meshy.models import Contact
from meshy.protocol import (
    build_send_cli_command,
)


def _fmt_uptime(secs):
    d, secs = divmod(secs, 86400)
    h, secs = divmod(secs, 3600)
    m, secs = divmod(secs, 60)
    parts = []
    if d:
        parts.append(_("{d}d").format(d=d))
    if h:
        parts.append(_("{h}h").format(h=h))
    if m:
        parts.append(_("{m}m").format(m=m))
    parts.append(_("{secs}s").format(secs=secs))
    return " ".join(parts)


def _fmt_ago(secs):
    if secs < 60:
        return _("{}s ago").format(secs)
    if secs < 3600:
        return _("{}m ago").format(secs // 60)
    if secs < 86400:
        return _("{}h ago").format(secs // 3600)
    return _("{}d ago").format(secs // 86400)


def _battery_pct(mv):
    if mv >= 4100:
        return 100
    if mv <= 3300:
        return 0
    return int((mv - 3300) / 8)


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/repeater-view.ui")
class RepeaterView(Gtk.Box):
    __gtype_name__ = "MeshyRepeaterView"

    REQUEST_TIMEOUT_MS = 15000
    FETCH_CMD_TIMEOUT_MS = 8000

    # Main
    _stack = Gtk.Template.Child("stack")

    # Status page
    _st_clock = Gtk.Template.Child("st_clock")
    _clock_drift_label = Gtk.Template.Child("clock_drift_label")
    _clock_sync_btn = Gtk.Template.Child("clock_sync_btn")
    _clock_reset_btn = Gtk.Template.Child("clock_reset_btn")
    _st_battery = Gtk.Template.Child("st_battery")
    _st_uptime = Gtk.Template.Child("st_uptime")
    _st_queue = Gtk.Template.Child("st_queue")
    _st_errors = Gtk.Template.Child("st_errors")
    _st_rssi = Gtk.Template.Child("st_rssi")
    _st_snr = Gtk.Template.Child("st_snr")
    _st_noise = Gtk.Template.Child("st_noise")
    _st_tx_air = Gtk.Template.Child("st_tx_air")
    _st_rx_air = Gtk.Template.Child("st_rx_air")
    _st_channel_util = Gtk.Template.Child("st_channel_util")
    _st_duty_cycle = Gtk.Template.Child("st_duty_cycle")
    _st_sent = Gtk.Template.Child("st_sent")
    _st_recv = Gtk.Template.Child("st_recv")
    _st_recv_errors = Gtk.Template.Child("st_recv_errors")
    _st_flood = Gtk.Template.Child("st_flood")
    _st_direct = Gtk.Template.Child("st_direct")
    _st_dups = Gtk.Template.Child("st_dups")
    _status_refresh_btn = Gtk.Template.Child("status_refresh_btn")

    # CLI page
    _cli_quick_box = Gtk.Template.Child("cli_quick_box")
    _cli_view = Gtk.Template.Child("cli_view")
    _cli_entry = Gtk.Template.Child("cli_entry")
    _cli_send_btn = Gtk.Template.Child("cli_send_btn")

    # Neighbors page
    _nb_spinner = Gtk.Template.Child("nb_spinner")
    _nb_status = Gtk.Template.Child("nb_status")
    _nb_list = Gtk.Template.Child("nb_list")
    _nb_refresh_btn = Gtk.Template.Child("nb_refresh_btn")
    _nb_load_more = Gtk.Template.Child("nb_load_more")
    _nb_map_btn = Gtk.Template.Child("nb_map_btn")

    # Access page
    _acl_spinner = Gtk.Template.Child("acl_spinner")
    _acl_status = Gtk.Template.Child("acl_status")
    _acl_list = Gtk.Template.Child("acl_list")
    _acl_refresh_btn = Gtk.Template.Child("acl_refresh_btn")
    _acl_add_btn = Gtk.Template.Child("acl_add_btn")

    # Regions page
    _reg_loading_box = Gtk.Template.Child("reg_loading_box")
    _reg_spinner = Gtk.Template.Child("reg_spinner")
    _reg_status = Gtk.Template.Child("reg_status")
    _reg_retry_btn = Gtk.Template.Child("reg_retry_btn")
    _reg_content_box = Gtk.Template.Child("reg_content_box")
    _reg_default_entry = Gtk.Template.Child("reg_default_entry")
    _reg_list = Gtk.Template.Child("reg_list")
    _reg_add_btn = Gtk.Template.Child("reg_add_btn")
    _reg_apply_btn = Gtk.Template.Child("reg_apply_btn")

    # Settings page – fetch spinners
    _basic_fetch_spinner = Gtk.Template.Child("basic_fetch_spinner")
    _loc_fetch_spinner = Gtk.Template.Child("loc_fetch_spinner")
    _radio_fetch_spinner = Gtk.Template.Child("radio_fetch_spinner")
    _advert_fetch_spinner = Gtk.Template.Child("advert_fetch_spinner")

    # Settings page
    _set_name = Gtk.Template.Child("set_name")
    _set_repeat = Gtk.Template.Child("set_repeat")
    _set_admin_pwd = Gtk.Template.Child("set_admin_pwd")
    _admin_pwd_save_btn = Gtk.Template.Child("admin_pwd_save_btn")
    _set_guest_pwd = Gtk.Template.Child("set_guest_pwd")
    _guest_pwd_save_btn = Gtk.Template.Child("guest_pwd_save_btn")
    _set_lat = Gtk.Template.Child("set_lat")
    _set_lon = Gtk.Template.Child("set_lon")
    _set_freq = Gtk.Template.Child("set_freq")
    _set_bw = Gtk.Template.Child("set_bw")
    _set_sf = Gtk.Template.Child("set_sf")
    _set_cr = Gtk.Template.Child("set_cr")
    _set_tx = Gtk.Template.Child("set_tx")
    _set_advert_int = Gtk.Template.Child("set_advert_int")
    _set_flood_int = Gtk.Template.Child("set_flood_int")
    _reboot_btn = Gtk.Template.Child("reboot_btn")
    _basic_fetch_btn = Gtk.Template.Child("basic_fetch_btn")
    _basic_save_btn = Gtk.Template.Child("basic_save_btn")
    _loc_fetch_btn = Gtk.Template.Child("loc_fetch_btn")
    _loc_save_btn = Gtk.Template.Child("loc_save_btn")
    _radio_fetch_btn = Gtk.Template.Child("radio_fetch_btn")
    _radio_save_btn = Gtk.Template.Child("radio_save_btn")
    _advert_fetch_btn = Gtk.Template.Child("advert_fetch_btn")
    _advert_save_btn = Gtk.Template.Child("advert_save_btn")

    # Telemetry
    _tele_btn = Gtk.Template.Child("tele_btn")

    # Bottom bar
    _logout_btn = Gtk.Template.Child("logout_btn")

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._contact = None
        self._timeout_id = None
        self._nb_offset = 0
        self._nb_total = 0
        self._nb_neighbors = []  # list of (contact_or_None, snr)
        self._acl_entries = []  # list of {'key': hex, 'perm': int}
        self._region_entries = []  # list of {'name', 'indent', 'home', 'flood'}
        self._original_region_entries = []
        self._original_default_scope = ""
        self._pending_region_cmds = []
        self._reg_timeout_id = None
        self._reg_cmd_ids = set()
        self._regions_pending = False
        self._reg_prefixes = []
        self._reg_popover_entry = None
        self._current_page = "placeholder"
        self._leaving_regions = False
        self._pending_cmd_ids = []
        self._pending_prefixes = []
        self._fetch_timeout_id = None
        self._fetch_btn = None
        self._fetch_spinner = None

        # CLI buffer reference
        self._cli_buf = self._cli_view.get_buffer()

        # Populate CLI quick command buttons
        for label, cmd in [
            ("get name", "get name"),
            ("get radio", "get radio"),
            ("get tx", "get tx"),
            ("ver", "ver"),
            ("neighbors", "neighbors"),
            ("advert", "advert"),
            ("clock", "clock"),
        ]:
            btn = Gtk.Button(label=label)
            btn.add_css_class("flat")
            btn.connect("clicked", lambda b, c=cmd: self._send_cli(c))
            self._cli_quick_box.append(btn)

        # Signal connections
        self._status_refresh_btn.connect("clicked", lambda *_: self._request_status())
        self._tele_btn.connect("clicked", lambda *_: self._request_telemetry())
        self._clock_sync_btn.connect("clicked", self._on_sync_time_clicked)
        self._clock_reset_btn.connect("clicked", self._on_clock_reset_clicked)

        self._cli_entry.connect(
            "activate", lambda *_: self._send_cli(self._cli_entry.get_text().strip())
        )
        self._cli_send_btn.connect(
            "clicked", lambda *_: self._send_cli(self._cli_entry.get_text().strip())
        )

        self._nb_refresh_btn.connect("clicked", lambda *_: self._request_neighbors(reset=True))
        self._nb_load_more.connect("clicked", lambda *_: self._request_neighbors(reset=False))
        self._nb_map_btn.connect("clicked", lambda *_: self._show_neighbors_map())

        self._acl_refresh_btn.connect("clicked", lambda *_: self._request_acl())
        self._acl_add_btn.connect("clicked", lambda *_: self._show_acl_add_dialog())

        self._reg_retry_btn.connect("clicked", lambda *_: self._request_regions())
        self._reg_add_btn.connect("clicked", lambda *_: self._show_region_add_dialog())
        self._reg_apply_btn.connect("clicked", lambda *_: self._on_reg_apply())
        self._reg_default_entry.connect("notify::text", lambda *_: self._on_reg_default_changed())
        self._stack.connect("notify::visible-child-name", self._on_stack_page_changed)

        reg_action_group = Gio.SimpleActionGroup()
        for name, cb in [
            ("toggle_flood", lambda *_: self._on_reg_toggle_flood()),
            ("set_home", lambda *_: self._on_reg_set_home()),
            ("remove", lambda *_: self._on_reg_remove()),
        ]:
            action = Gio.SimpleAction.new(name, None)
            action.connect("activate", cb)
            reg_action_group.add_action(action)
        self._reg_list.insert_action_group("reg", reg_action_group)

        self._basic_fetch_btn.connect(
            "clicked",
            lambda *_: self._fetch_section(
                ["get name", "get repeat"],
                self._parse_basic,
                self._basic_fetch_btn,
                self._basic_fetch_spinner,
            ),
        )
        self._basic_save_btn.connect("clicked", lambda *_: self._save_basic())
        self._admin_pwd_save_btn.connect("clicked", lambda *_: self._save_admin_password())
        self._guest_pwd_save_btn.connect("clicked", lambda *_: self._save_guest_password())
        self._loc_fetch_btn.connect(
            "clicked",
            lambda *_: self._fetch_section(
                ["get lat", "get lon"],
                self._parse_location,
                self._loc_fetch_btn,
                self._loc_fetch_spinner,
            ),
        )
        self._loc_save_btn.connect("clicked", lambda *_: self._save_location())
        self._radio_fetch_btn.connect(
            "clicked",
            lambda *_: self._fetch_section(
                ["get radio", "get tx"],
                self._parse_radio,
                self._radio_fetch_btn,
                self._radio_fetch_spinner,
            ),
        )
        self._radio_save_btn.connect("clicked", lambda *_: self._save_radio())
        self._advert_fetch_btn.connect(
            "clicked",
            lambda *_: self._fetch_section(
                ["get advert.interval", "get flood.advert.interval"],
                self._parse_advert,
                self._advert_fetch_btn,
                self._advert_fetch_spinner,
            ),
        )
        self._advert_save_btn.connect("clicked", lambda *_: self._save_advert())
        self._reboot_btn.connect("activated", self._on_reboot)
        self._logout_btn.connect("clicked", self._on_logout)

    def _switch_tab(self, name):
        """Programmatically switch to a tab."""
        self._current_page = name
        self._stack.set_visible_child_name(name)

    def _start_timeout(self, on_timeout):
        """Start a request timeout. Cancels any previous one."""
        self._cancel_timeout()
        self._timeout_id = GLib.timeout_add(
            self.REQUEST_TIMEOUT_MS, lambda: (on_timeout(), False)[1]
        )

    def _cancel_timeout(self):
        if self._timeout_id:
            GLib.source_remove(self._timeout_id)
            self._timeout_id = None

    # ─── Status ────────────────────────────────────────────────

    def _on_sync_time_clicked(self, btn):
        """Send the current system time to the repeater via CLI command."""
        if not self._contact:
            return
        import time
        from datetime import datetime

        ts = int(time.time())
        self._window.send_cli_command(self._contact, f"time {ts}")
        self._st_clock.set_subtitle(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
        self._clock_drift_label.set_visible(False)
        self._clock_sync_btn.set_visible(False)
        self._clock_reset_btn.set_visible(False)
        self._window.show_toast(_("Time sync sent to repeater"))

    def _on_clock_reset_clicked(self, btn):
        """Reset the repeater's clock and reboot it (for when clock is ahead)."""
        if not self._contact:
            return
        dialog = Adw.AlertDialog(
            heading=_("Reset Clock & Reboot?"),
            body=_(
                "The repeater clock is ahead of the current time.\n"
                "Firmware does not allow setting time backwards.\n\n"
                "This will reset the clock and reboot the repeater."
            ),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("reset", _("Reset & Reboot"))
        dialog.set_response_appearance("reset", Adw.ResponseAppearance.DESTRUCTIVE)

        def _on_response(d, response):
            if response == "reset":
                self._window.send_cli_command(self._contact, "clkreboot")
                self._clock_drift_label.set_visible(False)
                self._clock_sync_btn.set_visible(False)
                self._clock_reset_btn.set_visible(False)
                self._st_clock.set_subtitle(_("Rebooting..."))
                self._window.show_toast(_("Clock reset & reboot sent to repeater"))

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _request_status(self):
        if not self._contact:
            return
        self._start_timeout(self._on_status_timeout)
        self._window.send_status_req(self._contact.public_key, self._on_status_response)

    def _on_status_timeout(self):
        self._st_battery.set_subtitle(_("Request timed out"))
        self._window.set_status_callback(None)
        self._start_pending_regions()

    def _on_status_response(self, status):
        self._cancel_timeout()
        mv = status["battery_mv"]
        pct = _battery_pct(mv)
        self._st_battery.set_subtitle(f"{pct}% ({mv} mV)")
        self._st_uptime.set_subtitle(_fmt_uptime(status["uptime_secs"]))
        self._st_queue.set_subtitle(str(status["queue_len"]))
        self._st_errors.set_subtitle(str(status["error_events"]))
        self._st_rssi.set_subtitle(f'{status["last_rssi"]} dB')
        self._st_snr.set_subtitle(f'{status["last_snr"]:.1f} dB')
        self._st_noise.set_subtitle(f'{status["noise_floor"]} dB')
        self._st_tx_air.set_subtitle(_fmt_uptime(status["tx_air_secs"]))
        self._st_rx_air.set_subtitle(_fmt_uptime(status["rx_air_secs"]))
        uptime = status["uptime_secs"]
        if uptime > 0:
            util = (status["tx_air_secs"] + status["rx_air_secs"]) / uptime * 100
            self._st_channel_util.set_subtitle(f"{util:.1f}%")
            duty = status["tx_air_secs"] / uptime * 100
            self._st_duty_cycle.set_subtitle(f"{duty:.1f}%")
        self._st_sent.set_subtitle(str(status["packets_sent"]))
        self._st_recv.set_subtitle(str(status["packets_recv"]))
        recv_errors = status.get("recv_errors")
        if recv_errors is not None:
            self._st_recv_errors.set_visible(True)
            self._st_recv_errors.set_subtitle(str(recv_errors))
        self._st_flood.set_subtitle(f'TX: {status["flood_tx"]} / RX: {status["flood_rx"]}')
        self._st_direct.set_subtitle(f'TX: {status["direct_tx"]} / RX: {status["direct_rx"]}')
        self._st_dups.set_subtitle(f'Flood: {status["dup_flood"]} / Direct: {status["dup_direct"]}')
        self._start_pending_regions()

    def _start_pending_regions(self):
        if self._regions_pending:
            self._regions_pending = False
            GLib.timeout_add(500, lambda: (self._request_regions(), False)[1])

    # ─── Telemetry ─────────────────────────────────────────────

    def _request_telemetry(self):
        if not self._contact:
            return
        from meshy.models import ContactType

        self._tele_btn.set_sensitive(False)
        self._tele_btn.set_label(_("Requesting…"))

        timeout_id = [None]

        def _reset_btn():
            self._tele_btn.set_sensitive(True)
            self._tele_btn.set_label(_("Request Telemetry"))

        def on_timeout():
            _reset_btn()
            timeout_id[0] = None
            return False

        def on_response(lpp_data):
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None
            _reset_btn()
            if not lpp_data:
                return
            from meshy.protocol import parse_cayenne_lpp

            entries = parse_cayenne_lpp(lpp_data)
            if entries:
                self._show_telemetry_dialog(entries)

        timeout_id[0] = GLib.timeout_add(self.REQUEST_TIMEOUT_MS, on_timeout)
        self._window.get_telemetry(
            self._contact.public_key, on_response, contact_type=ContactType.REPEATER
        )

    def _show_telemetry_dialog(self, entries: list[dict]):
        from meshy.models import battery_percent_from_mv
        from meshy.protocol import LPP_GPS, LPP_VOLTAGE

        gps_entry = next((e for e in entries if e["type"] == LPP_GPS), None)

        dialog = Adw.Dialog()
        dialog.set_title(_("Telemetry — {}").format(self._contact.name if self._contact else ""))
        dialog.set_content_width(420)
        dialog.set_content_height(550 if gps_entry else 400)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        scroll = Gtk.ScrolledWindow(vexpand=True)
        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=12,
            margin_start=12,
            margin_end=12,
            margin_top=12,
            margin_bottom=12,
        )

        channels = {}
        for entry in entries:
            channels.setdefault(entry["channel"], []).append(entry)

        for ch in sorted(channels):
            group = Adw.PreferencesGroup(title=_("Channel {}").format(ch))

            for entry in channels[ch]:
                val = entry["value"]
                if isinstance(val, dict):
                    alt_str = f', alt {val["alt"]:.0f} m' if abs(val["alt"]) > 0.01 else ""
                    subtitle = f'{val["lat"]:.4f}, {val["lon"]:.4f}{alt_str}'
                elif entry["type"] == LPP_VOLTAGE and ch == 1:
                    pct = battery_percent_from_mv(int(val * 1000))
                    subtitle = f"{pct}% ({val:.2f} V)"
                elif isinstance(val, float):
                    subtitle = f'{val:.2f} {entry["unit"]}'
                else:
                    subtitle = f'{val} {entry["unit"]}'

                title = _("Battery") if entry["type"] == LPP_VOLTAGE and ch == 1 else entry["name"]
                row = Adw.ActionRow(title=title, subtitle=subtitle)
                group.add(row)

            content.append(group)

        if gps_entry:
            from meshy.views import create_shumate_map

            lat = gps_entry["value"]["lat"]
            lon = gps_entry["value"]["lon"]

            map_widget, marker_layer, viewport = create_shumate_map(zoom_level=14, lat=lat, lon=lon)
            if map_widget:
                map_widget.set_vexpand(False)
                map_widget.set_size_request(-1, 200)
                content.append(map_widget)

        scroll.set_child(content)
        toolbar_view.set_content(scroll)
        dialog.set_child(toolbar_view)
        dialog.present(self._window)

    # ─── CLI ───────────────────────────────────────────────────

    def _send_cli(self, cmd):
        if not cmd or not self._contact:
            return
        self._cli_entry.set_text("")
        self._cli_append(f"> {cmd}")
        self._window.send_frame(build_send_cli_command(self._contact.public_key, cmd))

    def _cli_append(self, text):
        end = self._cli_buf.get_end_iter()
        self._cli_buf.insert(end, text + "\n")
        GLib.idle_add(
            lambda: self._cli_view.get_parent()
            .get_vadjustment()
            .set_value(self._cli_view.get_parent().get_vadjustment().get_upper())
            or False
        )

    def on_cli_response(self, contact, message):
        """Called by window when a CLI response arrives."""
        if (
            self._contact
            and message.is_cli
            and contact.public_key_hex == self._contact.public_key_hex
        ):
            self._cli_append(message.text)

    # ─── Neighbors ─────────────────────────────────────────────

    def _request_neighbors(self, reset=True):
        if not self._contact:
            return
        if reset:
            self._nb_offset = 0
            self._nb_neighbors = []
            self._nb_map_btn.set_visible(False)
            child = self._nb_list.get_first_child()
            while child:
                next_c = child.get_next_sibling()
                self._nb_list.remove(child)
                child = next_c
        self._nb_spinner.set_spinning(True)
        self._nb_status.set_label(_("Requesting..."))
        self._nb_load_more.set_visible(False)
        self._start_timeout(self._on_neighbors_timeout)
        self._window.get_neighbors(
            self._contact.public_key, self._on_neighbors_response, offset=self._nb_offset
        )

    def _on_neighbors_timeout(self):
        self._nb_spinner.set_spinning(False)
        self._nb_status.set_label(_("Request timed out"))
        self._nb_load_more.set_visible(self._nb_offset < self._nb_total)
        self._window.set_neighbors_callback(None)

    def _on_neighbors_response(self, result):
        self._cancel_timeout()
        self._nb_spinner.set_spinning(False)
        self._nb_total = result["total"]
        neighbors = result["neighbors"]
        self._nb_offset += len(neighbors)

        for nb in neighbors:
            prefix = nb.get("pub_key_hex", "")
            last_heard = nb.get("last_heard", 0)
            snr = nb.get("snr", 0)

            contact = self._window.find_contact_by_key_prefix(prefix)
            self._nb_neighbors.append((contact, snr))
            title = contact.name if contact else prefix
            subtitle = f"{_fmt_ago(last_heard)} \u00b7 SNR: {snr:.1f} dB"
            row = Adw.ActionRow(title=title, subtitle=subtitle)
            row.add_prefix(Gtk.Image.new_from_icon_name("network-server-symbolic"))
            self._nb_list.append(row)

        self._nb_status.set_label(_("{} of {} neighbors").format(self._nb_offset, self._nb_total))
        self._nb_load_more.set_visible(self._nb_offset < self._nb_total)

        center = self._contact
        has_center = center and center.has_location
        has_located_nb = any(c and c.has_location for c, _ in self._nb_neighbors)
        self._nb_map_btn.set_visible(has_center and has_located_nb)

    def _show_neighbors_map(self):
        from gi.repository import Shumate
        from meshy.views.map_view import MapView

        center = self._contact
        if not center or not center.has_location:
            return

        dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(
            self._window, title=_("Neighbor Map")
        )

        # Remove marker_layer so we can re-add it on top of path layers
        map_widget.get_map().remove_layer(marker_layer)

        lats = [center.latitude]
        lons = [center.longitude]
        neighbors_located = []

        # First pass: add path layers (lines) so they render below markers
        for contact, snr in self._nb_neighbors:
            if not contact or not contact.has_location:
                continue
            neighbors_located.append((contact, snr))

            path_layer = MapView.create_dashed_path_layer(viewport)
            map_widget.get_map().add_layer(path_layer)

            center_node = Shumate.Marker()
            center_node.set_location(center.latitude, center.longitude)
            path_layer.add_node(center_node)

            nb_node = Shumate.Marker()
            nb_node.set_location(contact.latitude, contact.longitude)
            path_layer.add_node(nb_node)

            lats.append(contact.latitude)
            lons.append(contact.longitude)

        # Re-add marker layer on top of all path layers
        map_widget.get_map().add_layer(marker_layer)

        # Center repeater marker (red)
        center_widget = MapView._build_marker_widget(
            0.90, 0.16, 0.22, "network-server-symbolic", ""
        )
        center_widget.set_tooltip_text(center.name)
        center_marker = Shumate.Marker()
        center_marker.set_child(center_widget)
        center_marker.set_location(center.latitude, center.longitude)
        marker_layer.add_marker(center_marker)
        click = Gtk.GestureClick()
        click.connect(
            "pressed",
            lambda g, n, x, y: MapView.show_marker_popover(
                center_widget, center.name, center.latitude, center.longitude
            ),
        )
        center_widget.add_controller(click)

        # Neighbor markers (green) and SNR labels
        for contact, snr in neighbors_located:
            nb_widget = MapView._build_marker_widget(
                0.30, 0.69, 0.31, "network-server-symbolic", ""
            )
            nb_widget.set_tooltip_text(contact.name)
            nb_click = Gtk.GestureClick()
            nb_click.connect(
                "pressed",
                lambda g, n, x, y, w=nb_widget, c=contact, s=snr: MapView.show_marker_popover(
                    w, c.name, c.latitude, c.longitude, s
                ),
            )
            nb_widget.add_controller(nb_click)
            nb_marker = Shumate.Marker()
            nb_marker.set_child(nb_widget)
            nb_marker.set_location(contact.latitude, contact.longitude)
            marker_layer.add_marker(nb_marker)

            mid_lat = (center.latitude + contact.latitude) / 2
            mid_lon = (center.longitude + contact.longitude) / 2
            snr_widget = MapView.build_snr_widget(snr)
            snr_marker = Shumate.Marker()
            snr_marker.set_child(snr_widget)
            snr_marker.set_location(mid_lat, mid_lon)
            marker_layer.add_marker(snr_marker)

        MapView.fit_viewport(viewport, lats, lons)
        dialog.present(self._window)

    # ─── Access Control (ACL) ─────────────────────────────────

    def _parse_acl_response(self, data):
        """Parse raw ACL binary response into list of {'key': hex, 'perm': int}."""
        # Response: [code(1)][reserved(1)][tag(4)][payload...]
        # Payload: 7-byte entries: [pubkey_prefix(6)][perm(1)]
        buf = data[6:]  # skip code + reserved + tag
        entries = []
        i = 0
        while i + 7 <= len(buf):
            key = buf[i : i + 6].hex()
            perm = buf[i + 6]
            if key != "000000000000":
                entries.append({"key": key, "perm": perm})
            i += 7
        return entries

    def _request_acl(self):
        if not self._contact:
            return
        # Clear list
        child = self._acl_list.get_first_child()
        while child:
            next_c = child.get_next_sibling()
            self._acl_list.remove(child)
            child = next_c
        self._acl_spinner.set_spinning(True)
        self._acl_status.set_label(_("Requesting..."))
        self._start_timeout(self._on_acl_timeout)
        self._window.get_acl(self._contact.public_key, self._on_acl_response)

    def _on_acl_timeout(self):
        self._acl_spinner.set_spinning(False)
        self._acl_status.set_label(_("Request timed out"))
        self._window.set_acl_callback(None)

    def _on_acl_response(self, data):
        self._cancel_timeout()
        self._acl_spinner.set_spinning(False)
        self._acl_entries = self._parse_acl_response(data)

        for entry in self._acl_entries:
            prefix = entry["key"]
            perm = entry["perm"]

            # Try to match to a known contact or own companion
            self_key = self._window.self_info.get("public_key", "")
            if self_key.startswith(prefix):
                name = self._window.self_info.get("name", "") or _("Me")
            else:
                contact = self._window.find_contact_by_key_prefix(prefix)
                name = contact.name if contact else None

            role = _("Admin") if (perm & 1) == 1 else _("Guest")
            title = name or prefix
            subtitle = f"{role} \u00b7 {prefix}"
            row = Adw.ActionRow(title=title, subtitle=subtitle)
            row.add_prefix(Gtk.Image.new_from_icon_name("avatar-default-symbolic"))

            # Remove button
            remove_btn = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER)
            remove_btn.add_css_class("destructive-action")
            remove_btn.connect("clicked", lambda b, k=prefix: self._on_acl_remove(k))
            row.add_suffix(remove_btn)
            self._acl_list.append(row)

        count = len(self._acl_entries)
        self._acl_status.set_label(
            _("{} entry").format(count) if count == 1 else _("{} entries").format(count)
        )

    def _on_acl_remove(self, key_hex):
        """Remove a node from ACL via setperm <key> -1."""
        if not self._contact:
            return
        # Find contact name for the confirmation message
        name = key_hex
        self_key = self._window.self_info.get("public_key", "")
        if self_key.startswith(key_hex):
            name = self._window.self_info.get("name", "") or _("Me")
        else:
            for c in self._window.contacts:
                if c.public_key_hex.startswith(key_hex):
                    name = c.name
                    break

        dialog = Adw.AlertDialog(
            heading=_("Remove from ACL?"),
            body=_("Remove {} from the access control list?").format(name),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("remove", _("Remove"))
        dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)

        def _on_response(d, response):
            if response == "remove":
                self._send_cmds([f"setperm {key_hex} -1"])
                # Refresh after a short delay to let the command take effect
                GLib.timeout_add(1000, lambda: (self._request_acl(), False)[1])

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _show_acl_add_dialog(self):
        """Show dialog to add a contact to the ACL."""
        if not self._contact:
            return

        dialog = Adw.AlertDialog(
            heading=_("Add to Access Control"),
            body=_("Select a contact and role."),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("add", _("Add"))
        dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        content.set_size_request(300, -1)

        # Role selector
        role_row = Adw.ComboRow(title=_("Role"))
        role_model = Gtk.StringList.new([_("Admin"), _("Guest")])
        role_row.set_model(role_model)
        role_row.set_selected(0)
        role_group = Adw.PreferencesGroup()
        role_group.add(role_row)
        content.append(role_group)

        # Contact list
        contacts_group = Adw.PreferencesGroup(title=_("Contact"))
        contact_scroll = Gtk.ScrolledWindow()
        contact_scroll.set_min_content_height(200)
        contact_scroll.set_max_content_height(300)
        contact_list = Gtk.ListBox()
        contact_list.set_selection_mode(Gtk.SelectionMode.SINGLE)
        contact_list.add_css_class("boxed-list")

        # Populate with contacts (exclude self repeater and non-chat contacts)
        acl_keys = {e["key"] for e in self._acl_entries}
        for c in self._window.contacts:
            # Skip the repeater itself
            if c.public_key_hex == self._contact.public_key_hex:
                continue
            # Skip contacts already in ACL
            if any(c.public_key_hex.startswith(k) for k in acl_keys):
                continue
            row = Adw.ActionRow(
                title=GLib.markup_escape_text(c.name), subtitle=c.public_key_hex[:12] + "..."
            )
            row._contact = c
            contact_list.append(row)

        list_wrapper = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            margin_start=8,
            margin_end=8,
            margin_top=8,
            margin_bottom=8,
        )
        list_wrapper.append(contact_list)
        contact_scroll.set_child(list_wrapper)
        contacts_group.add(contact_scroll)
        content.append(contacts_group)

        dialog.set_extra_child(content)

        def _on_response(d, response):
            if response != "add":
                return
            selected = contact_list.get_selected_row()
            if not selected or not hasattr(selected, "_contact"):
                return
            contact = selected._contact
            perm = 1 if role_row.get_selected() == 0 else 0
            self._send_cmds([f"setperm {contact.public_key_hex} {perm}"])
            # Refresh ACL after a delay
            GLib.timeout_add(1000, lambda: (self._request_acl(), False)[1])

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    # ─── Regions ───────────────────────────────────────────────

    def _cancel_reg_timeout(self):
        if self._reg_timeout_id:
            GLib.source_remove(self._reg_timeout_id)
            self._reg_timeout_id = None

    def _request_regions(self):
        if not self._contact:
            return
        # Cancel previous region fetch
        self._cancel_reg_timeout()
        for src_id in list(self._reg_cmd_ids):
            GLib.source_remove(src_id)
        self._window.cancel_cli_prefixes(self._reg_prefixes)
        self._reg_cmd_ids = set()
        self._reg_prefixes = []
        # Clear region list
        child = self._reg_list.get_first_child()
        while child:
            next_c = child.get_next_sibling()
            self._reg_list.remove(child)
            child = next_c
        # Show loading, hide content
        self._reg_loading_box.set_visible(True)
        self._reg_content_box.set_visible(False)
        self._reg_spinner.set_spinning(True)
        self._reg_status.set_label(_("Loading regions..."))
        self._reg_retry_btn.set_visible(False)

        commands = ["region", "region default"]
        responses = [None, None]
        remaining = [2]

        def make_cb(idx):
            def on_response(text):
                responses[idx] = text.strip()
                remaining[0] -= 1
                if remaining[0] <= 0:
                    self._cancel_reg_timeout()
                    self._on_regions_loaded(responses)

            return on_response

        def on_timeout():
            self._reg_timeout_id = None
            self._window.cancel_cli_prefixes(self._reg_prefixes)
            self._reg_prefixes = []
            received = [r for r in responses if r is not None]
            if received:
                self._on_regions_loaded(responses)
            else:
                self._on_regions_timeout()

        self._reg_timeout_id = GLib.timeout_add(
            self.REQUEST_TIMEOUT_MS, lambda: (on_timeout(), False)[1]
        )

        for i, cmd in enumerate(commands):
            holder = [0]

            def send(c=cmd, idx=i, h=holder):
                self._reg_cmd_ids.discard(h[0])
                prefix = f"{self._window.cli_prefix_counter:02X}|"
                self._reg_prefixes.append(prefix)
                self._window.send_cli_with_prefix(self._contact.public_key, c, make_cb(idx))
                return False

            holder[0] = GLib.timeout_add(i * 1500, send)
            self._reg_cmd_ids.add(holder[0])

    def _on_regions_timeout(self):
        self._reg_spinner.set_spinning(False)
        self._reg_status.set_label(_("Request timed out"))
        self._reg_retry_btn.set_visible(True)

    def _on_regions_loaded(self, responses):
        self._region_entries = []

        region_text = responses[0] if len(responses) > 0 and responses[0] else ""
        default_text = responses[1] if len(responses) > 1 and responses[1] else ""

        if not region_text:
            self._on_regions_timeout()
            return

        # Parse default scope
        default_name = ""
        if "default scope is" in default_text:
            val = default_text.split("default scope is", 1)[1].strip()
            if val and val != "<null>":
                default_name = val
        self._reg_default_entry.set_text(default_name)
        self._original_default_scope = default_name

        # Parse region tree
        for line in region_text.split("\n"):
            if not line.strip():
                continue
            indent = len(line) - len(line.lstrip(" "))
            stripped = line.strip()
            flood = stripped.endswith(" F")
            if flood:
                stripped = stripped[:-2].strip()
            home = stripped.endswith("^")
            if home:
                stripped = stripped[:-1]
            self._region_entries.append(
                {
                    "name": stripped,
                    "indent": indent,
                    "home": home,
                    "flood": flood,
                }
            )

        self._original_region_entries = copy.deepcopy(self._region_entries)
        self._pending_region_cmds = []
        self._reg_apply_btn.set_sensitive(False)

        self._rebuild_region_rows()
        self._reg_loading_box.set_visible(False)
        self._reg_content_box.set_visible(True)

    def _rebuild_region_rows(self):
        child = self._reg_list.get_first_child()
        while child:
            next_c = child.get_next_sibling()
            self._reg_list.remove(child)
            child = next_c

        for entry in self._region_entries:
            is_wildcard = entry["name"] == "*"
            title = _("Global (wildcard)") if is_wildcard else entry["name"]
            parts = []
            if entry["home"]:
                parts.append(_("Home"))
            parts.append(_("Flood allowed") if entry["flood"] else _("Flood denied"))
            subtitle = " · ".join(parts)

            row = Adw.ActionRow(
                title=GLib.markup_escape_text(title),
                subtitle=subtitle,
            )

            prefix_box = Gtk.Box(spacing=0)
            visual_indent = max(0, entry["indent"] - 1)
            if visual_indent > 0:
                spacer = Gtk.Box()
                spacer.set_size_request(visual_indent * 16, -1)
                prefix_box.append(spacer)
            flood_icon = "check-plain-symbolic" if entry["flood"] else "circle-crossed-symbolic"
            prefix_box.append(Gtk.Image.new_from_icon_name(flood_icon))
            row.add_prefix(prefix_box)

            menu_btn = Gtk.Button(
                icon_name="view-more-symbolic",
                valign=Gtk.Align.CENTER,
                tooltip_text=_("Actions"),
            )
            menu_btn.add_css_class("flat")
            menu_btn.connect("clicked", lambda b, e=entry: self._show_reg_popover(b, e))
            row.add_suffix(menu_btn)

            self._reg_list.append(row)

    def _show_reg_popover(self, button, entry):
        self._reg_popover_entry = entry
        is_wildcard = entry["name"] == "*"
        menu = Gio.Menu()
        flood_label = _("Deny Flood") if entry["flood"] else _("Allow Flood")
        menu.append(flood_label, "reg.toggle_flood")
        if not is_wildcard:
            if not entry["home"]:
                menu.append(_("Set as Home"), "reg.set_home")
            menu.append(_("Remove"), "reg.remove")
        popover = Gtk.PopoverMenu(menu_model=menu)
        popover.set_has_arrow(False)
        popover.set_parent(button)
        popover.connect("closed", lambda p: GLib.idle_add(p.unparent))
        popover.popup()

    def _mark_regions_dirty(self):
        self._reg_apply_btn.set_sensitive(True)

    def _check_regions_dirty(self):
        if self._pending_region_cmds:
            return True
        return self._reg_default_entry.get_text().strip() != self._original_default_scope

    def _on_reg_default_changed(self):
        if self._check_regions_dirty():
            self._mark_regions_dirty()
        else:
            self._reg_apply_btn.set_sensitive(False)

    def _on_reg_toggle_flood(self):
        entry = self._reg_popover_entry
        if not entry:
            return
        entry["flood"] = not entry["flood"]
        cmd = "region allowf" if entry["flood"] else "region denyf"
        self._pending_region_cmds.append(f'{cmd} {entry["name"]}')
        self._rebuild_region_rows()
        self._mark_regions_dirty()

    def _on_reg_set_home(self):
        entry = self._reg_popover_entry
        if not entry:
            return
        for e in self._region_entries:
            e["home"] = False
        entry["home"] = True
        self._pending_region_cmds.append(f'region home {entry["name"]}')
        self._rebuild_region_rows()
        self._mark_regions_dirty()

    def _on_reg_remove(self):
        entry = self._reg_popover_entry
        if not entry:
            return
        idx = self._region_entries.index(entry)
        if (
            idx + 1 < len(self._region_entries)
            and self._region_entries[idx + 1]["indent"] > entry["indent"]
        ):
            self._window.show_toast(_("Remove child regions first"))
            return
        self._region_entries.remove(entry)
        self._pending_region_cmds.append(f'region remove {entry["name"]}')
        self._rebuild_region_rows()
        self._mark_regions_dirty()

    def _on_reg_apply(self):
        cmds = list(self._pending_region_cmds)
        current_default = self._reg_default_entry.get_text().strip()
        if current_default != self._original_default_scope:
            cmd = (
                f"region default {current_default}" if current_default else "region default <null>"
            )
            cmds.append(cmd)
        if not cmds:
            return
        cmds.append("region save")
        self._send_cmds(cmds)
        self._pending_region_cmds = []
        self._reg_apply_btn.set_sensitive(False)
        GLib.timeout_add(1500, lambda: (self._request_regions(), False)[1])

    def _on_stack_page_changed(self, stack, pspec):
        new_page = stack.get_visible_child_name()
        if self._leaving_regions:
            self._leaving_regions = False
            self._current_page = new_page
            return
        prev_page = self._current_page
        if prev_page != "regions" or not self._check_regions_dirty():
            self._current_page = new_page
            return
        self._leaving_regions = True
        stack.set_visible_child_name("regions")
        target_page = new_page

        dialog = Adw.AlertDialog(
            heading=_("Unsaved Changes"),
            body=_("You have unsaved region changes."),
        )
        dialog.add_response("discard", _("Discard"))
        dialog.add_response("apply", _("Apply"))
        dialog.set_response_appearance("discard", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED)
        dialog.set_close_response("close")

        def _on_response(d, response):
            if response == "apply":
                self._on_reg_apply()
                self._leaving_regions = True
                stack.set_visible_child_name(target_page)
            elif response == "discard":
                self._pending_region_cmds = []
                self._reg_apply_btn.set_sensitive(False)
                self._request_regions()
                self._leaving_regions = True
                stack.set_visible_child_name(target_page)
            else:
                self._leaving_regions = False

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _show_region_add_dialog(self):
        if not self._contact:
            return

        dialog = Adw.AlertDialog(
            heading=_("Add Region"),
            body=_("Enter a name and optionally choose a parent region."),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("add", _("Add"))
        dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        content.set_size_request(300, -1)

        name_entry = Adw.EntryRow(title=_("Region Name"))
        name_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        name_list.add_css_class("boxed-list")
        name_list.append(name_entry)
        content.append(name_list)

        parent_names = (
            [_("None")] + ["*"] + [e["name"] for e in self._region_entries if e["name"] != "*"]
        )
        parent_row = Adw.ComboRow(title=_("Parent"))
        parent_model = Gtk.StringList.new(parent_names)
        parent_row.set_model(parent_model)
        parent_row.set_selected(0)
        parent_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        parent_list.add_css_class("boxed-list")
        parent_list.append(parent_row)
        content.append(parent_list)

        dialog.set_extra_child(content)

        def _on_response(d, response):
            if response != "add":
                return
            name = name_entry.get_text().strip()
            if not name:
                return
            selected = parent_row.get_selected()
            if selected == 0:
                parent_entry = None
            else:
                parent_name = parent_names[selected]
                parent_entry = next(
                    (e for e in self._region_entries if e["name"] == parent_name), None
                )
            child_indent = (parent_entry["indent"] + 1) if parent_entry else 1
            insert_idx = len(self._region_entries)
            if parent_entry:
                pi = self._region_entries.index(parent_entry)
                insert_idx = pi + 1
                while (
                    insert_idx < len(self._region_entries)
                    and self._region_entries[insert_idx]["indent"] > parent_entry["indent"]
                ):
                    insert_idx += 1
            self._region_entries.insert(
                insert_idx,
                {
                    "name": name,
                    "indent": child_indent,
                    "home": False,
                    "flood": True,
                },
            )
            if parent_entry:
                self._pending_region_cmds.append(f"region put {name} {parent_name}")
            else:
                self._pending_region_cmds.append(f"region put {name}")
            self._rebuild_region_rows()
            self._mark_regions_dirty()

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    # ─── Settings ──────────────────────────────────────────────

    def _cancel_fetch(self):
        """Cancel any in-progress settings fetch."""
        if self._fetch_timeout_id:
            GLib.source_remove(self._fetch_timeout_id)
            self._fetch_timeout_id = None
        for src_id in self._pending_cmd_ids:
            GLib.source_remove(src_id)
        self._window.cancel_cli_prefixes(self._pending_prefixes)
        self._pending_cmd_ids = []
        self._pending_prefixes = []
        if self._fetch_spinner:
            self._fetch_spinner.set_spinning(False)
            self._fetch_spinner.set_visible(False)
        if self._fetch_btn:
            self._fetch_btn.set_sensitive(True)
        self._fetch_btn = None
        self._fetch_spinner = None

    def _fetch_section(self, commands, parser, fetch_btn, spinner):
        """Fetch a section's settings via sequential CLI commands with retry."""
        if not self._contact:
            return
        self._cancel_fetch()
        self._fetch_btn = fetch_btn
        self._fetch_spinner = spinner
        fetch_btn.set_sensitive(False)
        spinner.set_visible(True)
        spinner.set_spinning(True)
        responses = [None] * len(commands)
        self._fetch_next_cmd(commands, responses, 0, parser, False)

    def _fetch_next_cmd(self, commands, responses, idx, parser, retried):
        """Send command at idx; on response advance, on timeout retry once."""
        if not self._contact:
            self._cancel_fetch()
            return
        if idx >= len(commands):
            self._cancel_fetch()
            parser(responses)
            return

        def on_response(text):
            if self._fetch_timeout_id:
                GLib.source_remove(self._fetch_timeout_id)
                self._fetch_timeout_id = None
            responses[idx] = text.strip()
            self._pending_prefixes = []
            self._fetch_next_cmd(commands, responses, idx + 1, parser, False)

        def on_timeout():
            self._fetch_timeout_id = None
            self._window.cancel_cli_prefixes(self._pending_prefixes)
            self._pending_prefixes = []
            if not retried:
                self._fetch_next_cmd(commands, responses, idx, parser, True)
            else:
                self._fetch_next_cmd(commands, responses, idx + 1, parser, False)

        self._fetch_timeout_id = GLib.timeout_add(
            self.FETCH_CMD_TIMEOUT_MS, lambda: (on_timeout(), False)[1]
        )
        prefix = f"{self._window.cli_prefix_counter:02X}|"
        self._pending_prefixes = [prefix]
        self._window.send_cli_with_prefix(self._contact.public_key, commands[idx], on_response)

    def _parse_basic(self, responses):
        for text in responses:
            if text is None:
                continue
            t = text.strip().lstrip("> ").strip()
            low = t.lower()
            if low in ("on", "off", "1", "0", "true", "false"):
                self._set_repeat.set_active(low in ("on", "1", "true"))
            elif t and "," not in t and not any(c in t for c in "{}[]"):
                self._set_name.set_text(t)

    def _parse_location(self, responses):
        for i, text in enumerate(responses):
            if text is None:
                continue
            t = text.strip().lstrip("> ").strip()
            try:
                val = float(t)
                if i == 0:
                    self._set_lat.set_text(str(val))
                else:
                    self._set_lon.set_text(str(val))
            except ValueError:
                pass

    def _parse_radio(self, responses):
        for text in responses:
            if text is None:
                continue
            t = text.strip().lstrip("> ").strip()
            if "," in t:
                parts = t.split(",")
                try:
                    self._set_freq.set_text(parts[0].strip())
                    self._set_bw.set_text(parts[1].strip())
                    self._set_sf.set_value(int(float(parts[2].strip())))
                    if len(parts) > 3:
                        self._set_cr.set_value(int(float(parts[3].strip())))
                except (ValueError, IndexError):
                    pass
            else:
                with contextlib.suppress(ValueError):
                    self._set_tx.set_value(int(float(t)))

    def _parse_advert(self, responses):
        for i, text in enumerate(responses):
            if text is None:
                continue
            t = text.strip().lstrip("> ").strip()
            try:
                val = int(float(t))
                if i == 0:
                    self._set_advert_int.set_value(val)
                else:
                    self._set_flood_int.set_value(val)
            except ValueError:
                pass

    def _save_admin_password(self):
        if not self._contact:
            return
        pwd = self._set_admin_pwd.get_text()
        if not pwd:
            return
        self._send_cmds([f"password {pwd}"])
        self._set_admin_pwd.set_text("")
        self._window.show_toast(_("Admin password changed — re-login required"))

    def _save_guest_password(self):
        if not self._contact:
            return
        pwd = self._set_guest_pwd.get_text()
        self._send_cmds([f"set guest.password {pwd}"])

    def _save_basic(self):
        if not self._contact:
            return
        cmds = []
        name = self._set_name.get_text().strip()
        if name:
            cmds.append(f"set name {name}")
        cmds.append(f'set repeat {"on" if self._set_repeat.get_active() else "off"}')
        self._send_cmds(cmds)

    def _save_location(self):
        if not self._contact:
            return
        cmds = []
        with contextlib.suppress(ValueError):
            cmds.append(f"set lat {float(self._set_lat.get_text())}")
        with contextlib.suppress(ValueError):
            cmds.append(f"set lon {float(self._set_lon.get_text())}")
        self._send_cmds(cmds)

    def _save_radio(self):
        if not self._contact:
            return
        cmds = []
        freq = self._set_freq.get_text().strip()
        bw = self._set_bw.get_text().strip()
        if freq and bw:
            sf = int(self._set_sf.get_value())
            cr = int(self._set_cr.get_value())
            cmds.append(f"set radio {freq},{bw},{sf},{cr}")
        tx = int(self._set_tx.get_value())
        if tx > 0:
            cmds.append(f"set tx {tx}")
        self._send_cmds(cmds)

    def _save_advert(self):
        if not self._contact:
            return
        cmds = [
            f"set advert.interval {int(self._set_advert_int.get_value())}",
            f"set flood.advert.interval {int(self._set_flood_int.get_value())}",
        ]
        self._send_cmds(cmds)

    def _send_cmds(self, cmds):
        if not cmds:
            return
        for i, cmd in enumerate(cmds):
            GLib.timeout_add(
                i * 300,
                lambda c=cmd: (
                    self._window.send_frame(build_send_cli_command(self._contact.public_key, c))
                    or False
                ),
            )
        name = self._contact.name if self._contact else "repeater"
        self._window.show_toast(_("Settings sent to {}").format(name))

    def _on_reboot(self, *args):
        confirm = Adw.AlertDialog(
            heading=_("Reboot Repeater?"),
            body=_("Reboot {}? It will be offline briefly.").format(self._contact.name),
        )
        confirm.add_response("cancel", _("Cancel"))
        confirm.add_response("reboot", _("Reboot"))
        confirm.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE)
        confirm.connect(
            "response", lambda d, r: (self._send_cli("reboot")) if r == "reboot" else None
        )
        confirm.present(self._window)

    # ─── Public API ────────────────────────────────────────────

    def _reset_ui(self):
        """Reset all UI widgets to default/empty state."""
        self._cancel_fetch()

        # Status rows
        for w in (
            self._st_battery,
            self._st_uptime,
            self._st_queue,
            self._st_errors,
            self._st_rssi,
            self._st_snr,
            self._st_noise,
            self._st_tx_air,
            self._st_rx_air,
            self._st_sent,
            self._st_recv,
            self._st_flood,
            self._st_direct,
            self._st_dups,
        ):
            w.set_subtitle("--")
        self._st_recv_errors.set_visible(False)

        # Neighbors
        child = self._nb_list.get_first_child()
        while child:
            next_c = child.get_next_sibling()
            self._nb_list.remove(child)
            child = next_c
        self._nb_offset = 0
        self._nb_total = 0
        self._nb_neighbors = []
        self._nb_spinner.set_spinning(False)
        self._nb_status.set_label("")
        self._nb_load_more.set_visible(False)
        self._nb_map_btn.set_visible(False)

        # ACL
        child = self._acl_list.get_first_child()
        while child:
            next_c = child.get_next_sibling()
            self._acl_list.remove(child)
            child = next_c
        self._acl_entries = []
        self._acl_spinner.set_spinning(False)
        self._acl_status.set_label("")

        # Regions
        child = self._reg_list.get_first_child()
        while child:
            next_c = child.get_next_sibling()
            self._reg_list.remove(child)
            child = next_c
        self._region_entries = []
        self._original_region_entries = []
        self._original_default_scope = ""
        self._reg_default_entry.set_text("")
        self._reg_loading_box.set_visible(True)
        self._reg_content_box.set_visible(False)
        self._reg_spinner.set_spinning(False)
        self._reg_status.set_label("")
        self._reg_retry_btn.set_visible(False)

        # Settings
        self._set_name.set_text("")
        self._set_repeat.set_active(False)
        self._set_admin_pwd.set_text("")
        self._set_guest_pwd.set_text("")
        self._set_lat.set_text("")
        self._set_lon.set_text("")
        self._set_freq.set_text("")
        self._set_bw.set_text("")
        self._set_sf.set_value(5)
        self._set_cr.set_value(5)
        self._set_tx.set_value(1)
        self._set_advert_int.set_value(0)
        self._set_flood_int.set_value(0)

        # CLI
        self._cli_buf.set_text("")

    def set_repeater(self, contact: Contact, login_timestamp=None, is_admin=True):
        """Set the active repeater and switch to status view."""
        # Clean up previous repeater state
        self._cancel_timeout()
        self._cancel_reg_timeout()
        self._pending_region_cmds = []
        self._leaving_regions = False
        self._pending_cmd_ids = []
        self._pending_prefixes = []
        self._window.set_cli_response_callback(None)
        self._window.set_status_callback(None)
        self._window.set_neighbors_callback(None)
        self._window.set_acl_callback(None)
        self._reset_ui()

        self._contact = contact
        self._is_admin = is_admin

        # Show/hide admin-only tabs
        for name in ("cli", "access", "settings", "regions"):
            child = self._stack.get_child_by_name(name)
            if child:
                self._stack.get_page(child).set_visible(is_admin)

        # Show/hide clock sync controls (admin only)
        self._st_clock.set_visible(is_admin)

        # Display the repeater's clock time from the login response
        if is_admin and login_timestamp:
            import time
            from datetime import datetime

            dt = datetime.fromtimestamp(login_timestamp)
            self._st_clock.set_subtitle(dt.strftime("%Y-%m-%d %H:%M:%S"))
            now = int(time.time())
            drift = now - login_timestamp  # positive = behind, negative = ahead
            if abs(drift) > 300:  # more than 5 minutes off
                self._clock_drift_label.set_label(_("Time is off"))
                self._clock_drift_label.set_visible(True)
                if drift > 0:
                    # Repeater clock is behind — can set time forward
                    self._clock_sync_btn.set_visible(True)
                    self._clock_reset_btn.set_visible(False)
                else:
                    # Repeater clock is ahead — firmware rejects setting time back
                    self._clock_sync_btn.set_visible(False)
                    self._clock_reset_btn.set_visible(True)
            else:
                self._clock_drift_label.set_visible(False)
                self._clock_sync_btn.set_visible(False)
                self._clock_reset_btn.set_visible(False)
        else:
            self._st_clock.set_subtitle("--")
            self._clock_drift_label.set_visible(False)
            self._clock_sync_btn.set_visible(False)
            self._clock_reset_btn.set_visible(False)
        self._switch_tab("status")
        # Clear CLI output
        self._cli_buf.set_text("")
        # Register CLI callback for admin
        if is_admin:
            self._window.set_cli_response_callback(self.on_cli_response)
            self._regions_pending = True
        # Request status (regions will load after status completes)
        self._request_status()

    def _on_logout(self, *args):
        if self._contact:
            self._window.logout_repeater(self._contact.public_key_hex)

    def clear(self):
        self._cancel_timeout()
        self._cancel_reg_timeout()
        self._cancel_fetch()
        self._pending_region_cmds = []
        self._reg_apply_btn.set_sensitive(False)
        self._contact = None
        self._current_page = "placeholder"
        self._stack.set_visible_child_name("placeholder")
        self._window.set_cli_response_callback(None)
        self._window.set_status_callback(None)
        self._window.set_neighbors_callback(None)
        self._window.set_acl_callback(None)
