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

"""Main application window with adaptive navigation."""

import collections
import logging
import time
from datetime import datetime

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk, Pango
from meshy.backup import BackupManager
from meshy.ble import BleDevice, BleTransport
from meshy.connection_controller import ConnectionController
from meshy.frame_handler import FrameHandler
from meshy.message_controller import MessageController
from meshy.models import (
    Channel,
    Contact,
    ContactType,
    DeviceInfo,
    Message,
    battery_icon_name,
    snr_to_icon,
    snr_to_label,
)
from meshy.notification import NotificationService
from meshy.protocol import (
    STATS_TYPE_CORE,
    STATS_TYPE_PACKETS,
    STATS_TYPE_RADIO,
    build_add_update_contact,
    build_anon_req_regions,
    build_export_contact,
    build_get_advert_path,
    build_get_channel,
    build_get_neighbors,
    build_get_stats,
    build_import_contact,
    build_path_discovery,
    build_reboot,
    build_remove_contact,
    build_reset_path,
    build_send_cli_command,
    build_send_discover_req,
    build_send_self_advert,
    build_send_status_req,
    build_set_advert_latlon,
    build_set_advert_name,
    build_set_channel,
    build_set_custom_var,
    build_set_default_flood_scope,
    build_set_device_pin,
    build_set_flood_scope,
    build_set_path_hash_mode,
    build_set_radio_params,
    build_set_radio_tx_power,
    build_trace_path,
)
from meshy.storage import Storage
from meshy.transport_base import ConnectionState
from meshy.views.map_view import MapView

