# 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, Gio, GLib, GObject, Gtk, Shumate
from meshy.models import Contact, ContactType
from meshy.protocol import build_sync_next_message
from meshy.views import (
    _add_hover_swap,
    _contact_pick_factory_bind,
    _contact_pick_factory_setup,
    _contact_pick_factory_unbind,
    _ContactPickItem,
)


@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
            or f == self.FILTER_USERS
            and contact.type != ContactType.CHAT
            or f == self.FILTER_REPEATERS
            and contact.type != ContactType.REPEATER
            or f == self.FILTER_ROOMS
            and contact.type != ContactType.ROOM
            or f == self.FILTER_SENSORS
            and contact.type != ContactType.SENSOR
        ):
            return False

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

    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
        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()
        return not (
            f == "users"
            and contact.type != ContactType.CHAT
            or f == "repeaters"
            and contact.type != ContactType.REPEATER
            or f == "rooms"
            and contact.type != ContactType.ROOM
            or f == "sensors"
            and contact.type != ContactType.SENSOR
        )

    @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 parse_qs, urlparse

        # 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("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, deregister_map_widget

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

            from meshy.views.map_view import _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, MapView

            map_widget, marker_layer, viewport = create_shumate_map(zoom_level=14, lat=lat, lon=lon)
            map_widget.set_vexpand(False)
            map_widget.set_size_request(-1, 250)

            r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5))
            icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic")
            marker = MapView.create_marker(r, g, b, icon_name, contact.name, lat, lon)
            marker_layer.add_marker(marker)

            # 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")
                self_marker = MapView.create_marker(
                    *_SELF_COLOR, "avatar-default-symbolic", self_name, self_lat, self_lon
                )
                marker_layer.add_marker(self_marker)
                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)
        if gps_entry:
            dialog.connect("closed", lambda _d: deregister_map_widget(map_widget))
        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 (
            _MARKER_COLORS,
            _MARKER_ICONS,
            _SELF_COLOR,
            _UNKNOWN_COLOR,
            MapView,
        )

        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 _MARKER_COLORS, _MARKER_ICONS, MapView

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

        r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5))
        icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic")
        marker = MapView.create_marker(
            r, g, b, icon_name, contact.name, contact.latitude, contact.longitude
        )
        marker_layer.add_marker(marker)

        viewport.set_zoom_level(14)
        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_ENV,
            CONTACT_FLAG_TELE_LOC,
        )

        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

        if contact.type == ContactType.CHAT:
            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)
