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

"""Contacts list view - shown in the sidebar."""

import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('Shumate', '1.0')
from gi.repository import Adw, Gdk, Gio, GObject, Gtk, GLib, Pango, Shumate

from meshy.models import Contact, ContactType
from meshy.protocol import build_sync_next_message
from meshy.views import (
    _add_hover_swap, _ContactPickItem,
    _contact_pick_factory_setup, _contact_pick_factory_bind,
    _contact_pick_factory_unbind,
)


@Gtk.Template(resource_path='/page/codeberg/sesivany/Meshy/ui/contacts-view.ui')
class ContactsView(Gtk.Box):
    __gtype_name__ = 'MeshyContactsView'

    _search_entry = Gtk.Template.Child('search_entry')
    _list_box = Gtk.Template.Child('list_box')
    _status_label = Gtk.Template.Child('status_label')
    _add_btn = Gtk.Template.Child('add_btn')

    # Filter constants
    FILTER_ALL = 'all'
    FILTER_FAVORITES = 'favorites'
    FILTER_USERS = 'users'
    FILTER_REPEATERS = 'repeaters'
    FILTER_ROOMS = 'rooms'
    FILTER_SENSORS = 'sensors'

    # Sort constants
    SORT_AZ = 'az'
    SORT_HEARD = 'heard'
    SORT_MESSAGES = 'messages'

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._contact_rows: dict[str, Adw.ActionRow] = {}
        self._unread_keys: set[str] = set()
        self._unread_counts: dict[str, int] = {}
        self._unread_dots: dict[str, Gtk.Label] = {}
        self._current_filter = self.FILTER_ALL
        self._current_sort = self.SORT_HEARD
        self._favorites_first = True
        self._login_dialog_open = False
        self._total_contacts = 0

        # Register actions
        action_group = Gio.SimpleActionGroup()
        filter_action = Gio.SimpleAction.new_stateful(
            'filter', GLib.VariantType.new('s'),
            GLib.Variant.new_string(self._current_filter))
        filter_action.connect('activate', self._on_filter_changed)
        action_group.add_action(filter_action)
        sort_action = Gio.SimpleAction.new_stateful(
            'sort', GLib.VariantType.new('s'),
            GLib.Variant.new_string(self._current_sort))
        sort_action.connect('activate', self._on_sort_changed)
        action_group.add_action(sort_action)
        fav_action = Gio.SimpleAction.new_stateful(
            'favorites-first', None, GLib.Variant.new_boolean(True))
        fav_action.connect('change-state', self._on_favorites_first_toggled)
        action_group.add_action(fav_action)

        # Add contact menu (conditional QR item)
        add_menu = Gio.Menu()
        add_menu.append(_('Discover Contacts'), 'contacts.discover')
        add_menu.append(_('Add Manually'), 'contacts.add-manual')
        add_menu.append(_('Import from Clipboard'), 'contacts.import-clipboard')
        import meshy as _meshy_pkg
        if _meshy_pkg.QR_SCANNER_ENABLED:
            add_menu.append(_('Scan QR Code'), 'contacts.scan-qr')
        self._add_btn.set_menu_model(add_menu)

        add_action = Gio.SimpleAction.new('add-manual', None)
        add_action.connect('activate', self._on_add_manual)
        action_group.add_action(add_action)

        discover_action = Gio.SimpleAction.new('discover', None)
        discover_action.connect('activate', self._on_discover_contacts)
        action_group.add_action(discover_action)

        clipboard_action = Gio.SimpleAction.new('import-clipboard', None)
        clipboard_action.connect('activate', self._on_import_clipboard)
        action_group.add_action(clipboard_action)

        if _meshy_pkg.QR_SCANNER_ENABLED:
            qr_action = Gio.SimpleAction.new('scan-qr', None)
            qr_action.connect('activate', self._on_scan_qr)
            action_group.add_action(qr_action)

        self._action_group = action_group
        self.insert_action_group('contacts', action_group)
        self._search_btn = None
        self._search_binding = None

        # Signal connections
        self._search_entry.connect('search-changed', self._on_search_changed)
        self._list_box.set_filter_func(self._filter_func)
        self._list_box.connect('row-activated', self._on_row_selected)

        # Initial populate
        self.update_contacts(self._window.contacts)

    def set_search_button(self, btn):
        if self._search_binding:
            self._search_binding.unbind()
        self._search_btn = btn
        self._search_binding = btn.bind_property(
            'active', self._search_entry, 'visible',
            GObject.BindingFlags.SYNC_CREATE)
        self._search_entry.connect('stop-search', lambda _e: btn.set_active(False))

    def build_filter_menu(self):
        menu = Gio.Menu()
        for filter_id, label in (
            ('all', _('All')), ('favorites', _('Favourites')),
            ('users', _('Users')), ('repeaters', _('Repeaters')),
            ('rooms', _('Room Servers')), ('sensors', _('Sensors')),
        ):
            item = Gio.MenuItem.new(label, None)
            item.set_action_and_target_value(
                'contacts.filter', GLib.Variant.new_string(filter_id))
            menu.append_item(item)
        return menu

    def build_sort_menu(self):
        menu = Gio.Menu()
        section1 = Gio.Menu()
        for sort_id, label in (
            ('az', _('A–Z')), ('heard', _('Heard Recently')),
            ('messages', _('Latest Messages')),
        ):
            item = Gio.MenuItem.new(label, None)
            item.set_action_and_target_value(
                'contacts.sort', GLib.Variant.new_string(sort_id))
            section1.append_item(item)
        menu.append_section(None, section1)
        section2 = Gio.Menu()
        section2.append(_('Favorites First'), 'contacts.favorites-first')
        menu.append_section(None, section2)
        return menu

    def _filter_func(self, row) -> bool:
        if not hasattr(row, '_contact'):
            return True
        contact = row._contact

        # Type filter
        f = self._current_filter
        if f == self.FILTER_FAVORITES and not contact.is_favorite:
            return False
        elif f == self.FILTER_USERS and contact.type != ContactType.CHAT:
            return False
        elif f == self.FILTER_REPEATERS and contact.type != ContactType.REPEATER:
            return False
        elif f == self.FILTER_ROOMS and contact.type != ContactType.ROOM:
            return False
        elif f == self.FILTER_SENSORS and contact.type != ContactType.SENSOR:
            return False

        # Search filter
        search = self._search_entry.get_text().lower()
        if search and search not in contact.name.lower():
            return False

        return True

    def _update_status_count(self):
        """Update the status label with visible/total contact count."""
        total = self._total_contacts
        if self._current_filter != self.FILTER_ALL or self._search_entry.get_text():
            # Count rows that pass the filter
            visible = sum(1 for row in self._contact_rows.values()
                          if self._filter_func(row))
            self._status_label.set_label(
                _('{visible}/{total} contacts').format(visible=visible, total=total))
        else:
            self._status_label.set_label(
                _('{total} contacts').format(total=total) if total != 1
                else _('{total} contact').format(total=total))

    def _on_search_changed(self, entry):
        self._list_box.invalidate_filter()
        GLib.idle_add(self._update_status_count)

    def _on_filter_changed(self, action, param):
        self._current_filter = param.get_string()
        action.set_state(param)
        self._list_box.invalidate_filter()
        GLib.idle_add(self._update_status_count)

    def _on_sort_changed(self, action, param):
        self._current_sort = param.get_string()
        action.set_state(param)
        self.update_contacts(self._window.contacts)

    def _on_favorites_first_toggled(self, action, value):
        action.set_state(value)
        self._favorites_first = value.get_boolean()
        self.update_contacts(self._window.contacts)

    def _on_discover_contacts(self, *args):
        discovered = [c for c in self._window.discovered_contacts
                      if not self._window.is_contact_known(c.public_key_hex)]
        if not discovered:
            dialog = Adw.AlertDialog(
                heading=_('Discover Contacts'),
                body=_('No new contacts have been heard yet. '
                     'Leave the app running and contacts will appear '
                     'as their adverts are received.'),
            )
            dialog.add_response('ok', _('OK'))
            dialog.present(self._window)
            return

        builder = Gtk.Builder.new_from_resource(
            '/page/codeberg/sesivany/Meshy/ui/discover-dialog.ui')
        dialog = builder.get_object('discover_dialog')
        search_btn = builder.get_object('discover_search_btn')
        filter_btn = builder.get_object('discover_filter_btn')
        sort_btn = builder.get_object('discover_sort_btn')
        search_entry = builder.get_object('discover_search_entry')
        list_view = builder.get_object('discover_list_view')
        status_label = builder.get_object('discover_status_label')

        # Search toggle
        search_btn.bind_property(
            'active', search_entry, 'visible',
            GObject.BindingFlags.SYNC_CREATE)
        search_entry.connect('stop-search', lambda _e: search_btn.set_active(False))

        # Filter menu
        discover_filter = 'all'
        filter_menu = Gio.Menu()
        for fid, label in (
            ('all', _('All')), ('users', _('Users')),
            ('repeaters', _('Repeaters')), ('rooms', _('Room Servers')),
            ('sensors', _('Sensors')),
        ):
            item = Gio.MenuItem.new(label, f'discover.filter::{fid}')
            filter_menu.append_item(item)
        filter_btn.set_menu_model(filter_menu)

        # Sort menu
        dev = self._window.device_info
        has_self_loc = (dev and dev.latitude is not None and dev.longitude is not None
                        and (abs(dev.latitude) > 1e-6 or abs(dev.longitude) > 1e-6))
        sort_menu = Gio.Menu()
        for sid, label in (
            ('name', _('Name')),
            ('last-seen', _('Last Seen')),
        ):
            sort_menu.append_item(Gio.MenuItem.new(label, f'discover.sort::{sid}'))
        if has_self_loc:
            sort_menu.append_item(
                Gio.MenuItem.new(_('Distance'), 'discover.sort::distance'))
        sort_btn.set_menu_model(sort_menu)

        action_group = Gio.SimpleActionGroup()
        filter_action = Gio.SimpleAction.new_stateful(
            'filter', GLib.VariantType.new('s'),
            GLib.Variant.new_string('all'))
        sort_action = Gio.SimpleAction.new_stateful(
            'sort', GLib.VariantType.new('s'),
            GLib.Variant.new_string('name'))
        action_group.add_action(filter_action)
        action_group.add_action(sort_action)
        dialog.insert_action_group('discover', action_group)

        # Model — prevent GC by storing refs on the dialog
        store = Gio.ListStore(item_type=_ContactPickItem)
        for c in sorted(discovered, key=lambda c: c.name.lower()):
            store.append(_ContactPickItem(c))

        # Filter
        custom_filter = Gtk.CustomFilter.new(
            self._discover_filter_func, (search_entry, filter_action))

        def _on_filter_or_search_changed(*_args):
            custom_filter.changed(Gtk.FilterChange.DIFFERENT)
            self._update_discover_status(status_label, filter_model, store)

        def _on_filter_activated(_action, param):
            filter_action.set_state(param)
            _on_filter_or_search_changed()

        search_entry.connect('search-changed', _on_filter_or_search_changed)
        filter_action.connect('activate', _on_filter_activated)

        filter_model = Gtk.FilterListModel(model=store, filter=custom_filter)

        # Sorter
        self_lat = dev.latitude if dev else None
        self_lon = dev.longitude if dev else None
        custom_sorter = Gtk.CustomSorter.new(
            self._discover_sort_func, (sort_action, self_lat, self_lon))

        def _on_sort_activated(_action, param):
            sort_action.set_state(param)
            custom_sorter.changed(Gtk.SorterChange.DIFFERENT)

        sort_action.connect('activate', _on_sort_activated)

        sort_model = Gtk.SortListModel(model=filter_model, sorter=custom_sorter)

        selection = Gtk.NoSelection(model=sort_model)
        list_view.set_model(selection)

        # Factory
        def _on_add(contact):
            item = None
            for i in range(store.get_n_items()):
                it = store.get_item(i)
                if it.contact.public_key_hex == contact.public_key_hex:
                    item = it
                    break
            if item:
                self._add_discovered_lv(
                    item, store, dialog, status_label, filter_model)

        def _subtitle(contact):
            subtitle = contact.type_label
            if has_self_loc and contact.has_location:
                from meshy.utils import haversine_distance
                dist = haversine_distance(self_lat, self_lon,
                                          contact.latitude, contact.longitude)
                if dist < 1.0:
                    subtitle += f' · {dist * 1000:.0f} m'
                else:
                    subtitle += f' · {dist:.1f} km'
            return subtitle

        factory = Gtk.SignalListItemFactory()
        factory.connect('setup',
            lambda f, li: _contact_pick_factory_setup(f, li, _('Add')))
        factory.connect('bind',
            lambda f, li: _contact_pick_factory_bind(
                f, li, _on_add, subtitle_func=_subtitle))
        factory.connect('unbind', _contact_pick_factory_unbind)
        list_view.set_factory(factory)

        def _on_activate(lv, pos):
            item = sort_model.get_item(pos)
            if item:
                self._add_discovered_lv(
                    item, store, dialog, status_label, filter_model)

        list_view.connect('activate', _on_activate)

        # prevent Python GC from collecting these while the dialog is alive
        dialog._refs = (store, custom_filter, filter_model, custom_sorter,
                        sort_model, selection, factory, action_group)

        self._update_discover_status(status_label, filter_model, store)
        dialog.present(self._window)

    def _discover_filter_func(self, item, user_data):
        search_entry, filter_action = user_data
        contact = item.contact
        search = search_entry.get_text().lower()
        if search and search not in contact.name.lower():
            return False
        f = filter_action.get_state().get_string()
        if f == 'users' and contact.type != ContactType.CHAT:
            return False
        elif f == 'repeaters' and contact.type != ContactType.REPEATER:
            return False
        elif f == 'rooms' and contact.type != ContactType.ROOM:
            return False
        elif f == 'sensors' and contact.type != ContactType.SENSOR:
            return False
        return True

    @staticmethod
    def _discover_sort_func(a, b, user_data=None):
        sort_action, self_lat, self_lon = user_data
        mode = sort_action.get_state().get_string()

        if mode == 'last-seen':
            ta = a.contact.last_seen
            tb = b.contact.last_seen
            if ta is not None and tb is not None:
                return (tb > ta) - (tb < ta)
            if ta is not None:
                return -1
            if tb is not None:
                return 1
            na = a.contact.name.lower()
            nb = b.contact.name.lower()
            return (na > nb) - (na < nb)

        if mode == 'distance' and self_lat is not None and self_lon is not None:
            from meshy.utils import haversine_distance
            da = (haversine_distance(self_lat, self_lon,
                                     a.contact.latitude, a.contact.longitude)
                  if a.contact.has_location else float('inf'))
            db = (haversine_distance(self_lat, self_lon,
                                     b.contact.latitude, b.contact.longitude)
                  if b.contact.has_location else float('inf'))
            if da != db:
                return (da > db) - (da < db)

        na = a.contact.name.lower()
        nb = b.contact.name.lower()
        return (na > nb) - (na < nb)

    def _add_discovered_lv(self, item, store, dialog, status_label,
                            filter_model):
        self._window.add_discovered_contact(item.contact)
        found, pos = store.find(item)
        if found:
            store.remove(pos)
        self._update_discover_status(status_label, filter_model, store)
        if store.get_n_items() == 0:
            dialog.close()

    @staticmethod
    def _update_discover_status(label, filter_model, store):
        total = store.get_n_items()
        visible = filter_model.get_n_items()
        if total == 0:
            label.set_label(_('No contacts'))
        elif visible != total:
            label.set_label(
                _('{visible}/{total} discovered').format(
                    visible=visible, total=total))
        else:
            label.set_label(
                _('{total} discovered').format(total=total))

    def _on_add_manual(self, *args):
        dialog = Adw.AlertDialog(
            heading=_('Add Contact'),
            body=_('Enter the contact details.'),
        )
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)

        type_row = Adw.ComboRow(title=_('Contact Type'))
        type_list = Gtk.StringList()
        for label in [_('Chat'), _('Repeater'), _('Room Server'), _('Sensor')]:
            type_list.append(label)
        type_row.set_model(type_list)
        type_row.set_selected(0)

        name_row = Adw.EntryRow(title=_('Name'))
        key_row = Adw.EntryRow(title=_('Public Key (64 hex characters)'))

        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(type_row)
        fields_list.append(name_row)
        fields_list.append(key_row)
        content.append(fields_list)

        dialog.set_extra_child(content)
        dialog.add_response('cancel', _('Cancel'))
        dialog.add_response('add', _('Add'))
        dialog.set_response_appearance('add', Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == 'add':
                try:
                    name = name_row.get_text().strip()
                    key_text = key_row.get_text().strip()
                    pub_key = bytes.fromhex(key_text)
                    if len(pub_key) != 32 or not name:
                        return
                    if self._window.is_contact_known(key_text):
                        self._show_import_error(_('This contact is already in your list.'))
                        return
                    contact_type = type_row.get_selected() + 1  # CHAT=1, REPEATER=2, ROOM=3, SENSOR=4
                    self._window.add_contact(pub_key, contact_type, name)
                except ValueError:
                    pass

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

    def _on_import_clipboard(self, *args):
        clipboard = self._window.get_clipboard()
        clipboard.read_text_async(None, self._on_clipboard_read)

    def _on_clipboard_read(self, clipboard, result):
        try:
            text = clipboard.read_text_finish(result)
        except Exception:
            text = None

        if not text or not text.strip().startswith('meshcore://'):
            dialog = Adw.AlertDialog(
                heading=_('Import Failed'),
                body=_('No meshcore:// link found in clipboard.'),
            )
            dialog.add_response('ok', _('OK'))
            dialog.present(self._window)
            return

        app = self._window.get_application()
        app._handle_meshcore_uri(text.strip())

    def _on_scan_qr(self, *args):
        from meshy.qr_scanner import QRScannerDialog
        self._qr_scanner = QRScannerDialog(self._window, self._on_qr_result)
        self._qr_scanner.start()

    def _on_qr_result(self, data):
        """Handle a scanned QR code - meshcore:// URL or raw public key hex."""
        from urllib.parse import urlparse, parse_qs, unquote_plus

        # meshcore://contact/add?name=...&public_key=...
        if data.lower().startswith('meshcore://'):
            parsed = urlparse(data)
            params = parse_qs(parsed.query)

            full_path = f'{parsed.netloc}{parsed.path}'.rstrip('/')
            if full_path == 'channel/add' and 'secret' in params:
                # meshcore://channel/add?name=...&secret=...
                ch_name = params.get('name', [''])[0]
                secret_hex = params['secret'][0].strip()
                try:
                    secret = bytes.fromhex(secret_hex)
                except ValueError:
                    self._show_import_error(_('Invalid channel secret in QR code.'))
                    return
                if len(secret) != 16:
                    self._show_import_error(_('Channel secret must be 16 bytes, got {}.').format(len(secret)))
                    return
                self._window.add_channel_from_qr(ch_name, secret)
                return

            if 'public_key' in params:
                pub_key_hex = params['public_key'][0].strip()
                name = params.get('name', [''])[0]
                # Extract contact type: 1=Companion, 2=Repeater, 3=RoomServer, 4=Sensor
                try:
                    contact_type = int(params.get('type', ['1'])[0])
                except ValueError:
                    contact_type = 1
                try:
                    pub_key = bytes.fromhex(pub_key_hex)
                except ValueError:
                    self._show_import_error(_('Invalid public key in QR code.'))
                    return

                if len(pub_key) != 32:
                    self._show_import_error(_('Public key must be 32 bytes, got {}.').format(len(pub_key)))
                    return

                if self._window.is_contact_known(pub_key_hex):
                    self._show_import_error(_('This contact is already in your list.'))
                    return

                if name:
                    self._window.add_contact(pub_key, contact_type, name)
                else:
                    self._show_qr_contact_dialog(pub_key, pub_key_hex)
                return

            # Try as raw hex contact frame (old format)
            hex_data = data[len('meshcore://'):].strip()
            if '/' not in hex_data and '?' not in hex_data:
                try:
                    contact_frame = bytes.fromhex(hex_data)
                    self._window.import_contact(contact_frame)
                    return
                except ValueError:
                    pass

        # Try as raw hex public key (64 hex chars = 32 bytes)
        cleaned = data.strip().replace(' ', '').replace(':', '')
        try:
            pub_key = bytes.fromhex(cleaned)
            if len(pub_key) == 32:
                if self._window.is_contact_known(cleaned):
                    self._show_import_error(_('This contact is already in your list.'))
                    return
                self._show_qr_contact_dialog(pub_key, cleaned)
                return
        except ValueError:
            pass

        self._show_import_error(_('Could not parse QR code data:\n{}').format(data[:120]))

    def _show_import_error(self, message):
        dialog = Adw.AlertDialog(heading=_('Import Failed'), body=message)
        dialog.add_response('ok', _('OK'))
        dialog.present(self._window)

    def _show_qr_contact_dialog(self, pub_key, hex_str):
        """Show dialog to complete contact details from a scanned public key."""
        dialog = Adw.AlertDialog(
            heading=_('QR Code Scanned'),
            body=_('Public key: {}...{}').format(hex_str[:16], hex_str[-16:]),
        )
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)

        type_row = Adw.ComboRow(title=_('Contact Type'))
        type_list = Gtk.StringList()
        for label in [_('Chat'), _('Repeater'), _('Room Server'), _('Sensor')]:
            type_list.append(label)
        type_row.set_model(type_list)
        type_row.set_selected(0)

        name_row = Adw.EntryRow(title=_('Name'))

        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(type_row)
        fields_list.append(name_row)
        content.append(fields_list)

        dialog.set_extra_child(content)
        dialog.add_response('cancel', _('Cancel'))
        dialog.add_response('add', _('Add Contact'))
        dialog.set_response_appearance('add', Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == 'add':
                name = name_row.get_text().strip()
                if not name:
                    return
                contact_type = type_row.get_selected() + 1
                self._window.add_contact(pub_key, contact_type, name)

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

    def _on_row_selected(self, listbox, row):
        import logging
        log = logging.getLogger(__name__)
        if self._login_dialog_open:
            log.debug('Login dialog already open, ignoring row selection')
            return
        if row and hasattr(row, '_contact'):
            contact = row._contact
            log.info(f'Contact selected: {contact.name} (type={contact.type}, logged_in={self._window.is_room_logged_in(contact.public_key_hex)})')
            if contact.type == ContactType.REPEATER:
                if self._window.is_room_logged_in(contact.public_key_hex):
                    self._window.show_repeater(
                        contact, is_admin=self._window.is_room_admin(contact.public_key_hex))
                else:
                    self._show_room_login(contact, repeater=True)
            elif contact.type == ContactType.ROOM and not self._window.is_room_logged_in(contact.public_key_hex):
                log.info(f'Room not logged in, showing login dialog')
                self._show_room_login(contact)
            else:
                self._window.show_chat(contact)

    def _show_room_login(self, contact: Contact, management: bool = False, repeater: bool = False):
        """Show a login dialog for a room/repeater contact."""
        import logging
        log = logging.getLogger(__name__)
        log.info(f'Showing login dialog for {contact.name} (type={contact.type}, pub_key={contact.public_key_hex[:16]}...)')
        self._login_dialog_open = True
        dialog = Adw.Dialog()
        dialog.set_title(_('Login to {}').format(contact.name))
        dialog.set_content_width(360)
        dialog.set_content_height(280)

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

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

        password_entry = Adw.PasswordEntryRow(title=_('Password'))
        save_switch = Adw.SwitchRow(title=_('Save password'))

        login_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        login_list.add_css_class('boxed-list')
        login_list.append(password_entry)
        login_list.append(save_switch)
        content.append(login_list)

        # Pre-fill saved password
        if self._window.storage:
            saved = self._window.storage.get_room_password(contact.public_key_hex)
            if saved:
                password_entry.set_text(saved)
                save_switch.set_active(True)

        status_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER, margin_top=4)
        spinner = Gtk.Spinner()
        status_box.append(spinner)
        status_label = Gtk.Label(label='')
        status_label.add_css_class('dim-label')
        status_box.append(status_label)
        content.append(status_box)

        login_btn = Gtk.Button(label=_('Login'))
        login_btn.add_css_class('suggested-action')
        login_btn.set_halign(Gtk.Align.CENTER)
        content.append(login_btn)

        toolbar_view.set_content(content)
        dialog.set_child(toolbar_view)

        timeout_id = [None]

        def on_login_result(success, login_timestamp=None, is_admin=True):
            spinner.set_spinning(False)
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None
            if success:
                status_label.set_label(_('Login successful'))
                # Save or remove password
                if self._window.storage:
                    if save_switch.get_active():
                        self._window.storage.save_room_password(
                            contact.public_key_hex, password_entry.get_text())
                    else:
                        self._window.storage.remove_room_password(contact.public_key_hex)
                self._window.mark_room_logged_in(contact.public_key_hex, is_admin)
                # Immediately sync to fetch pending room messages
                self._window.send_frame(build_sync_next_message())
                dialog.close()
                if repeater:
                    self._window.show_repeater(contact, login_timestamp, is_admin)
                elif management:
                    self._show_room_management(contact)
                else:
                    self._window.show_chat(contact)
            else:
                status_label.set_label(_('Login failed — wrong password'))
                status_label.remove_css_class('dim-label')
                status_label.add_css_class('error')
                login_btn.set_sensitive(True)

        def on_timeout():
            spinner.set_spinning(False)
            status_label.set_label(_('Login timed out'))
            self._window.stop_login()
            login_btn.set_sensitive(True)
            timeout_id[0] = None
            return False

        def on_retry():
            status_label.set_label(_('Retrying via flood...'))

        def on_login_clicked(*args):
            log.info(f'Login button clicked for {contact.name}')
            password = password_entry.get_text()
            log.info(f'Password length: {len(password)}')
            spinner.set_spinning(True)
            status_label.set_label(_('Logging in...'))
            status_label.remove_css_class('error')
            status_label.add_css_class('dim-label')
            login_btn.set_sensitive(False)
            timeout_id[0] = GLib.timeout_add(15000, on_timeout)
            log.info(f'Calling send_login with pub_key={contact.public_key.hex()[:16]}...')
            self._window.send_login(contact.public_key, password, on_login_result, on_retry)

        login_btn.connect('clicked', on_login_clicked)
        password_entry.connect('apply', on_login_clicked)

        def on_dialog_closed(d):
            self._login_dialog_open = False
            self._window.stop_login()
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])

        dialog.connect('closed', on_dialog_closed)
        dialog.present(self._window)

    def _sort_key(self, c: Contact):
        """Compute sort key for a contact based on current sort setting."""
        fav = self._favorites_first
        if self._current_sort == self.SORT_AZ:
            return (not c.is_favorite if fav else False, c.name.lower())
        elif self._current_sort == self.SORT_MESSAGES:
            return (not c.is_favorite if fav else False, -(c.last_message_at.timestamp() if c.last_message_at else 0))
        else:  # SORT_HEARD
            return (not c.is_favorite if fav else False, -(c.last_seen.timestamp() if c.last_seen else 0))

    def update_contacts(self, contacts: list[Contact]):
        sorted_contacts = sorted(contacts, key=self._sort_key)
        new_keys = [c.public_key_hex for c in sorted_contacts]
        old_keys = list(self._contact_rows.keys())

        # Fast path: same contacts in same order — update in-place
        if new_keys == old_keys:
            for contact in sorted_contacts:
                key = contact.public_key_hex
                row = self._contact_rows.get(key)
                if row:
                    row.set_title(GLib.markup_escape_text(contact.name))
                    row.set_subtitle(contact.path_label)
                    row._contact = contact
            self._total_contacts = len(contacts)
            self._update_status_count()
            return

        # Full rebuild: contact set or order changed
        self._full_rebuild(sorted_contacts)

    def _full_rebuild(self, sorted_contacts: list[Contact]):
        """Remove all rows and recreate from scratch."""
        selected_row = self._list_box.get_selected_row()
        selected_key = selected_row._contact.public_key_hex if selected_row and hasattr(selected_row, '_contact') else None

        child = self._list_box.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            self._list_box.remove(child)
            child = next_child
        self._contact_rows.clear()

        new_selected = None
        for contact in sorted_contacts:
            row = self._create_contact_row(contact)
            self._list_box.append(row)
            self._contact_rows[contact.public_key_hex] = row
            if contact.public_key_hex == selected_key:
                new_selected = row

        if new_selected:
            self._list_box.select_row(new_selected)

        self._total_contacts = len(sorted_contacts)
        self._update_status_count()

    def _create_contact_row(self, contact: Contact) -> Gtk.ListBoxRow:
        row = Adw.ActionRow(
            title=GLib.markup_escape_text(contact.name),
            subtitle=contact.path_label,
        )
        row._contact = contact

        # Type icon with favorite star
        icon_name = {
            ContactType.CHAT: 'avatar-default-symbolic',
            ContactType.REPEATER: 'network-server-symbolic',
            ContactType.ROOM: 'user-home-symbolic',
            ContactType.SENSOR: 'weather-clear-symbolic',
        }.get(contact.type, 'avatar-default-symbolic')
        prefix_box = Gtk.Box(spacing=2)
        star = Gtk.Image.new_from_icon_name('starred-symbolic')
        star.set_pixel_size(10)
        if not contact.is_favorite:
            star.set_opacity(0)
        prefix_box.append(star)
        prefix_box.append(Gtk.Image.new_from_icon_name(icon_name))
        row.add_prefix(prefix_box)

        # Unread badge — visible by default when count > 0, hidden on hover
        count = self._unread_counts.get(contact.public_key_hex, 0)
        unread_badge = Gtk.Label(label=str(count) if count else '')
        unread_badge.add_css_class('unread-badge')
        unread_badge.set_valign(Gtk.Align.CENTER)
        unread_badge.set_visible(count > 0)
        row.add_suffix(unread_badge)
        self._unread_dots[contact.public_key_hex] = unread_badge

        # "More" button + hover behavior + long press
        info_btn = Gtk.Button(icon_name='view-more-symbolic', valign=Gtk.Align.CENTER)
        info_btn.add_css_class('flat')
        info_btn.set_visible(False)
        on_detail = lambda *_, r=row: self._show_contact_detail(r._contact)
        info_btn.connect('clicked', on_detail)
        row.add_suffix(info_btn)
        _add_hover_swap(row, info_btn, unread_badge,
                        lambda: self._unread_counts.get(contact.public_key_hex, 0),
                        on_detail)

        row.set_activatable(True)
        return row

    def mark_unread(self, public_key_hex: str, unread: bool):
        """Show or hide the unread badge for a contact."""
        if unread:
            self._unread_keys.add(public_key_hex)
            self._unread_counts[public_key_hex] = self._unread_counts.get(public_key_hex, 0) + 1
        else:
            self._unread_keys.discard(public_key_hex)
            self._unread_counts[public_key_hex] = 0
        if public_key_hex in self._unread_dots:
            badge = self._unread_dots[public_key_hex]
            count = self._unread_counts.get(public_key_hex, 0)
            badge.set_label(str(count) if count else '')
            badge.set_visible(count > 0)

    def select_contact(self, contact: Contact):
        """Programmatically select a contact in the list."""
        key = contact.public_key_hex
        if key in self._contact_rows:
            self._list_box.select_row(self._contact_rows[key])

    def _get_visible_rows(self):
        """Return visible rows in display order."""
        rows = []
        row = self._list_box.get_first_child()
        while row:
            if row.get_visible() and hasattr(row, '_contact'):
                rows.append(row)
            row = row.get_next_sibling()
        return rows

    def navigate(self, direction: int, unread_only: bool = False):
        """Move selection up (-1) or down (+1). If unread_only, skip to next unread."""
        rows = self._get_visible_rows()
        if not rows:
            return
        if unread_only:
            rows = [r for r in rows if r._contact.public_key_hex in self._unread_keys]
            if not rows:
                return
        selected = self._list_box.get_selected_row()
        if selected and selected in rows:
            idx = rows.index(selected)
            idx = (idx + direction) % len(rows)
        else:
            idx = 0 if direction > 0 else len(rows) - 1
        self._list_box.select_row(rows[idx])
        rows[idx].grab_focus()

    def _apply_contact_update(self, contact, name_entry, out_path_entry,
                              flood_switch):
        """Send the current contact settings to the device."""
        new_name = name_entry.get_text().strip() or contact.name
        path_text = out_path_entry.get_text().strip()
        force_flood = flood_switch.get_active()

        if force_flood:
            self._window.set_contact_path(contact, 0xFF, b'', name=new_name)
        elif path_text:
            try:
                hops = [h.strip() for h in path_text.split(',') if h.strip()]
                # Infer hash size from the length of the first hop
                hash_size = len(bytes.fromhex(hops[0]))
                path_bytes = b''.join(bytes.fromhex(h) for h in hops)
                hash_mode = hash_size - 1
                hop_count = len(hops)
                path_len_byte = (hash_mode << 6) | (hop_count & 0x3F)
                self._window.set_contact_path(
                    contact, path_len_byte, path_bytes, name=new_name
                )
            except ValueError:
                pass
        else:
            self._window.reset_contact_path(contact)
            if new_name != contact.name:
                GLib.timeout_add(500, lambda: self._window.set_contact_path(
                    contact, 0xFF, b'', name=new_name
                ) or False)

        # Update local contact name immediately so the UI reflects it
        if new_name != contact.name:
            contact.name = new_name
            self._window.storage.save_contact(contact)
            self._window.update_contacts_from_storage()
            self._window.refresh_chat_header()

    def _on_path_discovery(self, contact: Contact, btn: Gtk.Button):
        """Send a path discovery request for a contact."""
        btn.set_sensitive(False)
        self._discover_path_label.set_label(_('Searching...'))

        timeout_id = [None]

        def _on_response(data):
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None
            self._window.stop_path_discovery()
            btn.set_sensitive(True)
            # Parse the PATH_DISCOVERY_RESPONSE (0x8D)
            # Format: [code][reserved][prefix×6][out_path_len][out_path...][in_path_len][in_path...]
            try:
                i = 2  # skip code + reserved
                i += 6  # skip prefix
                opl_byte = data[i]; i += 1
                opl_hlen = ((opl_byte & 0xC0) >> 6) + 1
                opl = opl_byte & 0x3F
                out_hops = []
                for _j in range(opl):
                    out_hops.append(data[i:i+opl_hlen].hex().upper())
                    i += opl_hlen
                ipl_byte = data[i]; i += 1
                ipl_hlen = ((ipl_byte & 0xC0) >> 6) + 1
                ipl = ipl_byte & 0x3F
                in_hops = []
                for _j in range(ipl):
                    in_hops.append(data[i:i+ipl_hlen].hex().upper())
                    i += ipl_hlen

                if opl == 0 and ipl == 0:
                    self._discover_path_label.set_label(_('Direct'))
                else:
                    parts = []
                    if out_hops:
                        parts.append(','.join(out_hops))
                    self._discover_path_label.set_label(
                        ' → '.join(parts) if parts else f'{opl} hops')

                    # Apply discovered outbound path to the contact
                    if out_hops:
                        path_bytes = b''.join(bytes.fromhex(h) for h in out_hops)
                        hash_mode = opl_hlen - 1
                        path_len_byte = (hash_mode << 6) | (opl & 0x3F)
                        self._window.set_contact_path(
                            contact, path_len_byte, path_bytes)
                        self._window.show_toast(
                            f'Path set: {",".join(out_hops)}')
            except (IndexError, ValueError) as e:
                import logging
                logging.getLogger(__name__).warning(f'Path discovery parse error: {e}')
                self._discover_path_label.set_label(_('Path found'))
            self._window.refresh_contacts()

        def _on_timeout():
            self._window.stop_path_discovery()
            btn.set_sensitive(True)
            self._discover_path_label.set_label(_('No response'))
            timeout_id[0] = None
            return False

        timeout_id[0] = GLib.timeout_add(30000, _on_timeout)
        self._window.send_path_discovery(contact.public_key, _on_response)

    def _on_get_advert_path(self, contact: Contact):
        """Get and display the inbound advertisement path for a contact."""
        self._advert_path_label.set_label(_('Loading...'))

        # Set a timeout in case the response never comes (error consumed elsewhere)
        timeout_id = [None]

        def _on_response(result):
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None
            if not result:
                self._advert_path_label.set_label(_('No path cached'))
                return
            hops = result.get('hops', [])
            path_len = result.get('path_length', -1)
            if path_len < 0 or not hops:
                self._advert_path_label.set_label(_('Flood'))
            elif path_len == 0:
                self._advert_path_label.set_label(_('Direct'))
            else:
                self._advert_path_label.set_label(' → '.join(hops))

        def _on_timeout():
            self._window.set_advert_path_callback(None)
            self._advert_path_label.set_label(_('Not supported'))
            timeout_id[0] = None
            return False

        timeout_id[0] = GLib.timeout_add(3000, _on_timeout)
        self._window.get_advert_path(contact.public_key, _on_response)

    def _ping_contact(self, contact: Contact, btn: Gtk.Button):
        """Ping a repeater/room via trace path to get RTT and SNR."""
        # Trace protocol only supports 1B and 2B hashes (power-of-2 encoding)
        if self._window.device_info.path_hash_mode > 1:
            self._window.show_toast(
                _('Ping is not supported with 3-byte path hashes'))
            return

        import time
        btn.set_sensitive(False)
        start = time.monotonic()
        timeout_id = [None]

        hash_width = self._window.device_info.path_hash_mode + 1
        hop_hash = contact.public_key[:hash_width]

        def on_trace(result):
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None
            rtt = (time.monotonic() - start) * 1000
            snr_values = result.get('snr_values', [])
            parts = [f'{rtt:.0f} ms']
            if len(snr_values) >= 1:
                parts.append(f'SNR out: {snr_values[0]:+.1f} dB')
            if len(snr_values) >= 2:
                parts.append(f'SNR back: {snr_values[-1]:+.1f} dB')
            msg = _('Ping {}: {}').format(contact.name, ', '.join(parts))
            self._window.show_toast(msg, timeout=5)
            btn.set_sensitive(True)

        def on_device_timeout(timeout_ms):
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
            timeout_id[0] = GLib.timeout_add(timeout_ms, on_timeout)

        def on_timeout():
            self._window.stop_trace_path()
            self._window.show_toast(_('Ping {}: timed out').format(contact.name), timeout=5)
            btn.set_sensitive(True)
            timeout_id[0] = None
            return False

        timeout_id[0] = GLib.timeout_add(15000, on_timeout)
        trace_flags = self._window.device_info.path_hash_mode
        self._window.send_trace_path(hop_hash, on_trace, on_device_timeout,
                                     flags=trace_flags)

    def _request_telemetry(self, contact: Contact, row: Adw.ActionRow):
        """Request and display telemetry from a contact."""
        from meshy.protocol import parse_cayenne_lpp
        row.set_subtitle(_('Requesting...'))

        timeout_id = [None]

        def on_timeout():
            row.set_subtitle(_('No response (timed out)'))
            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
            if not lpp_data:
                row.set_subtitle(_('No telemetry data'))
                return
            entries = parse_cayenne_lpp(lpp_data)
            if not entries:
                row.set_subtitle(_('No telemetry data'))
                return
            self._show_telemetry_dialog(contact, entries)
            row.set_subtitle(_('Telemetry received'))

        timeout_id[0] = GLib.timeout_add(15000, on_timeout)
        self._window.get_telemetry(contact.public_key, on_response, contact_type=contact.type)

    @staticmethod
    def _battery_percent(volts: float) -> int:
        from meshy.models import battery_percent_from_mv
        return battery_percent_from_mv(int(volts * 1000))

    def _show_telemetry_dialog(self, contact: Contact, entries: list[dict]):
        """Show a dialog with parsed telemetry data and optional map."""
        from meshy.protocol import LPP_GPS, LPP_VOLTAGE

        # Check for GPS data
        gps_entry = next((e for e in entries if e['type'] == LPP_GPS), None)

        dialog = Adw.Dialog()
        dialog.set_title(_('Telemetry — {}').format(contact.name))
        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 = self._battery_percent(val)
                    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)

        # Show map if GPS data is present
        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)
            map_widget.set_size_request(-1, 250)

            def _make_marker(name, mlat, mlon, color='#e05050'):
                box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
                              halign=Gtk.Align.CENTER)
                dot = Gtk.Label(label='\u25CF')
                dot.set_markup(f'<span color="{color}" size="14000">\u25CF</span>')
                box.append(dot)
                lbl = Gtk.Label(label=name)
                lbl.add_css_class('caption')
                attrs = Pango.AttrList()
                attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
                lbl.set_attributes(attrs)
                box.append(lbl)
                m = Shumate.Marker()
                m.set_child(box)
                m.set_location(mlat, mlon)
                return m

            marker_layer.add_marker(_make_marker(contact.name, lat, lon))

            # Show our companion's position too
            lats, lons = [lat], [lon]
            si = self._window.self_info
            self_lat = si.get('adv_lat', 0.0)
            self_lon = si.get('adv_lon', 0.0)
            if abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6:
                self_name = si.get('name', '') or 'My Device'
                marker_layer.add_marker(_make_marker(self_name, self_lat, self_lon, '#3584e4'))
                lats.append(self_lat)
                lons.append(self_lon)

            # Fit viewport to show all markers
            from meshy.views.map_view import MapView
            MapView.fit_viewport(viewport, lats, lons)

            map_frame = Gtk.Frame()
            map_frame.set_child(map_widget)
            content.append(map_frame)

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

    def _show_room_management(self, contact: Contact):
        """Show a CLI management dialog for a room/repeater."""
        dialog = Adw.Dialog()
        dialog.set_title(_('{} — Management').format(contact.name))
        dialog.set_content_width(500)
        dialog.set_content_height(500)

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

        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL, spacing=4,
            margin_start=8, margin_end=8, margin_bottom=8,
        )

        # Output area
        output_scroll = Gtk.ScrolledWindow(vexpand=True)
        output_view = Gtk.TextView(editable=False, wrap_mode=Gtk.WrapMode.WORD_CHAR)
        output_view.set_monospace(True)
        output_buf = output_view.get_buffer()
        output_scroll.set_child(output_view)
        content.append(output_scroll)

        # Command input
        cmd_entry = Gtk.Entry(placeholder_text='Type a CLI command...',  hexpand=True)
        send_btn = Gtk.Button(icon_name='mail-send-symbolic')
        send_btn.add_css_class('suggested-action')

        input_box = Gtk.Box(spacing=8, margin_top=4)
        input_box.append(cmd_entry)
        input_box.append(send_btn)
        content.append(input_box)

        toolbar_view.set_content(content)
        dialog.set_child(toolbar_view)

        def append_output(text):
            end_iter = output_buf.get_end_iter()
            output_buf.insert(end_iter, text + '\n')
            GLib.idle_add(lambda: output_scroll.get_vadjustment().set_value(
                output_scroll.get_vadjustment().get_upper()) or False)

        def on_cli_response(cli_contact, message):
            """Handle CLI response messages from the room."""
            if cli_contact.public_key_hex == contact.public_key_hex and message.is_cli:
                append_output(message.text)

        # Register CLI response listener
        self._window.set_cli_response_callback(on_cli_response)

        def on_send(*args):
            cmd = cmd_entry.get_text().strip()
            if not cmd:
                return
            append_output(f'> {cmd}')
            cmd_entry.set_text('')
            from meshy.protocol import build_send_cli_command
            self._window.send_frame(
                build_send_cli_command(contact.public_key, cmd))

        send_btn.connect('clicked', on_send)
        cmd_entry.connect('activate', on_send)

        def on_dialog_closed(d):
            self._window.set_cli_response_callback(None)

        dialog.connect('closed', on_dialog_closed)

        # Send initial 'status' command
        append_output('> status')
        from meshy.protocol import build_send_cli_command
        self._window.send_frame(
            build_send_cli_command(contact.public_key, 'status'))

        dialog.present(self._window)

    def _show_path_on_map(self, contact: Contact, hop_contacts: list,
                          hop_prefixes: list = None):
        """Show the established path to a contact in a map dialog."""
        from meshy.views.map_view import (
            MapView, _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, _UNKNOWN_COLOR,
        )

        device = self._window.device_info
        self_lat = getattr(device, 'latitude', None)
        self_lon = getattr(device, 'longitude', None)
        has_self = (self_lat and self_lon and
                    (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6))

        # Build full hop list including unknowns: (lat, lon, contact, prefix, snr)
        raw_hops = []

        if has_self:
            raw_hops.append((self_lat, self_lon, None, None, None))

        for i, hop_contact in enumerate(hop_contacts):
            prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None
            if hop_contact and hop_contact.has_location:
                raw_hops.append((hop_contact.latitude, hop_contact.longitude,
                                 hop_contact, prefix, None))
            else:
                raw_hops.append((None, None, hop_contact, prefix, None))

        if contact.has_location:
            raw_hops.append((contact.latitude, contact.longitude,
                             contact, None, None))

        hops = MapView.interpolate_unknown_hops(raw_hops)

        if not any(h[0] is not None for h in hops):
            return

        dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(
            self._window, title=_('Path to {}').format(contact.name))

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

        lats, lons = [], []
        hop_num = 0
        for lat, lon, hop_contact, prefix, _snr in hops:
            if lat is None:
                continue
            node = Shumate.Marker()
            node.set_location(lat, lon)
            path_layer.add_node(node)

            if hop_contact is None and prefix is None:
                # Self/device marker
                r, g, b = _SELF_COLOR
                icon_name = 'network-wireless-symbolic'
                name = device.name or _('My Device')
                badge = ''
            elif hop_contact is None or not hop_contact.has_location:
                # Unknown/interpolated hop
                r, g, b = _UNKNOWN_COLOR
                icon_name = 'network-server-symbolic'
                name = prefix or _('Unknown')
                hop_num += 1
                badge = str(hop_num)
            else:
                r, g, b = _MARKER_COLORS.get(hop_contact.type, (0.5, 0.5, 0.5))
                icon_name = _MARKER_ICONS.get(hop_contact.type, 'avatar-default-symbolic')
                name = hop_contact.name
                hop_num += 1
                badge = str(hop_num)

            widget = MapView._build_marker_widget(
                r, g, b, icon_name, '', badge_text=badge)
            widget.set_tooltip_text(name)

            if hop_contact is not None:
                click = Gtk.GestureClick()
                click.connect('pressed', lambda g, n, x, y, c=hop_contact:
                              self._show_contact_detail(c))
                widget.add_controller(click)
            elif prefix is not None:
                click = Gtk.GestureClick()
                click.connect('pressed',
                    lambda g, n, x, y, w=widget, nm=name, la=lat, lo=lon:
                        MapView.show_marker_popover(
                            w, nm, la, lo,
                            contact_type=_('Unknown repeater'),
                            interpolated=True))
                widget.add_controller(click)

            hop_marker = Shumate.Marker()
            hop_marker.set_child(widget)
            hop_marker.set_location(lat, lon)
            marker_layer.add_marker(hop_marker)

            lats.append(lat)
            lons.append(lon)

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

    def _show_on_map(self, contact: Contact):
        """Show a contact's location in a map dialog."""
        from meshy.views.map_view import MapView

        dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(
            self._window, title=contact.name)

        # Add contact marker
        r, g, b = {
            ContactType.CHAT: (0.26, 0.52, 0.96),
            ContactType.REPEATER: (0.30, 0.69, 0.31),
            ContactType.ROOM: (0.61, 0.32, 0.88),
            ContactType.SENSOR: (1.0, 0.60, 0.0),
        }.get(contact.type, (0.5, 0.5, 0.5))

        label = Gtk.Label(label=contact.name)
        label.add_css_class('caption')
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
        label.set_attributes(attrs)
        marker = Shumate.Marker()
        marker.set_child(label)
        marker.set_location(contact.latitude, contact.longitude)
        marker_layer.add_marker(marker)

        viewport.set_zoom_level(15)
        viewport.set_location(contact.latitude, contact.longitude)
        dialog.present(self._window)

    def _show_contact_detail(self, contact: Contact):
        """Show contact detail dialog with name editing and path management."""
        dialog = Adw.Dialog()
        dialog.set_title(contact.name)
        dialog.set_content_width(420)
        dialog.set_content_height(520)

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

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

        # Info group
        info_group = Adw.PreferencesGroup(title=_('Contact Info'))

        name_entry = Adw.EntryRow(title=_('Name'))
        name_entry.set_text(contact.name)
        name_entry.set_show_apply_button(True)
        info_group.add(name_entry)

        favorite_row = Adw.SwitchRow(
            title=_('Favorite'),
            subtitle=_('Pin to top of contacts list'),
        )
        favorite_row.set_active(contact.is_favorite)

        def _on_favorite_toggled(*args):
            from meshy.models import CONTACT_FLAG_FAVORITE
            if favorite_row.get_active():
                contact.flags |= CONTACT_FLAG_FAVORITE
            else:
                contact.flags &= ~CONTACT_FLAG_FAVORITE
            self._window.storage.save_contact(contact)
            self._window.update_contact_flags(contact)
            self._window.update_contacts_from_storage()

        favorite_row.connect('notify::active', _on_favorite_toggled)
        info_group.add(favorite_row)

        info_group.add(Adw.ActionRow(title=_('Type'), subtitle=contact.type_label))
        key_row = Adw.ActionRow(title=_('Public Key'), subtitle=contact.public_key_hex)
        key_row.set_subtitle_selectable(True)
        copy_btn = Gtk.Button(icon_name='edit-copy-symbolic', valign=Gtk.Align.CENTER)
        copy_btn.add_css_class('flat')
        copy_btn.set_tooltip_text('Copy to clipboard')
        def _copy_key(*_args):
            clipboard = self._window.get_clipboard()
            clipboard.set(contact.public_key_hex)
            clipboard.store_async(None, None)
            self._window.show_toast(_('Public key copied'))
        copy_btn.connect('clicked', _copy_key)
        key_row.add_suffix(copy_btn)
        info_group.add(key_row)
        last_seen_str = contact.last_seen.strftime('%Y-%m-%d %H:%M') if contact.last_seen else _('Never')
        info_group.add(Adw.ActionRow(title=_('Last Seen'), subtitle=last_seen_str))
        if contact.has_location:
            loc_row = Adw.ActionRow(
                title=_('Location'),
                subtitle=f'{contact.latitude:.6f}, {contact.longitude:.6f}',
            )
            map_btn = Gtk.Button(
                icon_name='map-symbolic', valign=Gtk.Align.CENTER,
            )
            map_btn.add_css_class('flat')
            map_btn.set_tooltip_text('Show on Map')
            map_btn.connect('clicked', lambda *_, c=contact:
                self._show_on_map(c))
            loc_row.add_suffix(map_btn)
            info_group.add(loc_row)
        content.append(info_group)

        # Out Path group
        path_group = Adw.PreferencesGroup(
            title=_('Out Path'),
            description=_('Comma-separated hex hops (e.g. 9a,74 or 9a3b,744c for 2-byte)'),
        )

        # Out Path entry
        out_path_entry = Adw.EntryRow(title=_('Out Path'))
        out_path_entry.set_show_apply_button(True)
        if contact.path_hops:
            out_path_entry.set_text(','.join(h.lower() for h in contact.path_hops))
        path_group.add(out_path_entry)

        reset_path_row = Adw.ActionRow(
            title=_('Reset Path'),
            subtitle=_('Clear the established path and switch to flood'),
        )
        reset_path_btn = Gtk.Button(
            icon_name='edit-clear-symbolic', valign=Gtk.Align.CENTER,
        )
        reset_path_btn.connect('clicked', lambda *_: (
            self._window.reset_contact_path(contact),
            out_path_entry.set_text(''),
        ))
        reset_path_row.add_suffix(reset_path_btn)
        reset_path_row.set_activatable_widget(reset_path_btn)
        path_group.add(reset_path_row)

        # Force Flood toggle — always off on open, toggling it on resets the path
        flood_switch = Adw.SwitchRow(
            title=_('Force Flood'),
            subtitle=_('Always broadcast, never use an established path'),
        )
        flood_switch.set_active(False)
        path_group.add(flood_switch)

        _updating = [False]

        def _on_path_changed(*args):
            if _updating[0]:
                return
            if out_path_entry.get_text().strip():
                _updating[0] = True
                flood_switch.set_active(False)
                _updating[0] = False

        def _on_flood_toggled(*args):
            if _updating[0]:
                return
            if flood_switch.get_active():
                _updating[0] = True
                out_path_entry.set_text('')
                _updating[0] = False

        out_path_entry.connect('changed', _on_path_changed)
        flood_switch.connect('notify::active', _on_flood_toggled)

        # Apply on confirm
        def _apply(*args):
            self._apply_contact_update(
                contact, name_entry, out_path_entry, flood_switch
            )

        name_entry.connect('apply', _apply)
        out_path_entry.connect('apply', _apply)
        flood_switch.connect('notify::active', lambda *a: _apply() if not _updating[0] else None)

        # Show Path on Map — only if there are hops and at least one is locatable
        if contact.path_hops:
            hop_contacts = self._window.resolve_hop_prefixes(contact.path_hops)
            has_located = any(c and c.has_location for c in hop_contacts)
            if has_located or contact.has_location:
                path_map_row = Adw.ActionRow(
                    title=_('Show Path on Map'),
                    subtitle=_('Visualize the message route on the map'),
                )
                path_map_btn = Gtk.Button(
                    icon_name='map-symbolic', valign=Gtk.Align.CENTER,
                )
                path_map_btn.add_css_class('flat')
                path_map_btn.connect('clicked',
                    lambda *_, c=contact, hc=hop_contacts, hp=contact.path_hops:
                    self._show_path_on_map(c, hc, hp))
                path_map_row.add_suffix(path_map_btn)
                path_map_row.set_activatable_widget(path_map_btn)
                path_group.add(path_map_row)

        content.append(path_group)

        # Telemetry group
        tele_group = Adw.PreferencesGroup(title=_('Telemetry'))

        from meshy.models import CONTACT_FLAG_TELE_BASE, CONTACT_FLAG_TELE_LOC, CONTACT_FLAG_TELE_ENV

        def _make_tele_switch(title, subtitle, flag):
            sw = Adw.SwitchRow(title=title, subtitle=subtitle)
            sw.set_active(bool(contact.flags & flag))
            def _on_toggle(*args):
                if sw.get_active():
                    contact.flags |= flag
                else:
                    contact.flags &= ~flag
                self._window.storage.save_contact(contact)
                self._window.update_contact_flags(contact)
            sw.connect('notify::active', _on_toggle)
            return sw

        tele_group.add(_make_tele_switch(
            _('Allow Telemetry Requests'), _('Allow this contact to query battery, voltage, temperature'),
            CONTACT_FLAG_TELE_BASE))
        tele_group.add(_make_tele_switch(
            _('Include Location'), _('Include GPS coordinates in telemetry responses'),
            CONTACT_FLAG_TELE_LOC))
        tele_group.add(_make_tele_switch(
            _('Include Environment Sensors'), _('Include humidity, pressure, air quality data'),
            CONTACT_FLAG_TELE_ENV))

        # Request telemetry button
        req_tele_row = Adw.ActionRow(
            title=_('Request Telemetry'),
            subtitle=_('Query this contact\'s telemetry data'),
        )
        req_tele_btn = Gtk.Button(
            icon_name='view-refresh-symbolic', valign=Gtk.Align.CENTER,
        )
        req_tele_btn.connect('clicked', lambda *_: self._request_telemetry(contact, req_tele_row))
        req_tele_row.add_suffix(req_tele_btn)
        req_tele_row.set_activatable_widget(req_tele_btn)
        tele_group.add(req_tele_row)

        content.append(tele_group)

        # Actions group (only for repeaters/rooms)
        actions_group = Adw.PreferencesGroup(title=_('Actions'))
        actions_count = 0

        if contact.type == ContactType.ROOM:
            mgmt_row = Adw.ActionRow(
                title=_('Room Management'),
                subtitle=_('Login and access CLI, status, and settings'),
            )
            mgmt_btn = Gtk.Button(
                icon_name='utilities-terminal-symbolic', valign=Gtk.Align.CENTER,
            )
            mgmt_btn.connect('clicked', lambda *_: (
                dialog.close(),
                self._show_room_login(contact, management=True),
            ))
            mgmt_row.add_suffix(mgmt_btn)
            mgmt_row.set_activatable_widget(mgmt_btn)
            actions_group.add(mgmt_row)
            actions_count += 1

        if contact.type == ContactType.REPEATER:
            mgmt_row = Adw.ActionRow(
                title=_('Repeater Management'),
                subtitle=_('Login and access status, CLI, neighbors, and settings'),
            )
            mgmt_btn = Gtk.Button(
                icon_name='utilities-terminal-symbolic', valign=Gtk.Align.CENTER,
            )
            mgmt_btn.connect('clicked', lambda *_: (
                dialog.close(),
                self._show_room_login(contact, repeater=True),
            ))
            mgmt_row.add_suffix(mgmt_btn)
            mgmt_row.set_activatable_widget(mgmt_btn)
            actions_group.add(mgmt_row)
            actions_count += 1

        if contact.type in (ContactType.REPEATER, ContactType.ROOM):
            ping_row = Adw.ActionRow(
                title=_('Ping'),
                subtitle=_('Measure round-trip time to this node'),
            )
            ping_btn = Gtk.Button(
                icon_name='network-transmit-receive-symbolic', valign=Gtk.Align.CENTER,
            )
            ping_btn.connect('clicked', lambda *_: self._ping_contact(contact, ping_btn))
            ping_row.add_suffix(ping_btn)
            ping_row.set_activatable_widget(ping_btn)
            actions_group.add(ping_row)
            actions_count += 1

        # Path Discovery — works with repeaters and rooms only
        if contact.type in (ContactType.REPEATER, ContactType.ROOM):
            discover_row = Adw.ActionRow(
                title=_('Discover Paths'),
                subtitle=_('Find routes to this node via flood'),
            )
            self._discover_path_label = Gtk.Label(label='')
            self._discover_path_label.add_css_class('dim-label')
            self._discover_path_label.set_valign(Gtk.Align.CENTER)
            discover_row.add_suffix(self._discover_path_label)
            discover_btn = Gtk.Button(
                icon_name='system-search-symbolic', valign=Gtk.Align.CENTER,
            )
            discover_btn.add_css_class('flat')
            discover_btn.connect('clicked', lambda *_: self._on_path_discovery(contact, discover_btn))
            discover_row.add_suffix(discover_btn)
            discover_row.set_activatable_widget(discover_btn)
            actions_group.add(discover_row)
            actions_count += 1

        if actions_count > 0:
            content.append(actions_group)

        # Danger Zone
        danger_group = Adw.PreferencesGroup(title=_('Danger Zone'))

        remove_row = Adw.ActionRow(title=_('Remove Contact'))
        remove_btn = Gtk.Button(icon_name='user-trash-symbolic', valign=Gtk.Align.CENTER)
        remove_btn.add_css_class('destructive-action')

        def _on_remove(*args):
            confirm = Adw.AlertDialog(
                heading=_('Remove Contact?'),
                body=_('Remove {} and all messages?').format(contact.name),
            )
            confirm.add_response('cancel', _('Cancel'))
            confirm.add_response('remove', _('Remove'))
            confirm.set_response_appearance('remove', Adw.ResponseAppearance.DESTRUCTIVE)
            confirm.connect('response', lambda d, r: (
                self._window.remove_contact(contact), dialog.close()
            ) if r == 'remove' else None)
            confirm.present(self._window)

        remove_btn.connect('clicked', _on_remove)
        remove_row.add_suffix(remove_btn)
        remove_row.set_activatable_widget(remove_btn)
        danger_group.add(remove_row)

        content.append(danger_group)

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