log = logging.getLogger(__name__)


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/window.ui")
class MeshyWindow(Adw.ApplicationWindow):
    __gtype_name__ = "MeshyWindow"

    _toast_overlay = Gtk.Template.Child("toast_overlay")
    _main_stack = Gtk.Template.Child("main_stack")
    _connection_box = Gtk.Template.Child("connection_box")
    _connection_screen_banner = Gtk.Template.Child("connection_screen_banner")
    _connecting_status = Gtk.Template.Child("connecting_status")
    _connecting_spinner = Gtk.Template.Child("connecting_spinner")
    _outer_split = Gtk.Template.Child("outer_split")
    _split_view = Gtk.Template.Child("split_view")
    _nav_rail = Gtk.Template.Child("nav_rail")
    _nav_device = Gtk.Template.Child("nav_device")
    _nav_contacts = Gtk.Template.Child("nav_contacts")
    _nav_contacts_dot = Gtk.Template.Child("nav_contacts_dot")
    _nav_channels = Gtk.Template.Child("nav_channels")
    _nav_channels_dot = Gtk.Template.Child("nav_channels_dot")
    _nav_map = Gtk.Template.Child("nav_map")
    _nav_settings = Gtk.Template.Child("nav_settings")
    _sidebar_header = Gtk.Template.Child("sidebar_header")
    _nav_rail_toggle = Gtk.Template.Child("nav_rail_toggle")
    _sidebar_banner = Gtk.Template.Child("sidebar_banner")
    _sidebar_page = Gtk.Template.Child("sidebar_page")
    _sidebar_box = Gtk.Template.Child("sidebar_box")
    _content_header = Gtk.Template.Child("content_header")
    _companion_box = Gtk.Template.Child("companion_box")
    _companion_name_label = Gtk.Template.Child("companion_name_label")
    _companion_separator = Gtk.Template.Child("companion_separator")
    _companion_battery_icon = Gtk.Template.Child("companion_battery_icon")
    _companion_signal_icon = Gtk.Template.Child("companion_signal_icon")
    _content_box = Gtk.Template.Child("content_box")
    _content_page = Gtk.Template.Child("content_page")

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        # Add top margin on macOS to avoid window controls overlapping nav rail
        from meshy.platforms import is_macos

        if is_macos():
            self._nav_rail.set_margin_top(60)

        self._ble = BleTransport()  # BLE transport (None if not available on macOS)
        self._usb = None  # USB transport (created on demand)
        self._tcp = None  # TCP transport (created on demand)
        self._transport = self._ble if self._ble else None  # Active transport
        self._storage: Storage | None = None
        self._device_info = DeviceInfo()
        self._device_stats: dict = {}  # stats from CMD_GET_STATS
        self._key_store = None  # meshcoredecoder key store for LOG_DATA
        self._channel_hash_map = {}  # channel_hash_hex → channel_index
        self._self_info: dict = {}
        self._contacts: list[Contact] = []
        self._contacts_by_key: dict[str, Contact] = {}  # pub_key_hex -> Contact
        self._discovered: list[Contact] = []  # heard adverts not yet added
        self._discovered_by_key: dict[str, Contact] = {}
        self._channels: list[Channel] = []
        self._msg_ctrl = MessageController(self)
        self._conn_ctrl = ConnectionController(self)
        self._pending_clipboard_export: bool = False
        self._neighbors_callback = None
        self._advert_listener = None  # callback for live advert monitoring
        self._discover_callback = None  # callback for discover responses
        self._trace_callback = None  # callback for trace path responses
        self._trace_timeout_callback = None  # callback for trace timeout from device
        self._advert_path_callback = None  # callback for advert path responses
        self._path_discovery_callback = None  # callback for path discovery responses
        self._acl_callback = None  # callback for ACL responses
        self._telemetry_callback = None  # callback for telemetry responses
        self._login_callback = None  # callback for room login response
        self._login_target_prefix = None  # 6-byte prefix of room/repeater being logged into
        self._login_is_admin = False  # whether last login was admin
        self._login_retry_timeout_id = None
        self._login_pub_key = None
        self._login_password = None
        self._login_on_retry = None
        self._login_was_flood_retry = False
        self._logged_in_rooms: dict[str, bool] = {}  # pub_key_hex -> is_admin
        self._cli_response_callback = None  # callback for CLI responses from rooms/repeaters
        self._cli_prefix_counter = 0  # cycling 00-FF prefix for CLI correlation
        self._cli_pending_prefixes = {}  # prefix_str -> callback(text)
        self._status_callback = None  # callback for binary status responses
        self._region_callbacks: dict[str, callable] = {}  # ack_hex -> callback for region responses
        self._pending_binary_requests: dict[str, dict] = {}  # tag_hex -> {type, callback}
        self._ignore_next_err = False  # suppress next ERR toast (e.g. time sync rejection)
        self._rx_log = collections.deque(maxlen=500)
        self._rx_log_callback = None  # live-update callback when Rx Log dialog is open
        self._collapsed_handler_ids: list[int] = []  # split_view signal handlers to clean up
        self._custom_vars: dict = {}  # key -> value from firmware
        self._background_mode = False
        self._direct_repeaters: dict = {}  # key -> (snr, monotonic_time, miss_count)
        self._repeaters_refreshed: set = set()
        self._signal_icon_timer_id = None
        self._settings = Gio.Settings.new("page.codeberg.sesivany.Meshy")
        self._backup = BackupManager()
        self._frame_handler = FrameHandler(self)
        self._notification_service = NotificationService(self.get_application())

        if self._ble:
            self._conn_ctrl.setup_transport_callbacks(self._ble)
        self._build_ui()
        self._restore_window_state()

        # Add macOS-specific CSS class for traffic light button spacing
        from meshy.platforms import is_macos

        if is_macos():
            self.add_css_class("macos-window")

        # Clear unread indicators when the window regains focus
        self.connect("notify::is-active", self._on_window_active_changed)

        # Auto-connect to last known device on startup
        GLib.timeout_add(500, self._conn_ctrl.auto_connect)

    @property
    def _connection_banner(self):
        page = self._main_stack.get_visible_child_name()
        if page == "connection":
            return self._connection_screen_banner
        return self._sidebar_banner

    def _restore_window_state(self):
        self.set_default_size(
            self._settings.get_int("window-width"), self._settings.get_int("window-height")
        )

        # Restore maximized state
        if self._settings.get_boolean("window-maximized"):
            self.maximize()

        self.connect("close-request", self._on_close_request)

    def set_background_mode(self, enabled):
        self._background_mode = enabled

    def shutdown(self):
        """Full cleanup for application quit."""
        self._conn_ctrl.manual_disconnect = True
        self._conn_ctrl.cancel_reconnect()
        if self._transport:
            self._transport.disconnect()
        if self._storage:
            self._storage.close()
        if self._conn_ctrl.batt_timer_id:
            GLib.source_remove(self._conn_ctrl.batt_timer_id)
            self._conn_ctrl.batt_timer_id = None
        if self._conn_ctrl.watchdog_timer_id:
            GLib.source_remove(self._conn_ctrl.watchdog_timer_id)
            self._conn_ctrl.watchdog_timer_id = None

    def _on_close_request(self, *args):
        self._settings.set_int("window-width", self.get_width())
        self._settings.set_int("window-height", self.get_height())
        self._settings.set_boolean("window-maximized", self.is_maximized())
        if self._background_mode and self.is_connected:
            self.set_visible(False)
            return True
        app = self.get_application()
        if app and hasattr(app, "_background_held") and app._background_held:
            app.release()
            app._background_held = False
        self.shutdown()

    def _build_ui(self):
        # Views are created lazily on first access to reduce startup memory.
        # Only ConnectionView is needed immediately for the connect screen.
        self._device_view_inst = None
        self._contacts_view_inst = None
        self._chat_view_inst = None
        self._channels_view_inst = None
        self._settings_view_inst = None
        self._repeater_view_inst = None
        self._map_view_inst = None
        self._saved_map_state = None
        from meshy.views.connection_view import ConnectionView

        self._connection_view = ConnectionView(self)
        self._current_view = "device"
        self._content_header_extra = None
        self._content_title_btn = None
        self._content_title_label = None
        self._sidebar_header_extras = []

        # Nav button dict for toggling
        self._nav_buttons = {
            "device": self._nav_device,
            "contacts": self._nav_contacts,
            "channels": self._nav_channels,
            "map": self._nav_map,
            "settings": self._nav_settings,
        }
        for view_id, btn in self._nav_buttons.items():
            btn.connect("toggled", self._on_nav_toggled, view_id)

        # Actions
        disconnect_action = Gio.SimpleAction.new("disconnect", None)
        disconnect_action.connect("activate", self._on_menu_disconnect)
        self.add_action(disconnect_action)

        quit_action = Gio.SimpleAction.new("quit", None)
        quit_action.connect("activate", lambda *_: self.get_application()._do_full_quit())
        self.add_action(quit_action)

        about_action = Gio.SimpleAction.new("about", None)
        about_action.connect("activate", lambda *_: self.get_application()._on_about(None, None))
        self.add_action(about_action)

        help_action = Gio.SimpleAction.new("help", None)
        help_action.connect("activate", self._on_help)
        self.add_action(help_action)

        import meshy as _meshy_pkg

        if _meshy_pkg.SHORTCUTS_DIALOG_ENABLED:
            shortcuts_action = Gio.SimpleAction.new("show-help-overlay", None)
            shortcuts_action.connect("activate", self._on_show_shortcuts)
            self.add_action(shortcuts_action)

        for view_id in ("device", "contacts", "channels", "map", "settings"):
            action = Gio.SimpleAction.new(f"navigate-{view_id}", None)
            action.connect("activate", self._on_navigate_action, view_id)
            self.add_action(action)

        search_action = Gio.SimpleAction.new("search", None)
        search_action.connect("activate", self._on_search_action)
        self.add_action(search_action)

        new_msg_action = Gio.SimpleAction.new("new-message", None)
        new_msg_action.connect("activate", self._on_new_message_action)
        self.add_action(new_msg_action)

        for name, direction, unread in (
            ("navigate-prev", -1, False),
            ("navigate-next", 1, False),
            ("navigate-prev-unread", -1, True),
            ("navigate-next-unread", 1, True),
        ):
            action = Gio.SimpleAction.new(name, None)
            action.connect("activate", self._on_list_navigate, direction, unread)
            self.add_action(action)

        custom_tiles_action = Gio.SimpleAction.new("custom-tiles", None)
        custom_tiles_action.connect("activate", self._on_custom_tiles)
        self.add_action(custom_tiles_action)

        # Signal connections for template widgets
        self._nav_rail_toggle.connect("clicked", self._on_nav_rail_toggle)
        self._sidebar_banner.connect("button-clicked", self._on_connect_clicked)
        self._connection_screen_banner.connect("button-clicked", self._on_connect_clicked)
        self._outer_split.connect("notify::collapsed", self._on_outer_split_collapsed_changed)
        self._outer_split.connect("notify::show-sidebar", self._on_outer_split_show_sidebar_changed)

        # Breakpoints
        bp_medium = Adw.Breakpoint.new(Adw.BreakpointCondition.parse("max-width: 860sp"))
        bp_medium.add_setter(self._outer_split, "collapsed", True)
        self.add_breakpoint(bp_medium)

        bp_narrow = Adw.Breakpoint.new(Adw.BreakpointCondition.parse("max-width: 600sp"))
        bp_narrow.add_setter(self._outer_split, "collapsed", True)
        bp_narrow.add_setter(self._split_view, "collapsed", True)
        self.add_breakpoint(bp_narrow)

        # Activate device view by default
        self._nav_buttons["device"].set_active(True)

        # Show connecting screen if we have a saved device, otherwise
        # show the connection screen
        last_addr = self._settings.get_string("last-device-address")
        if last_addr:
            self._show_connecting_screen(last_addr)
        else:
            self._connection_box.append(self._connection_view)

    def _on_nav_rail_toggle(self, button):
        """Toggle the nav rail visibility when in collapsed mode."""
        show = not self._outer_split.get_show_sidebar()
        self._outer_split.set_show_sidebar(show)

    def _on_outer_split_collapsed_changed(self, split_view, *args):
        """Show/hide the nav rail toggle button based on collapsed state."""
        collapsed = split_view.get_collapsed()
        self._nav_rail_toggle.set_visible(collapsed)
        if not collapsed:
            split_view.set_show_sidebar(True)

    def _on_outer_split_show_sidebar_changed(self, split_view, *args):
        """Update toggle button icon based on sidebar visibility."""
        if split_view.get_show_sidebar():
            self._nav_rail_toggle.set_icon_name("sidebar-show-symbolic")
        else:
            self._nav_rail_toggle.set_icon_name("sidebar-show-right-symbolic")

    @property
    def _device_view(self):
        if self._device_view_inst is None:
            from meshy.views.device_view import DeviceView

            self._device_view_inst = DeviceView(self)
        return self._device_view_inst

    @property
    def _contacts_view(self):
        if self._contacts_view_inst is None:
            from meshy.views.contacts_view import ContactsView

            self._contacts_view_inst = ContactsView(self)
        return self._contacts_view_inst

    @property
    def _chat_view(self):
        if self._chat_view_inst is None:
            from meshy.views.chat_view import ChatView

            self._chat_view_inst = ChatView(self)
        return self._chat_view_inst

    @property
    def _channels_view(self):
        if self._channels_view_inst is None:
            from meshy.views.channels_view import ChannelsView

            self._channels_view_inst = ChannelsView(self)
        return self._channels_view_inst

    @property
    def _settings_view(self):
        if self._settings_view_inst is None:
            from meshy.views.settings_view import SettingsView

            self._settings_view_inst = SettingsView(self)
        return self._settings_view_inst

    @property
    def _map_view(self):
        if self._map_view_inst is None:
            self._map_view_inst = MapView(self)
        return self._map_view_inst

    @property
    def _repeater_view(self):
        if self._repeater_view_inst is None:
            from meshy.views.repeater_view import RepeaterView

            self._repeater_view_inst = RepeaterView(self)
        return self._repeater_view_inst

    def _on_nav_toggled(self, button, view_id):
        if not button.get_active():
            return
        # Untoggle other buttons
        for vid, btn in self._nav_buttons.items():
            if vid != view_id:
                btn.handler_block_by_func(self._on_nav_toggled)
                btn.set_active(False)
                btn.handler_unblock_by_func(self._on_nav_toggled)

        self._current_view = view_id
        self._switch_view(view_id)

        # Auto-hide nav rail overlay after selection
        if self._outer_split.get_collapsed():
            self._outer_split.set_show_sidebar(False)

    def _on_show_shortcuts(self, action, param):
        builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/gtk/help-overlay.ui")
        dialog = builder.get_object("help_overlay")
        dialog.present(self)

    def _on_help(self, action, param):
        Gtk.UriLauncher(uri="https://meshy-app.org/docs/").launch(self, None, None, None)

    def _on_navigate_action(self, action, param, view_id):
        self._nav_buttons[view_id].set_active(True)

    def _on_search_action(self, action, param):
        if self._current_view == "contacts" and self._contacts_view_inst:
            self._contacts_view_inst._search_bar.set_search_mode(True)
            self._contacts_view_inst._search_entry.grab_focus()
        elif self._current_view == "channels" and self._channels_view_inst:
            self._channels_view_inst._search_bar.set_search_mode(True)
            self._channels_view_inst._search_entry.grab_focus()

    def _on_new_message_action(self, action, param):
        if self._current_view == "contacts" and self._chat_view_inst:
            self._chat_view_inst._text_entry.grab_focus()
        elif self._current_view == "channels" and self._channels_view_inst:
            self._channels_view_inst._chat_widget._text_entry.grab_focus()

    def _on_list_navigate(self, action, param, direction, unread_only):
        if self._current_view == "contacts" and self._contacts_view:
            self._contacts_view.navigate(direction, unread_only)
        elif self._current_view == "channels" and self._channels_view_inst:
            self._channels_view.navigate(direction, unread_only)

    def _on_custom_tiles(self, action, param):
        from meshy.views import apply_custom_source

        dialog = Adw.Dialog()
        dialog.set_title(_("Custom Map Tiles"))
        dialog.set_content_width(450)
        dialog.set_content_height(300)

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

        box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=12,
            margin_start=16,
            margin_end=16,
            margin_top=12,
            margin_bottom=12,
        )

        group = Adw.PreferencesGroup()

        type_model = Gtk.StringList.new([_("Raster"), _("Vector")])
        type_row = Adw.ComboRow(title=_("Type"), model=type_model)
        saved_type = self._settings.get_string("custom-tile-type")
        type_row.set_selected(1 if saved_type == "vector" else 0)
        group.add(type_row)

        url_row = Adw.EntryRow(title=_("URL Template"))
        url_row.set_text(self._settings.get_string("custom-tile-url"))
        group.add(url_row)
        box.append(group)

        raster_hint = _(
            "Use {z}, {x}, {y} as placeholders.\n"
            "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png"
        )
        vector_hint = _(
            "URL to a MapLibre/Mapbox style JSON.\n"
            "Example: https://api.maptiler.com/maps/outdoor-v2/style.json?key=KEY"
        )
        hint = Gtk.Label(
            label=raster_hint if saved_type != "vector" else vector_hint, wrap=True, xalign=0
        )
        hint.add_css_class("dim-label")
        hint.add_css_class("caption")
        box.append(hint)

        def _on_type_changed(*_args):
            is_vector = type_row.get_selected() == 1
            url_row.set_title(_("Style JSON URL") if is_vector else _("URL Template"))
            hint.set_label(vector_hint if is_vector else raster_hint)

        type_row.connect("notify::selected", _on_type_changed)
        _on_type_changed()

        btn_box = Gtk.Box(spacing=8, halign=Gtk.Align.END, margin_top=4)

        clear_btn = Gtk.Button(label=_("Reset to Default"))

        def _on_clear(*_args):
            url_row.set_text("")
            self._settings.set_string("custom-tile-url", "")
            self._settings.set_string("custom-tile-type", "")
            apply_custom_source("", "")
            dialog.close()

        clear_btn.connect("clicked", _on_clear)
        btn_box.append(clear_btn)

        apply_btn = Gtk.Button(label=_("Apply"))
        apply_btn.add_css_class("suggested-action")

        def _on_apply(*_args):
            url = url_row.get_text().strip()
            tile_type = "vector" if type_row.get_selected() == 1 else "raster"
            self._settings.set_string("custom-tile-url", url)
            self._settings.set_string("custom-tile-type", tile_type if url else "")

            def _on_result(ok):
                if ok:
                    self.show_toast(_("Custom map tiles applied"))
                else:
                    self.show_toast(_("Failed to load custom map tiles"))

            apply_custom_source(url, tile_type, on_result=_on_result)
            dialog.close()

        apply_btn.connect("clicked", _on_apply)
        btn_box.append(apply_btn)
        box.append(btn_box)

        toolbar_view.set_content(box)
        dialog.set_child(toolbar_view)
        dialog.present(self)

    def _clear_box(self, box):
        child = box.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            box.remove(child)
            child = next_child

    def _add_sidebar_nav_button(self, label: str):
        """Create a navigation button for collapsed mode (e.g. 'Device Information ›')."""
        btn = Gtk.Button(label=f"{label} \u203a")
        btn.add_css_class("flat")
        btn.set_margin_start(12)
        btn.set_margin_end(12)
        btn.set_margin_top(8)
        btn.connect("clicked", lambda *_: self._split_view.set_show_content(True))
        btn.set_visible(self._split_view.get_collapsed())
        self._collapsed_handler_ids.append(
            self._split_view.connect(
                "notify::collapsed", lambda sv, *_: btn.set_visible(sv.get_collapsed())
            )
        )
        self._sidebar_box.append(btn)
        return btn

    def _create_list_controls(self, view):
        """Create search/filter/sort buttons in the sidebar header for a list view."""
        search_btn = Gtk.ToggleButton(icon_name="system-search-symbolic", tooltip_text=_("Search"))
        search_btn.add_css_class("flat")
        view.set_search_button(search_btn)
        filter_btn = Gtk.MenuButton(icon_name="filter-symbolic", tooltip_text=_("Filter"))
        filter_btn.set_menu_model(view.build_filter_menu())
        filter_btn.add_css_class("flat")
        sort_btn = Gtk.MenuButton(icon_name="view-sort-descending-symbolic", tooltip_text=_("Sort"))
        sort_btn.set_menu_model(view.build_sort_menu())
        sort_btn.add_css_class("flat")
        for btn in (sort_btn, filter_btn, search_btn):
            self._sidebar_header.pack_end(btn)
            self._sidebar_header_extras.append(btn)

    def _switch_view(self, view_id: str):
        """Switch the sidebar and content based on the selected navigation."""
        # Discard unapplied settings changes when navigating away
        if self._settings_view_inst is not None and view_id != "settings":
            self._settings_view_inst.restore_snapshot()
        if self._repeater_view_inst is not None:
            self._repeater_view_inst.clear()
        if self._map_view_inst is not None and view_id != "map":
            vp = self._map_view_inst._map.get_viewport()
            self._saved_map_state = (
                vp.get_zoom_level(),
                vp.get_latitude(),
                vp.get_longitude(),
            )
            from meshy.views import deregister_map_widget

            deregister_map_widget(self._map_view_inst._map)
            self._map_view_inst.cleanup()
            self._map_view_inst = None
            import ctypes
            import gc

            gc.collect()
            try:
                ctypes.CDLL("libc.so.6").malloc_trim(0)
            except OSError:
                pass
        self._clear_box(self._sidebar_box)
        self._clear_box(self._content_box)
        # Disconnect previous collapsed handlers to prevent leaks
        for handler_id in self._collapsed_handler_ids:
            self._split_view.disconnect(handler_id)
        self._collapsed_handler_ids.clear()
        # Remove any extra buttons from content headerbar
        if hasattr(self, "_content_header_extra") and self._content_header_extra:
            self._content_header.remove(self._content_header_extra)
            self._content_header_extra = None
        # Remove extra buttons from sidebar headerbar
        for btn in self._sidebar_header_extras:
            self._sidebar_header.remove(btn)
        self._sidebar_header_extras.clear()
        # Reset custom title widget and sidebar title visibility
        self._content_header.set_title_widget(None)
        self._sidebar_header.set_show_title(True)
        self._content_title_btn = None
        # Remove view-specific action groups from window
        for prefix in ("contacts", "channel"):
            self.insert_action_group(prefix, None)

        if view_id == "device":
            # Sidebar = Device Info button + Actions, content = device info
            self._sidebar_page.set_title(_("Actions"))
            self._content_page.set_title(_("Device Information"))
            self._add_sidebar_nav_button(_("Device Information"))
            actions = self._device_view.build_sidebar_actions()
            actions.set_margin_start(12)
            actions.set_margin_end(12)
            actions.set_margin_top(8)
            self._sidebar_box.append(actions)
            self._content_box.append(self._device_view)
            self._split_view.set_show_content(True)

        elif view_id == "contacts":
            self._sidebar_page.set_title(_("Contacts"))
            self._content_page.set_title(_("Chat"))
            self._sidebar_header.set_show_title(False)
            self._sidebar_box.append(self._contacts_view)
            self._content_box.append(self._chat_view)
            self.insert_action_group("contacts", self._contacts_view._action_group)
            self._create_list_controls(self._contacts_view)
            if self._chat_view_inst and self._chat_view_inst._contact:
                self._set_contact_title_widget(self._chat_view_inst._contact)
            self._split_view.set_show_content(False)

        elif view_id == "channels":
            self._sidebar_page.set_title(_("Channels"))
            self._content_page.set_title(_("Channel"))
            self._sidebar_header.set_show_title(False)
            self._sidebar_box.append(self._channels_view)
            self._content_box.append(self._channels_view.get_chat_widget())
            self.insert_action_group("channel", self._channels_view._action_group)
            self._create_list_controls(self._channels_view)
            selected = self._channels_view._channel_list.get_selected_row()
            if selected and hasattr(selected, "_channel"):
                self._content_page.set_title(
                    selected._channel.name or _("Channel {}").format(selected._channel.index)
                )
            self._split_view.set_show_content(False)

        elif view_id == "map":
            self._sidebar_page.set_title(_("Filter"))
            self._content_page.set_title(_("Map"))

            # Navigation button for collapsed mode
            self._add_sidebar_nav_button(_("Map"))

            # Filter controls
            self._sidebar_box.append(self._map_view.build_sidebar_filters())

            self._map_view.update_contacts(self._contacts)
            self._map_view.update_discovered(self._discovered)
            if self._saved_map_state:
                zoom, lat, lon = self._saved_map_state
                vp = self._map_view._map.get_viewport()
                vp.set_zoom_level(zoom)
                vp.set_location(lat, lon)
                self._saved_map_state = None
            self._content_box.append(self._map_view)
            self._split_view.set_show_content(True)

        elif view_id == "settings":
            # Sidebar = Settings button + hint, content = settings with Apply in headerbar
            self._sidebar_page.set_title(_("Backup & Restore"))
            self._content_page.set_title(_("Settings"))
            self._add_sidebar_nav_button(_("Settings"))

            sb = Gtk.Builder.new_from_resource(
                "/page/codeberg/sesivany/Meshy/ui/settings-sidebar.ui"
            )
            sb.get_object("backup_btn").connect("clicked", self._settings_view._on_export_backup)
            sb.get_object("restore_btn").connect("clicked", self._settings_view._on_import_backup)
            sb.get_object("factory_reset_btn").connect(
                "clicked", self._device_view._on_factory_reset
            )
            self._sidebar_box.append(sb.get_object("backup_group"))
            self._sidebar_box.append(sb.get_object("spacer"))
            self._sidebar_box.append(sb.get_object("reset_group"))

            self._content_box.append(self._settings_view)
            self._settings_view.load_regions()
            self._settings_view.restore_default_scope()
            apply_btn = self._settings_view.create_apply_button()
            self._content_header.pack_end(apply_btn)
            self._content_header_extra = apply_btn
            self._split_view.set_show_content(True)

    def _build_section_nav(self, view) -> Gtk.Widget:
        """Build a section navigation list for views that have sections."""
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        listbox = Gtk.ListBox()
        listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
        listbox.add_css_class("navigation-sidebar")

        section_icons = {
            "Connection": "network-wireless-symbolic",
            "Device Information": "computer-symbolic",
            "Battery & Storage": "battery-symbolic",
            "Radio Configuration": "preferences-system-symbolic",
            "Radio": "preferences-system-symbolic",
            "Actions": "system-run-symbolic",
            "Advert": "network-wireless-symbolic",
            "Auto-Add Contacts": "avatar-default-symbolic",
            "Location": "find-location-symbolic",
            "Telemetry Privacy": "preferences-other-symbolic",
            "Regions": "network-workgroup-symbolic",
        }

        for section_name in view.sections:
            row = Adw.ActionRow(title=section_name)
            icon = section_icons.get(section_name, "go-next-symbolic")
            row.add_prefix(Gtk.Image.new_from_icon_name(icon))
            row.set_activatable(True)
            row._section_name = section_name
            listbox.append(row)

        def on_row_activated(lb, row):
            if hasattr(row, "_section_name"):
                view.scroll_to_section(row._section_name)
                self._split_view.set_show_content(True)

        listbox.connect("row-activated", on_row_activated)

        scroll = Gtk.ScrolledWindow(vexpand=True)
        scroll.set_child(listbox)
        box.append(scroll)
        return box

    def _set_contact_title_widget(self, contact: Contact):
        """Set the content header to a clickable contact name title widget."""
        self._content_page.set_title(contact.name)
        title_btn = Gtk.Button()
        title_btn.add_css_class("flat")
        title_box = Gtk.Box(spacing=4, halign=Gtk.Align.CENTER)
        self._content_title_label = Gtk.Label(
            label=_("{name} ({path})").format(name=contact.name, path=contact.path_label),
            ellipsize=Pango.EllipsizeMode.END,
        )
        self._content_title_label.add_css_class("heading")
        title_box.append(self._content_title_label)
        title_box.append(Gtk.Image.new_from_icon_name("go-down-symbolic"))
        title_btn.set_child(title_box)
        contact_key = contact.public_key_hex
        title_btn.connect(
            "clicked",
            lambda *_, key=contact_key: self._contacts_view._show_contact_detail(
                self._contacts_by_key.get(key, contact)
            ),
        )
        self._content_header.set_title_widget(title_btn)
        self._content_title_btn = title_btn

    def show_chat(self, contact: Contact):
        """Show chat view for a specific contact."""
        # If repeater view is in content, swap it for chat
        if self._repeater_view_inst and self._repeater_view_inst.get_parent() == self._content_box:
            self._repeater_view_inst.clear()
            self._content_box.remove(self._repeater_view_inst)
            self._content_box.append(self._chat_view)
        self._chat_view.set_contact(contact)
        self._set_contact_title_widget(contact)
        self._split_view.set_show_content(True)
        self._contacts_view.mark_unread(contact.public_key_hex, False)

    def _refresh_chat_header(self):
        """Update the chat header title if the current contact's path changed."""
        if not (self._chat_view_inst and self._chat_view_inst._contact):
            return
        key = self._chat_view_inst._contact.public_key_hex
        c = self._contacts_by_key.get(key)
        if c:
            self._chat_view_inst._contact = c
            if hasattr(self, "_content_title_label") and self._content_title_label:
                self._content_title_label.set_label(
                    _("{name} ({path})").format(name=c.name, path=c.path_label)
                )
            else:
                self._content_page.set_title(
                    _("{name} ({path})").format(name=c.name, path=c.path_label)
                )

    def show_repeater(self, contact: Contact, login_timestamp=None, is_admin=True):
        """Show repeater management view for a repeater contact."""
        # Swap chat view for repeater view if needed
        if self._chat_view_inst and self._chat_view_inst.get_parent() == self._content_box:
            self._content_box.remove(self._chat_view_inst)
            self._content_box.append(self._repeater_view)
        self._repeater_view.set_repeater(contact, login_timestamp, is_admin)
        self._content_header.set_title_widget(None)
        self._content_title_btn = None
        role = _("Management") if is_admin else _("Guest")
        self._content_page.set_title(_("{name} \u2014 {role}").format(name=contact.name, role=role))
        self._split_view.set_show_content(True)

    def mark_contact_read(self, public_key_hex: str):
        self._storage.set_contact_last_read_at(public_key_hex, datetime.now().timestamp())

    def get_contact_last_read_at(self, public_key_hex: str) -> float | None:
        return self._storage.get_contact_last_read_at(public_key_hex)

    def mark_channel_read(self, channel_index: int):
        self._storage.set_channel_last_read_at(channel_index, datetime.now().timestamp())

    def get_channel_last_read_at(self, channel_index: int) -> float | None:
        return self._storage.get_channel_last_read_at(channel_index)

    def _on_window_active_changed(self, window, pspec):
        """Clear unread indicator for the currently visible chat when window gains focus."""
        if not self.is_active():
            return
        if (
            self._current_view == "contacts"
            and self._chat_view_inst
            and self._chat_view_inst._contact
        ):
            self._contacts_view.mark_unread(self._chat_view_inst._contact.public_key_hex, False)
        elif self._current_view == "channels" and self._channels_view_inst:
            chat = self._channels_view_inst.get_chat_widget()
            if chat._current_channel:
                self._channels_view_inst.mark_unread(chat._current_channel.index, False)

    # ─── BLE Connection ───────────────────────────────────────

    def _setup_transport_callbacks(self, transport):
        self._conn_ctrl.setup_transport_callbacks(transport)

    def _setup_ble_callbacks(self):
        if self._ble:
            self._conn_ctrl.setup_transport_callbacks(self._ble)

    def _auto_connect(self):
        return self._conn_ctrl.auto_connect()

    def _update_companion_label(self):
        """Update the companion name + battery/signal icons in the header."""
        name = self._device_info.name
        if not name:
            self._companion_box.set_visible(False)
            return
        self._settings.set_string("last-device-name", name)
        self._companion_name_label.set_label(name)
        has_battery = self._device_info.battery_mv > 0
        if has_battery:
            pct = self._device_info.battery_percent
            icon = battery_icon_name(pct)
            self._companion_battery_icon.set_from_icon_name(icon)
            self._companion_battery_icon.set_tooltip_text(f"{pct}%")
        self._companion_battery_icon.set_visible(has_battery)
        has_icons = has_battery or self._companion_signal_icon.get_visible()
        self._companion_separator.set_visible(has_icons)
        self._companion_box.set_visible(True)

    def _update_signal_icon_tick(self):
        """Periodic callback to refresh the signal strength icon."""
        self._update_signal_icon()
        for key in list(self._direct_repeaters):
            snr, ts, miss = self._direct_repeaters[key]
            if key in self._repeaters_refreshed:
                self._direct_repeaters[key] = (snr, ts, 0)
            else:
                miss += 1
                if miss >= 5:
                    del self._direct_repeaters[key]
                else:
                    self._direct_repeaters[key] = (snr, ts, miss)
        self._repeaters_refreshed.clear()
        self._signal_icon_timer_id = GLib.timeout_add_seconds(60, self._update_signal_icon_tick)
        return GLib.SOURCE_REMOVE

    def _update_signal_icon(self):
        """Update the signal strength icon from repeater SNR data."""
        import time

        now = time.monotonic()
        stale = [k for k, (_, ts, _mc) in self._direct_repeaters.items() if now - ts > 600]
        for k in stale:
            del self._direct_repeaters[k]
        if self._direct_repeaters:
            best_snr = max(snr for snr, _, _mc in self._direct_repeaters.values())
            sf = self._device_info.radio_sf or 12
            icon = snr_to_icon(best_snr, sf)
            self._companion_signal_icon.set_from_icon_name(icon)
            quality = {
                "Excellent": _("Excellent"),
                "Good": _("Good"),
                "Fair": _("Fair"),
                "Poor": _("Poor"),
                "Very poor": _("Very poor"),
            }.get(snr_to_label(best_snr, sf), "")
            self._companion_signal_icon.set_tooltip_text(f"{quality}, SNR: {best_snr:.1f} dB")
            self._companion_signal_icon.set_visible(True)
        else:
            self._companion_signal_icon.set_from_icon_name("strength-bars-none-symbolic")
            self._companion_signal_icon.set_tooltip_text(None)
            self._companion_signal_icon.set_visible(True)
        has_icons = (
            self._companion_battery_icon.get_visible() or self._companion_signal_icon.get_visible()
        )
        self._companion_separator.set_visible(has_icons)

    def _save_tcp_companion_if_needed(self):
        """Save or update the current TCP companion in GSettings."""
        if self._settings.get_string("last-transport") != "tcp":
            return
        addr = self._transport.connected_address
        name = self._device_info.name
        if not addr or not name:
            return
        entry = f"{name}|{addr}"
        companions = list(self._settings.get_strv("tcp-companions"))
        companions = [c for c in companions if not c.endswith(f"|{addr}")]
        companions.append(entry)
        self._settings.set_strv("tcp-companions", companions)

    def _remove_tcp_companion(self, address: str):
        """Remove a saved TCP companion by address."""
        companions = list(self._settings.get_strv("tcp-companions"))
        companions = [c for c in companions if not c.endswith(f"|{address}")]
        self._settings.set_strv("tcp-companions", companions)

    def _show_connecting_screen(self, address=""):
        """Show the auto-connecting status screen."""
        name = self._settings.get_string("last-device-name")
        if name:
            self._connecting_status.set_description(f"{name} ({address})" if address else name)
        else:
            self._connecting_status.set_description(address)
        self._connecting_spinner.set_spinning(True)
        self._main_stack.set_visible_child_name("connecting")

    def _show_connection_screen(self):
        """Show the full-window connection screen."""
        self._connecting_spinner.set_spinning(False)
        if self._connection_view.get_parent():
            self._connection_view.get_parent().remove(self._connection_view)
        self._connection_box.append(self._connection_view)
        self._connection_screen_banner.set_revealed(False)
        self._main_stack.set_visible_child_name("connection")
        self._connection_view.refresh_paired_devices()

    def _hide_connection_screen(self):
        """Hide connection/connecting screen and switch to main app layout."""
        from_connection = self._main_stack.get_visible_child_name() == "connection"
        self._connecting_spinner.set_spinning(False)
        self._connection_view.stop_polling()
        if self._connection_view.get_parent():
            self._connection_view.get_parent().remove(self._connection_view)
        if from_connection:
            self._sidebar_banner.set_title(self._connection_screen_banner.get_title())
            self._sidebar_banner.set_button_label(self._connection_screen_banner.get_button_label())
            self._sidebar_banner.set_revealed(self._connection_screen_banner.get_revealed())
        self._main_stack.set_visible_child_name("main")
        self._current_view = "device"
        self._switch_view("device")
        self._nav_buttons["device"].set_active(True)

    def connect_to_device(self, device, transport=None):
        self._conn_ctrl.connect_to_device(device, transport)

    def _on_menu_disconnect(self, *_args):
        """Handle Disconnect from hamburger menu."""
        self._conn_ctrl.manual_disconnect = True
        if self._conn_ctrl.reconnect_timer_id is not None:
            self._conn_ctrl.cancel_reconnect()
            self._conn_ctrl.reconnect_attempts = 0
        self._transport.disconnect()

    def _on_connect_clicked(self, banner):
        # Cancel button during auto-reconnect delay
        if self._conn_ctrl.reconnect_timer_id is not None:
            self._conn_ctrl.cancel_reconnect()
            self._conn_ctrl.reconnect_attempts = 0
            self._conn_ctrl.manual_disconnect = True
            self._settings.set_string("last-device-address", "")
            self._connection_banner.set_title(_("Not connected"))
            self._connection_banner.set_button_label(_("Connect"))
            self._show_connection_screen()
            return
        # Cancel button during auto-reconnect connecting phase
        if (
            self._transport.state == ConnectionState.CONNECTING
            and self._conn_ctrl.reconnect_attempts > 0
        ):
            self._conn_ctrl.reconnect_attempts = 0
            self._conn_ctrl.manual_disconnect = True
            self._transport.disconnect()
            self._settings.set_string("last-device-address", "")
            self._connection_banner.set_title(_("Not connected"))
            self._connection_banner.set_button_label(_("Connect"))
            self._show_connection_screen()
            return
        if self._transport.state == ConnectionState.CONNECTED:
            self._conn_ctrl.manual_disconnect = True
            self._transport.disconnect()
        elif self._transport.state == ConnectionState.DISCONNECTED:
            last_addr = self._settings.get_string("last-device-address")
            if last_addr:
                self._connection_banner.set_title(_("Connecting..."))
                self._transport.connect_by_address(last_addr)
            else:
                self._show_scan_dialog()
        elif self._transport.state == ConnectionState.SCANNING:
            self._transport.stop_scan()

    def _show_scan_dialog(self):
        self._conn_ctrl.show_scan_dialog()

    def _on_scan_device_selected(self, listbox, row):
        self._conn_ctrl._on_scan_device_selected(listbox, row)

    def _on_ble_device_discovered(self, device: BleDevice):
        self._conn_ctrl.on_ble_device_discovered(device)

    def _on_ble_state_changed(self, state: ConnectionState):
        self._conn_ctrl.on_ble_state_changed(state)

    def _on_connected(self):
        self._conn_ctrl.on_connected()

    def _apply_default_scope(self):
        scope = self._settings_view.default_scope
        # Suppress error for older firmware that doesn't support SET_DEFAULT_FLOOD_SCOPE
        self._ignore_next_err = True
        self.send_frame(build_set_default_flood_scope(scope))
        return False

    def _reset_session_scope(self):
        """Reset session flood scope to null so firmware uses the persistent default."""
        self.send_frame(build_set_flood_scope(""))
        return False

    def _poll_battery(self) -> bool:
        return self._conn_ctrl.poll_battery()

    def _connection_watchdog(self) -> bool:
        return self._conn_ctrl.connection_watchdog()

    # ─── Data Handling ─────────────────────────────────────────

    def _on_ble_data_received(self, data: bytes):
        """Handle incoming data from the BLE device."""
        self._conn_ctrl.last_device_rx = time.monotonic()
        GLib.idle_add(self._handle_frame, data)

    def _debug_log(self, msg: str):
        log.debug(msg)

    def _handle_frame(self, data: bytes):
        """Process a received protocol frame on the main thread."""
        return self._frame_handler.handle_frame(data)

    def _send_notification(self, title: str, body: str, notif_id: str):
        """Send a desktop notification using platform-specific service."""
        self._notification_service.send_notification(title, body, notif_id)

    # ─── Public API for views ─────────────────────────────────

    def send_message(self, contact: Contact, text: str, route_override: str | None = None):
        self._msg_ctrl.send_message(contact, text, route_override=route_override)

    def resend_message(self, contact: Contact, message: Message):
        self._msg_ctrl.resend_message(contact, message)

    def send_channel_message(self, channel_index: int, text: str):
        self._msg_ctrl.send_channel_message(channel_index, text)

    def resend_channel_message(self, channel_index: int, text: str) -> int:
        return self._msg_ctrl.resend_channel_message(channel_index, text)

    def delete_message(self, message_id: str):
        self._msg_ctrl.delete_message(message_id)

    def delete_channel_message(self, channel_index: int, timestamp: float, text: str):
        self._msg_ctrl.delete_channel_message(channel_index, timestamp, text)

    def send_cli_command(self, contact: Contact, command: str):
        """Send a CLI command to a repeater."""
        frame = build_send_cli_command(contact.public_key, command)
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(frame)

    def send_cli_with_prefix(self, contact_pub_key: bytes, command: str, callback):
        """Send a CLI command with a correlation prefix.

        The repeater firmware echoes the prefix back, allowing reliable
        matching of responses to commands.
        """
        prefix = f"{self._cli_prefix_counter:02X}|"
        self._cli_prefix_counter = (self._cli_prefix_counter + 1) & 0xFF
        self._cli_pending_prefixes[prefix] = callback
        frame = build_send_cli_command(contact_pub_key, prefix + command)
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(frame)

    def cancel_cli_prefixes(self, prefixes):
        """Remove pending prefix callbacks (e.g. when cancelling a fetch)."""
        for p in prefixes:
            self._cli_pending_prefixes.pop(p, None)

    def send_advert(self, flood: bool = False, silent: bool = False):
        self.send_frame(build_send_self_advert(flood))
        if not silent:
            mode = _("flood routed") if flood else _("zero-hop")
            self.show_toast(_("Advert sent ({})").format(mode))

    @property
    def discovered_contacts(self) -> list:
        return self._discovered

    def _add_local_contact(self, contact: Contact):
        """Add a contact to the local list and UI immediately."""
        key = contact.public_key_hex
        if key not in self._contacts_by_key:
            self._contacts.append(contact)
        self._contacts_by_key[key] = contact
        self._storage.save_contact(contact)
        self._contacts_view.update_contacts(self._contacts)

    def add_discovered_contact(self, contact: Contact):
        """Move a contact from discovered to the device's contact list."""
        self.send_frame(
            build_add_update_contact(
                contact.public_key,
                contact_type=contact.type,
                name=contact.name,
                path_len=0xFF,
                lat=contact.latitude,
                lon=contact.longitude,
            )
        )
        # Remove from discovered list
        self._discovered = [
            c for c in self._discovered if c.public_key_hex != contact.public_key_hex
        ]
        self._discovered_by_key.pop(contact.public_key_hex, None)
        # Add to local list immediately
        self._add_local_contact(contact)
        # Also refresh from device to get firmware-assigned fields
        GLib.timeout_add(500, lambda: self._frame_handler.send_get_contacts() or False)

    def request_regions(self, contact, callback, use_path=False):
        """Send an anonymous region request to a repeater.

        When *use_path* is False (default) a zero-hop request is sent,
        reaching only directly reachable repeaters.  When True, the
        contact's stored path is used so multi-hop repeaters can be reached.
        """
        callback_key = contact.public_key_hex[:12]
        self._region_callbacks[callback_key] = callback
        if use_path and getattr(contact, "path_length", -1) > 0:
            path = getattr(contact, "path", b"")
            frame = build_anon_req_regions(contact.public_key, contact.path_length, path)
        else:
            frame = build_anon_req_regions(contact.public_key, 0, b"")
        self._msg_ctrl.sent_msg_queue.append(f"region:{callback_key}")
        self.send_frame(frame)

    def send_discover_req(self, callback):
        """Send a NODE_DISCOVER_REQ control packet. Callback receives each response."""
        self._discover_callback = callback
        self.send_frame(build_send_discover_req(prefix_only=False))

    def stop_discover(self):
        """Stop listening for discover responses."""
        self._discover_callback = None

    def send_trace_path(self, path_bytes: bytes, callback, timeout_callback=None, flags: int = 0):
        """Send a SEND_TRACE_PATH command. Callback receives trace result dict."""
        self._trace_callback = callback
        self._trace_timeout_callback = timeout_callback
        tag = int(datetime.now().timestamp())
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(build_trace_path(tag, path_bytes, flags))

    def stop_trace_path(self):
        """Stop listening for trace responses."""
        self._trace_callback = None
        self._trace_timeout_callback = None

    def set_flood_scope(self, scope: str):
        """Set flood scope. Empty to disable, '#name' for hashtag scope."""
        self.send_frame(build_set_flood_scope(scope))

    def get_advert_path(self, pub_key: bytes, callback):
        """Get the advertisement path cached for a contact."""
        self._advert_path_callback = callback
        self.send_frame(build_get_advert_path(pub_key))

    def send_path_discovery(self, pub_key: bytes, callback):
        """Send a path discovery request. Callback receives raw response data."""
        self._path_discovery_callback = callback
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(build_path_discovery(pub_key))

    def stop_path_discovery(self):
        """Stop listening for path discovery responses."""
        self._path_discovery_callback = None

    _LOGIN_FLOOD_RETRY_MS = 7000

    def send_login(self, pub_key: bytes, password: str, callback, on_retry=None):
        """Send a room/repeater login. Callback receives True (success) or False (fail).

        If the first (directed) attempt gets no response within 7s, the cached
        path is reset and the login is retried via flood.  *on_retry* is called
        (no args) when the flood retry fires, so the UI can update its status.
        """
        from meshy.protocol import build_send_login

        self._login_callback = callback
        self._login_target_prefix = pub_key[:6]
        self._login_pub_key = pub_key
        self._login_password = password
        self._login_on_retry = on_retry
        frame = build_send_login(pub_key, password)
        log.info(f"Sending login to {pub_key.hex()[:16]}...")
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(frame)
        self._login_retry_timeout_id = GLib.timeout_add(
            self._LOGIN_FLOOD_RETRY_MS, self._on_login_flood_retry
        )

    def _on_login_flood_retry(self):
        self._login_retry_timeout_id = None
        if not self._login_callback:
            return False
        log.info("Login directed attempt timed out, resetting path and retrying via flood")
        from meshy.protocol import build_reset_path, build_send_login

        self.send_frame(build_reset_path(self._login_pub_key))
        frame = build_send_login(self._login_pub_key, self._login_password)
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(frame)
        self._login_was_flood_retry = True
        if self._login_on_retry:
            self._login_on_retry()
        return False

    def stop_login(self):
        """Stop listening for login responses."""
        self._login_callback = None
        self._login_target_prefix = None
        self._login_pub_key = None
        self._login_password = None
        self._login_on_retry = None
        if self._login_retry_timeout_id:
            GLib.source_remove(self._login_retry_timeout_id)
            self._login_retry_timeout_id = None

    def is_room_logged_in(self, pub_key_hex: str) -> bool:
        return pub_key_hex in self._logged_in_rooms

    def is_room_admin(self, pub_key_hex: str) -> bool:
        return self._logged_in_rooms.get(pub_key_hex, False)

    def mark_room_logged_in(self, pub_key_hex: str, is_admin: bool = True):
        self._logged_in_rooms[pub_key_hex] = is_admin

    def logout_repeater(self, pub_key_hex: str):
        """Log out of a repeater/room — clear local state and return to contacts."""
        self._logged_in_rooms.pop(pub_key_hex, None)
        if self._repeater_view_inst and self._repeater_view_inst.get_parent() == self._content_box:
            self._repeater_view_inst.clear()
            self._content_box.remove(self._repeater_view_inst)
            self._content_box.append(self._chat_view)
        self._split_view.set_show_content(False)

    def send_status_req(self, pub_key: bytes, callback):
        """Send a status request to a repeater. Callback receives parsed status dict."""
        self._status_callback = callback
        self._msg_ctrl.sent_msg_queue.append(None)
        self.send_frame(build_send_status_req(pub_key))

    def get_neighbors(self, repeater_pub_key: bytes, callback, offset: int = 0):
        """Request neighbor list from a repeater. Callback receives dict with 'total' and 'neighbors'."""
        self._neighbors_callback = callback
        self._msg_ctrl.sent_msg_queue.append({"type": "neighbors", "callback": callback})
        self.send_frame(build_get_neighbors(repeater_pub_key, offset=offset))

    def get_acl(self, repeater_pub_key: bytes, callback):
        """Request ACL from a repeater. Callback receives raw binary response."""
        from meshy.protocol import build_get_acl

        self._acl_callback = callback
        self._msg_ctrl.sent_msg_queue.append({"type": "acl", "callback": callback})
        self.send_frame(build_get_acl(repeater_pub_key))

    def get_telemetry(self, pub_key: bytes, callback, contact_type: int = None):
        """Request telemetry. Uses CMD_SEND_TELEMETRY_REQ for chat companions,
        CMD_SEND_BINARY_REQ for repeaters/rooms/sensors."""
        from meshy.models import ContactType
        from meshy.protocol import build_get_telemetry, build_get_telemetry_chat

        if contact_type == ContactType.CHAT:
            self._telemetry_callback = callback
            self.send_frame(build_get_telemetry_chat(pub_key))
        else:
            self._telemetry_callback = callback
            self._msg_ctrl.sent_msg_queue.append({"type": "telemetry", "callback": callback})
            self.send_frame(build_get_telemetry(pub_key))

    def get_self_telemetry(self, callback):
        """Request telemetry from the connected companion device itself."""
        from meshy.protocol import build_get_telemetry_chat

        self._telemetry_callback = callback
        self.send_frame(build_get_telemetry_chat())

    def find_contact_by_key_prefix(self, prefix_hex: str) -> Contact | None:
        """Find a contact whose public key starts with the given hex prefix."""
        for c in self._contacts:
            if c.public_key_hex.startswith(prefix_hex):
                return c
        for c in self._discovered:
            if c.public_key_hex.startswith(prefix_hex):
                return c
        return None

    def resolve_hop_prefixes(self, hop_hexes: list[str]) -> list[Contact | None]:
        """Resolve hop hex prefixes to contacts using geographic proximity.

        When multiple contacts match a prefix, pick the one closest to
        the previous hop.
        """
        import math

        def _distance_sq(lat1, lon1, lat2, lon2):
            dlat = lat1 - lat2
            dlon = (lon1 - lon2) * math.cos(math.radians((lat1 + lat2) / 2))
            return dlat * dlat + dlon * dlon

        ref_lat, ref_lon = None, None
        si = self._self_info
        ref_lat = si.get("adv_lat")
        ref_lon = si.get("adv_lon")
        if not ref_lat or not ref_lon or (abs(ref_lat) < 1e-6 and abs(ref_lon) < 1e-6):
            ref_lat, ref_lon = None, None

        all_contacts = list(self._contacts) + list(self._discovered)
        result = []
        for hop_hex in hop_hexes:
            prefix = hop_hex.lower()
            candidates = [
                c
                for c in all_contacts
                if (
                    c.type in (ContactType.REPEATER, ContactType.ROOM)
                    and c.public_key_hex.startswith(prefix)
                )
            ]

            if not candidates:
                result.append(None)
            elif len(candidates) == 1:
                found = candidates[0]
                result.append(found)
                if found.has_location:
                    ref_lat, ref_lon = found.latitude, found.longitude
            else:
                if ref_lat is not None and ref_lon is not None:
                    located = [c for c in candidates if c.has_location]
                    if located:
                        found = min(
                            located,
                            key=lambda c: _distance_sq(ref_lat, ref_lon, c.latitude, c.longitude),
                        )
                    else:
                        found = candidates[0]
                else:
                    found = candidates[0]
                result.append(found)
                if found.has_location:
                    ref_lat, ref_lon = found.latitude, found.longitude

        return result

    def is_contact_known(self, pub_key_hex: str) -> bool:
        """Check if a contact with this public key already exists."""
        return pub_key_hex in self._contacts_by_key

    def add_contact(self, pub_key: bytes, contact_type: int, name: str):
        """Add a contact manually by public key."""
        self.send_frame(
            build_add_update_contact(
                pub_key,
                contact_type=contact_type,
                name=name,
                path_len=0xFF,  # flood - no known path to new contact
            )
        )
        # Add to local list immediately
        contact = Contact(
            public_key=pub_key,
            name=name,
            type=contact_type,
            path_length=-1,
            is_active=True,
        )
        self._add_local_contact(contact)
        # Also refresh from device to get firmware-assigned fields
        GLib.timeout_add(500, lambda: self._frame_handler.send_get_contacts() or False)

    def import_contact(self, contact_frame: bytes):
        """Import a contact from a meshcore:// URI payload."""
        self.send_frame(build_import_contact(contact_frame))
        # Try to add to local list immediately by parsing the advert frame
        from meshy.application import MeshyApplication

        name, contact_type = MeshyApplication._parse_advert_name(contact_frame)
        if name:
            try:
                # Extract pub key from frame (after header + path)
                i = 0
                i += 1  # header
                path_len_byte = contact_frame[i]
                i += 1
                hop_count = path_len_byte & 0x3F
                hash_size = ((path_len_byte >> 6) & 0x03) + 1
                i += hop_count * hash_size
                pub_key = contact_frame[i : i + 32]
                if len(pub_key) == 32:
                    contact = Contact(
                        public_key=pub_key,
                        name=name,
                        type=contact_type,
                        path_length=-1,
                        is_active=True,
                    )
                    self._add_local_contact(contact)
            except (IndexError, ValueError):
                pass
        # Also refresh from device to get firmware-assigned fields
        GLib.timeout_add(500, lambda: self._frame_handler.send_get_contacts() or False)

    def export_self_to_clipboard(self):
        """Export self contact as meshcore:// URI to clipboard."""
        self._pending_clipboard_export = True
        self.send_frame(build_export_contact(b""))  # empty key = export self

    def set_custom_var(self, key_value: str):
        """Set a custom variable on the firmware (e.g. 'gps:1')."""
        self.send_frame(build_set_custom_var(key_value))

    def set_advert_name(self, name: str):
        self.send_frame(build_set_advert_name(name))

    def set_advert_latlon(self, lat: float, lon: float):
        self.send_frame(build_set_advert_latlon(lat, lon))

    def _build_telemetry_byte(self) -> int:
        si = self._self_info
        return (
            (si.get("telemetry_mode_env", 0) & 0x03) << 4
            | (si.get("telemetry_mode_loc", 0) & 0x03) << 2
            | (si.get("telemetry_mode_base", 0) & 0x03)
        )

    def _send_other_params(self):
        """Send CMD_SET_OTHER_PARAMS with current self_info values."""
        from meshy.protocol import build_set_other_params

        si = self._self_info
        self.send_frame(
            build_set_other_params(
                manual_add_contacts=int(si.get("manual_add_contacts", False)),
                telemetry_flags=self._build_telemetry_byte(),
                advert_loc_policy=si.get("adv_loc_policy", 0),
                multi_acks=si.get("multi_acks", 0),
            )
        )

    def set_advert_loc_policy(self, policy: int):
        """Set advert location policy (0=don't include, 1=include)."""
        self._self_info["adv_loc_policy"] = policy
        self._send_other_params()

    def set_telemetry_mode(self, base: int, loc: int, env: int):
        """Set telemetry privacy modes (0=deny, 1=allow by flags, 2=allow all)."""
        self._self_info["telemetry_mode_base"] = base
        self._self_info["telemetry_mode_loc"] = loc
        self._self_info["telemetry_mode_env"] = env
        self._send_other_params()

    def set_multi_acks(self, value: int):
        self._self_info["multi_acks"] = value
        self._send_other_params()

    def set_radio_params(
        self, freq_hz: int, bw_hz: int, sf: int, cr: int, client_repeat: bool | None = None
    ):
        self.send_frame(build_set_radio_params(freq_hz, bw_hz, sf, cr, client_repeat))

    def set_tx_power(self, power_dbm: int):
        self.send_frame(build_set_radio_tx_power(power_dbm))

    def set_device_pin(self, pin: int):
        self.send_frame(build_set_device_pin(pin))

    def remove_contact(self, contact: Contact):
        self.send_frame(build_remove_contact(contact.public_key))
        self._storage.remove_contact(contact.public_key_hex)
        self._contacts = [c for c in self._contacts if c.public_key_hex != contact.public_key_hex]
        self._contacts_by_key.pop(contact.public_key_hex, None)
        # Keep in _discovered so the name is available for re-adding or discovery
        if contact.public_key_hex not in self._discovered_by_key:
            self._frame_handler.add_or_update_discovered(contact)
        self._contacts_view.update_contacts(self._contacts)

    def reset_contact_path(self, contact: Contact):
        self.send_frame(build_reset_path(contact.public_key))
        # Update local state immediately so the UI reflects the change
        contact.path_length = -1
        contact.path = b""
        for i, c in enumerate(self._contacts):
            if c.public_key_hex == contact.public_key_hex:
                self._contacts[i].path_length = -1
                self._contacts[i].path = b""
                break
        self._contacts_view.update_contacts(self._contacts)
        # Also update the content header title if this contact's chat is open
        if (
            self._chat_view_inst
            and self._chat_view_inst._contact
            and self._chat_view_inst._contact.public_key_hex == contact.public_key_hex
        ):
            if hasattr(self, "_content_title_label") and self._content_title_label:
                self._content_title_label.set_label(
                    _("{name} ({path})").format(name=contact.name, path=contact.path_label)
                )
            else:
                self._content_page.set_title(
                    _("{name} ({path})").format(name=contact.name, path=contact.path_label)
                )

    def update_contact_flags(self, contact: Contact):
        """Update contact flags on the device (telemetry permissions, favorite, etc.)."""
        self.send_frame(
            build_add_update_contact(
                pub_key=contact.public_key,
                contact_type=contact.type,
                flags=contact.flags,
                path_len=(((contact.path_hash_size - 1) << 6) | (contact.path_length & 0x3F))
                if contact.path_length >= 0
                else 0xFF,
                path=contact.path,
                name=contact.name,
                lat=contact.latitude,
                lon=contact.longitude,
            )
        )

    def set_contact_path(
        self, contact: Contact, path_len: int, path: bytes = b"", name: str | None = None
    ):
        """Set a custom path and/or name for a contact. path_len=0xFF for flood."""
        self.send_frame(
            build_add_update_contact(
                pub_key=contact.public_key,
                contact_type=contact.type,
                flags=contact.flags,
                path_len=path_len,
                path=path,
                name=name or contact.name,
                lat=contact.latitude,
                lon=contact.longitude,
            )
        )
        # Optimistic update: reflect the change in local state immediately
        if path_len == 0xFF:
            new_path_length = -1
            new_path = b""
            new_hash_size = 1
        else:
            new_path_length = path_len & 0x3F
            new_hash_size = ((path_len >> 6) & 0x03) + 1
            new_path = path
        contact.path_length = new_path_length
        contact.path = new_path
        contact.path_hash_size = new_hash_size
        for i, c in enumerate(self._contacts):
            if c.public_key_hex == contact.public_key_hex:
                self._contacts[i].path_length = new_path_length
                self._contacts[i].path = new_path
                self._contacts[i].path_hash_size = new_hash_size
                break
        self._contacts_view.update_contacts(self._contacts)
        if (
            self._chat_view_inst
            and self._chat_view_inst._contact
            and self._chat_view_inst._contact.public_key_hex == contact.public_key_hex
        ):
            if hasattr(self, "_content_title_label") and self._content_title_label:
                self._content_title_label.set_label(
                    _("{name} ({path})").format(name=contact.name, path=contact.path_label)
                )
            else:
                self._content_page.set_title(
                    _("{name} ({path})").format(name=contact.name, path=contact.path_label)
                )

    def set_channel(self, index: int, name: str, psk: bytes):
        self.send_frame(build_set_channel(index, name, psk))
        # Re-fetch channel info so the UI updates
        GLib.timeout_add(500, lambda: self.send_frame(build_get_channel(index)) or False)

    def add_channel_from_qr(self, name: str, secret: bytes):
        """Add a channel scanned from a QR code, using the first empty slot."""
        for idx in range(self._device_info.max_channels):
            ch = next((c for c in self._channels if c.index == idx), None)
            if ch is None or ch.is_empty:
                self.set_channel(idx, name, secret)
                self.show_toast(_('Channel "{}" added').format(name))
                # Refresh channels after a delay
                GLib.timeout_add(700, self._fetch_all_channels)
                return
        self.show_toast(_("No empty channel slots available"))

    def _fetch_channels_sequential(self, index):
        self._conn_ctrl.fetch_channels_sequential(index)

    def _send_sync_next_message(self):
        self._conn_ctrl.send_sync_next_message()

    def _finish_msg_sync(self):
        self._conn_ctrl.finish_msg_sync()

    def _fetch_all_channels(self):
        self._conn_ctrl.fetch_channels_sequential(0)
        return False

    def remove_channel(self, channel: Channel):
        # Clear channel on device by setting empty name and zero PSK
        self.send_frame(build_set_channel(channel.index, "", bytes(16)))
        self._storage.remove_channel(channel.index)
        self._channels = [ch for ch in self._channels if ch.index != channel.index]
        self._channels_view.update_channels(self._channels)
        self._frame_handler.rebuild_key_store()

    def set_channel_notification_level(self, channel: Channel, level):
        channel.notification_level = level
        self._storage.set_channel_notification_level(channel.index, level)

    def reboot_device(self):
        self.send_frame(build_reboot())

    def factory_reset_device(self):
        from meshy.protocol import build_factory_reset

        self.send_frame(build_factory_reset())

    def refresh_contacts(self):
        self._frame_handler.send_get_contacts()

    def refresh_stats(self):
        self.send_frame(build_get_stats(STATS_TYPE_CORE))
        GLib.timeout_add(200, lambda: self.send_frame(build_get_stats(STATS_TYPE_RADIO)) or False)
        GLib.timeout_add(400, lambda: self.send_frame(build_get_stats(STATS_TYPE_PACKETS)) or False)

    def set_path_hash_mode(self, mode: int):
        """Set the device's path hash mode: 0=1-byte, 1=2-byte, 2=3-byte."""
        self.send_frame(build_set_path_hash_mode(mode))
        self._device_info.path_hash_mode = mode
        self._device_view.update_device_info(self._device_info)

    # ─── Public API for views ─────────────────────────────────

    @property
    def storage(self) -> Storage | None:
        return self._storage

    @property
    def self_info(self) -> dict:
        return self._self_info

    @property
    def split_view(self):
        return self._split_view

    @property
    def content_page(self):
        return self._content_page

    @property
    def toast_overlay(self):
        return self._toast_overlay

    @property
    def ble_transport(self):
        return self._ble

    @property
    def rx_log(self) -> list:
        return self._rx_log

    @property
    def cli_prefix_counter(self) -> int:
        return self._cli_prefix_counter

    @cli_prefix_counter.setter
    def cli_prefix_counter(self, value: int):
        self._cli_prefix_counter = value

    def show_toast(self, message: str, timeout: int = 3):
        toast = Adw.Toast(title=message, timeout=timeout)
        self._toast_overlay.add_toast(toast)

    def update_nav_unread_contacts(self):
        self._nav_contacts_dot.set_visible(bool(self._contacts_view._unread_keys))

    def update_nav_unread_channels(self):
        self._nav_channels_dot.set_visible(bool(self._channels_view._unread_indices))

    def send_frame(self, frame: bytes):
        self._transport.send_data(frame)

    def refresh_chat_header(self):
        self._refresh_chat_header()

    def show_contact_detail(self, contact: Contact):
        self._contacts_view._show_contact_detail(contact)

    def get_regions(self) -> list[str]:
        if self._storage:
            return self._storage.get_regions()
        return []

    def set_cli_response_callback(self, callback):
        self._cli_response_callback = callback

    def set_rx_log_callback(self, callback):
        self._rx_log_callback = callback

    def set_advert_listener(self, callback):
        self._advert_listener = callback

    def set_status_callback(self, callback):
        self._status_callback = callback

    def set_neighbors_callback(self, callback):
        self._neighbors_callback = callback
        if callback is None:
            self._cancel_pending_binary("neighbors")

    def set_acl_callback(self, callback):
        self._acl_callback = callback
        if callback is None:
            self._cancel_pending_binary("acl")

    def _cancel_pending_binary(self, req_type: str):
        stale = [k for k, v in self._pending_binary_requests.items() if v["type"] == req_type]
        for k in stale:
            del self._pending_binary_requests[k]

    def set_advert_path_callback(self, callback):
        self._advert_path_callback = callback

    def remove_tcp_companion(self, address: str):
        self._remove_tcp_companion(address)

    def get_messages_for_contact(self, contact, limit=30, offset=0):
        return self._storage.get_messages(contact.public_key_hex, limit, offset)

    def get_channel_messages(self, channel_index, limit=30, offset=0):
        return self._storage.get_channel_messages(channel_index, limit, offset)

    @property
    def contacts(self) -> list[Contact]:
        return self._contacts

    def update_contacts_from_storage(self):
        """Reload contact flags and names from storage and refresh the view."""
        for i, c in enumerate(self._contacts):
            stored = self._storage.get_contact(c.public_key_hex)
            if stored:
                self._contacts[i].flags = stored.flags
                self._contacts[i].name = stored.name
        self._contacts_view.update_contacts(self._contacts)

    @property
    def channels(self) -> list[Channel]:
        return self._channels

    @property
    def device_info(self) -> DeviceInfo:
        return self._device_info

    @property
    def is_connected(self) -> bool:
        return self._transport.is_connected

    # ─── Backup & Restore ──────────────────────────────────────

    def get_auto_add_config(self) -> dict:
        return self._settings_view.get_auto_add_config()

    def export_backup(self) -> dict:
        return self._backup.export_backup(self)

    def import_backup(self, backup: dict, restore_messages: bool = True):
        return self._backup.import_backup(self, backup, restore_messages)
