# 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 copy
from gi.repository import Adw, Gio, Gtk, GLib

from meshy.models import Contact
from meshy.protocol import (
    build_send_cli_command, build_send_status_req, build_get_neighbors,
)


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.protocol import LPP_GPS, LPP_VOLTAGE
        from meshy.models import battery_percent_from_mv

        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_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):
        import math
        from gi.repository import Shumate, Gdk
        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 = '{} \u00b7 {}'.format(role, 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:
                try:
                    self._set_tx.set_value(int(float(t)))
                except ValueError:
                    pass

    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 = []
        try:
            cmds.append(f'set lat {float(self._set_lat.get_text())}')
        except ValueError:
            pass
        try:
            cmds.append(f'set lon {float(self._set_lon.get_text())}')
        except ValueError:
            pass
        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:
            from datetime import datetime
            import time
            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)
