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

"""Device information view."""

import json
import logging
import threading
import urllib.request

import gi

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

from gi.repository import Adw, Gdk, GLib, Gtk, Pango, Shumate
from meshy.models import DeviceInfo, snr_to_icon

log = logging.getLogger(__name__)

_RELEASES_URL = "https://flasher.meshcore.io/releases"
_FLASHER_URL = "https://flasher.meshcore.io"


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/device-view.ui")
class DeviceView(Gtk.Box):
    __gtype_name__ = "MeshyDeviceView"

    _scroll = Gtk.Template.Child("scroll")
    _status_group = Gtk.Template.Child("status_group")
    _status_row = Gtk.Template.Child("status_row")
    _status_icon = Gtk.Template.Child("status_icon")
    _info_group = Gtk.Template.Child("info_group")
    _name_row = Gtk.Template.Child("name_row")
    _board_row = Gtk.Template.Child("board_row")
    _fw_row = Gtk.Template.Child("fw_row")
    _fw_update_btn = Gtk.Template.Child("fw_update_btn")
    _key_row = Gtk.Template.Child("key_row")
    _copy_key_btn = Gtk.Template.Child("copy_key_btn")
    _telemetry_row = Gtk.Template.Child("telemetry_row")
    _telemetry_btn = Gtk.Template.Child("telemetry_btn")
    _location_row = Gtk.Template.Child("location_row")
    _location_map_btn = Gtk.Template.Child("location_map_btn")
    _batt_group = Gtk.Template.Child("batt_group")
    _batt_row = Gtk.Template.Child("batt_row")
    _batt_bar = Gtk.Template.Child("batt_bar")
    _storage_row = Gtk.Template.Child("storage_row")
    _storage_bar = Gtk.Template.Child("storage_bar")
    _radio_group = Gtk.Template.Child("radio_group")
    _freq_row = Gtk.Template.Child("freq_row")
    _bw_row = Gtk.Template.Child("bw_row")
    _sf_row = Gtk.Template.Child("sf_row")
    _cr_row = Gtk.Template.Child("cr_row")
    _tx_row = Gtk.Template.Child("tx_row")
    _path_hash_row = Gtk.Template.Child("path_hash_row")
    _stats_group = Gtk.Template.Child("stats_group")
    _uptime_row = Gtk.Template.Child("uptime_row")
    _queue_row = Gtk.Template.Child("queue_row")
    _noise_row = Gtk.Template.Child("noise_row")
    _rssi_row = Gtk.Template.Child("rssi_row")
    _snr_row = Gtk.Template.Child("snr_row")
    _airtime_row = Gtk.Template.Child("airtime_row")
    _pkt_row = Gtk.Template.Child("pkt_row")
    _pkt_flood_row = Gtk.Template.Child("pkt_flood_row")
    _pkt_direct_row = Gtk.Template.Child("pkt_direct_row")
    _refresh_stats_btn = Gtk.Template.Child("refresh_stats_btn")

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window

        self._location_lat = None
        self._location_lon = None
        self._location_pending_lat = None
        self._location_pending_lon = None
        self._gps_enabled = None  # None = unknown, True/False from custom_vars

        self._section_widgets = [
            ("Connection", self._status_group),
            ("Device Information", self._info_group),
            ("Battery &amp; Storage", self._batt_group),
            ("Radio Configuration", self._radio_group),
            ("Statistics", self._stats_group),
        ]

        self._latest_fw_version = None

        # Connect signals that reference self._window
        self._copy_key_btn.connect("clicked", self._on_copy_public_key)
        self._telemetry_btn.connect("clicked", self._on_request_telemetry)
        self._location_map_btn.connect("clicked", self._on_show_location_map)
        self._fw_update_btn.connect("clicked", self._on_fw_update_clicked)
        self._refresh_stats_btn.connect("clicked", lambda *_: self._window.refresh_stats())

    def _build_actions_group(self) -> Adw.PreferencesGroup:
        """Build the Actions preference group from UI template."""
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/device-actions.ui"
        )
        actions_group = builder.get_object("actions_group")

        builder.get_object("zero_hop_btn").connect(
            "clicked", lambda *_: self._window.send_advert(flood=False)
        )
        builder.get_object("flood_btn").connect(
            "clicked", lambda *_: self._window.send_advert(flood=True)
        )
        builder.get_object("clipboard_btn").connect("clicked", self._on_advert_to_clipboard)
        builder.get_object("qr_btn").connect("clicked", self._on_show_qr_code)
        builder.get_object("trace_btn").connect("clicked", self._on_trace_path)
        builder.get_object("discover_btn").connect("clicked", self._on_discover_nearby)
        builder.get_object("rx_log_btn").connect("clicked", self._on_rx_log)
        builder.get_object("reboot_btn").connect("clicked", self._on_reboot)

        return actions_group

    def build_sidebar_actions(self) -> Gtk.Widget:
        """Build a fresh Actions group for placement in the sidebar."""
        return self._build_actions_group()

    @property
    def sections(self) -> list[str]:
        return [name for name, _w in self._section_widgets]

    def scroll_to_section(self, section_name: str):
        for name, widget in self._section_widgets:
            if name == section_name:
                widget.grab_focus()
                # Scroll the widget into view
                GLib.idle_add(self._do_scroll_to, widget)
                break

    def _do_scroll_to(self, widget):
        # Get widget position relative to the scrolled window
        success, x, y = widget.translate_coordinates(self._scroll.get_child(), 0, 0)
        if success:
            adj = self._scroll.get_vadjustment()
            adj.set_value(y)
        return False

    def _on_copy_public_key(self, *args):
        key = self._key_row.get_subtitle()
        if key and key != "--":
            clipboard = self._window.get_clipboard()
            clipboard.set(key)
            self._window.show_toast(_("Public key copied"))

    def _on_advert_to_clipboard(self, *args):
        """Export self contact and copy to clipboard."""
        self._window.export_self_to_clipboard()

    def _on_show_qr_code(self, *args):
        """Show a QR code dialog with structured meshcore:// URL."""
        info = self._window.device_info
        if not info.public_key:
            self._window.show_toast(_("Device public key not available"))
            return
        self._show_qr_dialog(None)

    def _show_qr_dialog(self, _unused=None):
        """Display a QR code dialog for sharing this device as a contact."""
        import io

        try:
            import segno
        except ImportError:
            self._window.show_toast(_("QR code library not available"))
            return

        dialog = Adw.Dialog()
        dialog.set_title(_("Your Contact QR Code"))
        dialog.set_content_width(380)
        dialog.set_content_height(440)

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

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

        # Device name
        info = self._window.device_info
        name = info.name or "My Device"
        name_label = Gtk.Label(label=name)
        name_label.add_css_class("title-2")
        content.append(name_label)

        # Build structured URL (accepted by official client)
        from urllib.parse import quote

        structured_uri = (
            f"meshcore://contact/add?name={quote(name)}" f"&public_key={info.public_key}&type=1"
        )

        qr = segno.make(structured_uri)
        png_buffer = io.BytesIO()
        qr.save(png_buffer, kind="png", scale=8, border=2)
        texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(png_buffer.getvalue()))

        picture = Gtk.Picture.new_for_paintable(texture)
        picture.set_content_fit(Gtk.ContentFit.CONTAIN)
        picture.set_size_request(280, 280)
        content.append(picture)

        # Instructions
        hint = Gtk.Label(label=_("Scan this QR code to add this contact"))
        hint.add_css_class("dim-label")
        content.append(hint)

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

    def _on_factory_reset(self, *args):
        dialog = Adw.AlertDialog(
            heading=_("Factory Reset?"),
            body=_(
                "This will permanently erase ALL data on the companion "
                "device, including contacts, messages, channels, settings, "
                "and the device identity (keys). "
                "This action cannot be undone."
            ),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("reset", _("Factory Reset"))
        dialog.set_response_appearance("reset", Adw.ResponseAppearance.DESTRUCTIVE)

        def _on_response(d, response):
            if response == "reset":
                self._window.factory_reset_device()

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

    def _on_reboot(self, *args):
        dialog = Adw.AlertDialog(
            heading=_("Reboot Device?"),
            body=_("The device will disconnect and restart."),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("reboot", _("Reboot"))
        dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE)

        def _on_response(d, response):
            if response == "reboot":
                self._window.reboot_device()

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

    def update_device_info(self, info: DeviceInfo):
        self._name_row.set_subtitle(info.name or "--")
        self._board_row.set_subtitle(info.model or "--")
        self._fw_row.set_subtitle(
            f"{info.ver} ({info.fw_build})" if info.ver and info.fw_build else info.ver or "--"
        )
        if info.ver:
            self._check_firmware_update(info.ver)
        if info.public_key:
            self._key_row.set_subtitle(info.public_key)

        # Battery
        if info.battery_mv > 0:
            pct = info.battery_percent
            self._batt_row.set_subtitle(f"{pct}% ({info.battery_mv} mV)")
            self._batt_bar.set_value(pct)

        # Storage
        if info.storage_total_kb > 0:
            spct = info.storage_percent
            self._storage_row.set_subtitle(
                f"{spct}% ({info.storage_used_kb} / {info.storage_total_kb} KB)"
            )
            self._storage_bar.set_value(spct)

        # Radio - radio_freq is in MHz, radio_bw is in kHz
        if info.radio_freq:
            self._freq_row.set_subtitle(f"{info.radio_freq:.3f} MHz")
        if info.radio_bw:
            self._bw_row.set_subtitle(f"{info.radio_bw:.1f} kHz")
        if info.radio_sf:
            self._sf_row.set_subtitle(f"SF{info.radio_sf}")
        if info.radio_cr:
            self._cr_row.set_subtitle(f"4/{info.radio_cr}")
        if info.tx_power:
            self._tx_row.set_subtitle(f"{info.tx_power} dBm")
        hash_labels = {
            0: _("1-byte (max 64 hops)"),
            1: _("2-byte (max 32 hops)"),
            2: _("3-byte (max 21 hops)"),
        }
        self._path_hash_row.set_subtitle(hash_labels.get(info.path_hash_mode, "--"))

    def update_location(self, lat: float, lon: float):
        """Update the location row from the device's advertised position."""
        if self._gps_enabled is None:
            # Custom vars not yet received — store for later
            self._location_pending_lat = lat
            self._location_pending_lon = lon
            return
        self._apply_location(lat, lon)

    def update_gps_enabled(self, enabled: bool):
        """Update GPS enabled state from custom_vars."""
        self._gps_enabled = enabled
        # Apply pending location now that we know GPS state
        if self._location_pending_lat is not None:
            self._apply_location(self._location_pending_lat, self._location_pending_lon)
            self._location_pending_lat = None
            self._location_pending_lon = None
        else:
            self._update_location_title()

    def _apply_location(self, lat: float, lon: float):
        has_pos = abs(lat) > 1e-6 or abs(lon) > 1e-6
        if has_pos:
            self._location_lat = lat
            self._location_lon = lon
            self._location_row.set_subtitle(self._format_location(lat, lon))
        else:
            self._location_lat = None
            self._location_lon = None
            self._location_row.set_subtitle(_("Not set"))
        self._location_map_btn.set_visible(has_pos)
        self._update_location_title()

    def _update_location_title(self):
        has_pos = self._location_lat is not None
        if self._gps_enabled is None:
            # No GPS hardware on companion
            self._location_row.set_title(_("Location (manually set)") if has_pos else _("Location"))
        elif self._gps_enabled:
            self._location_row.set_title(
                _("Location (GPS)") if has_pos else _("Location (GPS on, no fix)")
            )
        else:
            # GPS off but has position — set manually or via system location
            self._location_row.set_title(
                _("Location (manually set)") if has_pos else _("Location (GPS off)")
            )

    @staticmethod
    def _format_location(lat: float, lon: float) -> str:
        lat_dir = "N" if lat >= 0 else "S"
        lon_dir = "E" if lon >= 0 else "W"
        return f"{abs(lat):.6f}° {lat_dir}, {abs(lon):.6f}° {lon_dir}"

    def _on_request_telemetry(self, btn):
        """Request telemetry from the connected companion device."""
        from meshy.protocol import (
            LPP_ALTITUDE,
            LPP_GPS,
            LPP_TEMPERATURE,
            LPP_VOLTAGE,
            parse_cayenne_lpp,
        )

        pub_key_hex = self._window.device_info.public_key
        if not pub_key_hex:
            self._telemetry_row.set_subtitle(_("Not connected"))
            return

        btn.set_sensitive(False)
        self._telemetry_row.set_subtitle(_("Requesting…"))

        timeout_id = [None]

        def on_timeout():
            btn.set_sensitive(True)
            self._telemetry_row.set_subtitle(_("No response (timed out)"))
            timeout_id[0] = None
            return False

        def on_response(lpp_data):
            btn.set_sensitive(True)
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None
            if not lpp_data:
                self._telemetry_row.set_subtitle(_("No telemetry data"))
                return
            entries = parse_cayenne_lpp(lpp_data)
            if not entries:
                self._telemetry_row.set_subtitle(_("No telemetry data"))
                return

            # Build summary for the subtitle
            parts = []
            for e in entries:
                if e["type"] == LPP_TEMPERATURE:
                    parts.append(f"{e['value']:.1f} °C")
                elif e["type"] == LPP_ALTITUDE:
                    parts.append(f"{e['value']} m alt")
                elif e["type"] == LPP_VOLTAGE:
                    parts.append(f"{e['value']:.2f} V")
                elif e["type"] == LPP_GPS:
                    val = e["value"]
                    parts.append(f"{val['alt']:.0f} m alt")
            self._telemetry_row.set_subtitle(", ".join(parts) if parts else _("Telemetry received"))

        timeout_id[0] = GLib.timeout_add(15000, on_timeout)
        self._window.get_self_telemetry(on_response)

    def _on_show_location_map(self, btn):
        if self._location_lat is None or self._location_lon is None:
            return
        lat, lon = self._location_lat, self._location_lon
        name = self._window.device_info.name or "My Device"

        dialog = Adw.Dialog()
        dialog.set_title(_("Device Location"))
        dialog.set_content_width(400)
        dialog.set_content_height(450)

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

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

        # Coordinates label
        coords = Gtk.Label(label=self._format_location(lat, lon))
        coords.add_css_class("caption")
        content.append(coords)

        # Map
        from meshy.views import create_shumate_map, deregister_map_widget
        from meshy.views.map_view import _SELF_COLOR, MapView

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

        marker = MapView.create_marker(*_SELF_COLOR, "avatar-default-symbolic", name, lat, lon)
        marker_layer.add_marker(marker)

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

        toolbar_view.set_content(content)
        dialog.set_child(toolbar_view)
        dialog.connect("closed", lambda _d: deregister_map_widget(map_widget))
        dialog.present(self._window)

    def update_stats(self, stats: dict):
        """Update statistics display from CMD_GET_STATS responses."""
        from meshy.protocol import STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO

        core = stats.get(STATS_TYPE_CORE, {})
        if core:
            secs = core.get("uptime_secs", 0)
            days, rem = divmod(secs, 86400)
            hours, rem = divmod(rem, 3600)
            mins, _secs = divmod(rem, 60)
            parts = []
            if days:
                parts.append(_("{days}d").format(days=days))
            if hours:
                parts.append(_("{hours}h").format(hours=hours))
            parts.append(_("{mins}m").format(mins=mins))
            self._uptime_row.set_subtitle(" ".join(parts))
            self._queue_row.set_subtitle(_("{} messages").format(core.get("queue_len", 0)))

        radio = stats.get(STATS_TYPE_RADIO, {})
        if radio:
            self._noise_row.set_subtitle(f'{radio.get("noise_floor", 0)} dBm')
            self._rssi_row.set_subtitle(f'{radio.get("last_rssi", 0)} dBm')
            self._snr_row.set_subtitle(f'{radio.get("last_snr", 0):.1f} dB')
            tx_s = radio.get("tx_air_secs", 0)
            rx_s = radio.get("rx_air_secs", 0)
            self._airtime_row.set_subtitle(f"TX: {tx_s}s / RX: {rx_s}s")

        pkts = stats.get(STATS_TYPE_PACKETS, {})
        if pkts:
            recv = pkts.get("recv", 0)
            sent = pkts.get("sent", 0)
            errs = pkts.get("recv_errors")
            sub = f"RX: {recv} / TX: {sent}"
            if errs is not None:
                sub += " / " + _("{count} errors").format(count=errs)
            self._pkt_row.set_subtitle(sub)
            self._pkt_flood_row.set_subtitle(
                f'TX: {pkts.get("flood_tx", 0)} / RX: {pkts.get("flood_rx", 0)}'
            )
            self._pkt_direct_row.set_subtitle(
                f'TX: {pkts.get("direct_tx", 0)} / RX: {pkts.get("direct_rx", 0)}'
            )

    def update_connection_status(self, connected: bool, transport: str = ""):
        """Update the connection status icon and text."""
        if connected:
            transport_labels = {"ble": "Bluetooth", "usb": "USB", "tcp": "TCP"}
            label = transport_labels.get(transport, "")
            if label:
                self._status_row.set_subtitle("{} \u00b7 {}".format(label, _("Connected")))
            else:
                self._status_row.set_subtitle(_("Connected"))
            self._status_icon.set_from_icon_name("charge-symbolic")
        else:
            self._status_row.set_subtitle(_("Disconnected"))
            self._status_icon.set_from_icon_name("network-offline-symbolic")

    def _on_trace_path(self, *args):
        """Open the Trace Path dialog."""
        from meshy.models import ContactType

        # 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(_("Trace Path is not supported with 3-byte path hashes"))
            return

        dialog = Adw.Dialog()
        dialog.set_title(_("Trace Path"))
        dialog.set_content_width(460)
        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=8,
            margin_start=8,
            margin_end=8,
            margin_bottom=8,
        )

        # Path hash size: mode 0→1 byte, mode 1→2 bytes, mode 2→3 bytes
        hash_width = self._window.device_info.path_hash_mode + 1
        examples = {1: _("e.g. aa,bb,cc"), 2: _("e.g. aabb,ccdd"), 3: _("e.g. aabbcc,ddeeff")}

        # Own node hash for bookending the trace
        self_pub_key = (
            bytes.fromhex(self._window.device_info.public_key)
            if self._window.device_info.public_key
            else b""
        )
        self_hash = self_pub_key[:hash_width] if len(self_pub_key) >= hash_width else b""
        self_name = self._window.device_info.name or "This device"

        # Add from Contacts button (above path entry)
        add_contact_btn = Gtk.MenuButton(label=_("Add from Contacts"))
        add_contact_btn.add_css_class("flat")
        add_contact_btn.set_halign(Gtk.Align.START)

        popover = Gtk.Popover()
        popover.set_size_request(300, -1)
        pop_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        pop_box.set_margin_start(8)
        pop_box.set_margin_end(8)
        pop_box.set_margin_top(8)
        pop_box.set_margin_bottom(8)

        search_entry = Gtk.SearchEntry(placeholder_text="Search repeaters...")
        pop_box.append(search_entry)

        repeater_list = Gtk.ListBox()
        repeater_list.set_selection_mode(Gtk.SelectionMode.NONE)
        repeater_list.add_css_class("boxed-list")

        repeaters = [
            c for c in self._window.contacts if c.type in (ContactType.REPEATER, ContactType.ROOM)
        ]
        repeater_rows = []
        for contact in repeaters:
            hop_hex = contact.public_key[:hash_width].hex()
            row = Adw.ActionRow(title=GLib.markup_escape_text(contact.name), subtitle=hop_hex)
            row.set_activatable(True)
            row._hop_hex = hop_hex
            row._search_text = contact.name.lower()
            repeater_list.append(row)
            repeater_rows.append(row)

        if not repeaters:
            empty = Gtk.Label(label=_("No repeaters or rooms in contacts"))
            empty.add_css_class("dim-label")
            empty.set_margin_top(8)
            empty.set_margin_bottom(8)
            pop_box.append(empty)

        # Pick on Map button
        pick_map_btn = Gtk.Button(label=_("Pick on Map"))
        pick_map_btn.add_css_class("flat")
        has_located = any(c.has_location for c in repeaters)
        pick_map_btn.set_sensitive(has_located)
        if not has_located:
            pick_map_btn.set_tooltip_text(_("No repeaters with known location"))

        # Path input
        path_entry = Adw.EntryRow(title=_("Path"))
        path_entry.set_text("")

        btn_box = Gtk.Box(spacing=8)
        btn_box.append(add_contact_btn)
        pick_map_btn.set_hexpand(True)
        pick_map_btn.set_halign(Gtk.Align.END)
        btn_box.append(pick_map_btn)
        content.append(btn_box)
        content.append(path_entry)

        placeholder_label = Gtk.Label(label=examples.get(hash_width, examples[1]))
        placeholder_label.add_css_class("dim-label")
        placeholder_label.add_css_class("caption")
        placeholder_label.set_halign(Gtk.Align.START)
        placeholder_label.set_margin_start(12)
        content.append(placeholder_label)

        def on_search_changed(entry):
            query = entry.get_text().strip().lower()
            for row in repeater_rows:
                row.set_visible(query in row._search_text or query in row._hop_hex)

        search_entry.connect("search-changed", on_search_changed)

        def on_row_activated(lb, row):
            current = path_entry.get_text().strip()
            if current:
                path_entry.set_text(f"{current},{row._hop_hex}")
            else:
                path_entry.set_text(row._hop_hex)
            popover.popdown()

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

        repeater_scroll = Gtk.ScrolledWindow()
        repeater_scroll.set_child(repeater_list)
        repeater_scroll.set_min_content_height(120)
        repeater_scroll.set_max_content_height(300)
        pop_box.append(repeater_scroll)

        popover.set_child(pop_box)
        add_contact_btn.set_popover(popover)

        def on_pick_map(*args):
            from meshy.views.map_view import open_trace_picker_map

            def _on_map_done(hex_string):
                current = path_entry.get_text().strip()
                if current:
                    path_entry.set_text(f"{current},{hex_string}")
                else:
                    path_entry.set_text(hex_string)

            open_trace_picker_map(self._window, self._window.contacts, hash_width, _on_map_done)

        pick_map_btn.connect("clicked", on_pick_map)

        # Run button
        run_btn = Gtk.Button(label=_("Run Trace"))
        run_btn.add_css_class("suggested-action")
        run_btn.set_halign(Gtk.Align.CENTER)
        run_btn.set_margin_top(4)
        content.append(run_btn)

        # Separator
        content.append(Gtk.Separator())

        # Status
        status_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER)
        spinner = Gtk.Spinner()
        status_box.append(spinner)
        status_label = Gtk.Label(label=_("Enter path hashes and press Run Trace"))
        status_label.add_css_class("dim-label")
        status_box.append(status_label)
        status_map_btn = Gtk.Button(icon_name="map-symbolic")
        status_map_btn.add_css_class("flat")
        status_map_btn.set_tooltip_text("Show on Map")
        status_map_btn.set_visible(False)
        status_box.append(status_map_btn)
        content.append(status_box)

        # Results list
        results_scroll = Gtk.ScrolledWindow(vexpand=True)
        results_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        results_scroll.set_child(results_box)
        content.append(results_scroll)

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

        timeout_id = [None]
        trace_start = [0.0]

        def clear_results():
            child = results_box.get_first_child()
            while child:
                next_c = child.get_next_sibling()
                results_box.remove(child)
                child = next_c

        def add_node_row(label):
            """Add a node row to results."""
            row = Gtk.Label(label=label, xalign=0)
            row.set_margin_start(16)
            row.set_margin_top(6)
            row.set_margin_bottom(2)
            results_box.append(row)

        def add_snr_row(snr):
            """Add an SNR indicator row between nodes."""
            snr_text = f"\u2502  {snr:+.1f} dB"
            label = Gtk.Label(label=snr_text, xalign=0)
            label.set_margin_start(20)
            label.set_margin_top(2)
            label.set_margin_bottom(2)
            label.add_css_class("caption")
            if snr >= 0:
                label.add_css_class("success")
            elif snr >= -5:
                label.add_css_class("dim-label")
            else:
                label.add_css_class("error")
            results_box.append(label)

        def show_results(result):
            """Display trace results in the results box."""
            spinner.set_spinning(False)
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])
                timeout_id[0] = None

            hops = result["path_hops"]  # list of bytes objects (per-hop hashes)
            snr_values = result["snr_values"]
            clear_results()

            import time

            duration = time.monotonic() - trace_start[0]
            status_label.set_label(
                _("Trace complete: {} hops, {:.1f}s").format(len(hops), duration)
            )

            hop_hexes = [h.hex() for h in hops]
            hop_contacts = self._window.resolve_hop_prefixes(hop_hexes)

            # Start with companion node
            add_node_row(f"\u25cf {self_name} ({self_hash.hex()})")

            for i, hop_bytes in enumerate(hops):
                # SNR between previous node and this one
                if i < len(snr_values):
                    add_snr_row(snr_values[i])

                hop_hex = hop_bytes.hex()
                contact = hop_contacts[i]
                if contact:
                    add_node_row(f"\u25cf {contact.name} ({hop_hex})")
                else:
                    add_node_row(f"\u25cb {hop_hex}")

            # End with companion node (return path)
            if len(snr_values) > len(hops):
                add_snr_row(snr_values[len(hops)])
            add_node_row(f"\u25cf {self_name} ({self_hash.hex()})")

            # Show on Map button in status bar if any hop has a known location
            has_any_location = any(c and c.has_location for c in hop_contacts)
            if has_any_location:
                status_map_btn.set_visible(True)
                # Reconnect handler for new results
                with contextlib.suppress(AttributeError, TypeError):
                    status_map_btn.disconnect_by_func(status_map_btn._handler)

                def _on_map_clicked(
                    *_, hc=hop_contacts, snr=snr_values, hw=hash_width, hp=hop_hexes
                ):
                    self._show_trace_on_map(hc, snr, hw, hp)

                status_map_btn._handler = _on_map_clicked
                status_map_btn.connect("clicked", _on_map_clicked)
            else:
                status_map_btn.set_visible(False)

            run_btn.set_sensitive(True)

        def on_timeout():
            spinner.set_spinning(False)
            status_label.set_label(_("Trace timed out"))
            self._window.stop_trace_path()
            run_btn.set_sensitive(True)
            timeout_id[0] = None
            return False

        def on_run(*args):
            text = path_entry.get_text().strip()
            if not text:
                return
            parts = [p.strip() for p in text.split(",") if p.strip()]
            try:
                path_bytes = b""
                for p in parts:
                    path_bytes += bytes.fromhex(p)
            except ValueError:
                status_label.set_label(_("Invalid hex in path"))
                return

            trace_bytes = path_bytes

            clear_results()
            spinner.set_spinning(True)
            status_label.set_label(_("Tracing..."))
            status_map_btn.set_visible(False)
            run_btn.set_sensitive(False)
            import time

            trace_start[0] = time.monotonic()

            def on_device_timeout(timeout_ms):
                """Device told us how long to wait."""
                if timeout_id[0]:
                    GLib.source_remove(timeout_id[0])
                timeout_id[0] = GLib.timeout_add(timeout_ms, on_timeout)

            # Start with a fallback timeout; device may override with its own
            timeout_id[0] = GLib.timeout_add(30000, on_timeout)
            # Flag encodes hash size: mode 0→flag 0 (1B), mode 1→flag 1 (2B)
            trace_flags = self._window.device_info.path_hash_mode
            self._window.send_trace_path(
                trace_bytes, show_results, on_device_timeout, flags=trace_flags
            )

        run_btn.connect("clicked", on_run)

        def on_dialog_closed(d):
            self._window.stop_trace_path()
            if timeout_id[0]:
                GLib.source_remove(timeout_id[0])

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

    def _show_trace_on_map(self, hop_contacts, snr_values, hash_width, hop_prefixes=None):
        """Show trace path results in a map dialog."""
        from meshy.views.map_view import (
            _MARKER_COLORS,
            _MARKER_ICONS,
            _SELF_COLOR,
            _UNKNOWN_COLOR,
            MapView,
            companion_too_far,
        )

        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: (lat, lon, contact, prefix, snr)
        raw_hops = []
        for i, contact in enumerate(hop_contacts):
            snr = snr_values[i] if i < len(snr_values) else None
            prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None
            if contact and contact.has_location:
                raw_hops.append((contact.latitude, contact.longitude, contact, prefix, snr))
            else:
                raw_hops.append((None, None, contact, prefix, snr))

        if has_self and not companion_too_far(self_lat, self_lon, raw_hops):
            raw_hops.insert(0, (self_lat, self_lon, None, 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=_("Trace Path")
        )

        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, contact, prefix, snr in hops:
            if lat is None:
                continue
            node = Shumate.Marker()
            node.set_location(lat, lon)
            path_layer.add_node(node)

            if contact is None and prefix is None:
                r, g, b = _SELF_COLOR
                icon_name = "network-wireless-symbolic"
                name = device.name or _("My Device")
                type_label = None
                badge = ""
            elif contact is None or not contact.has_location:
                r, g, b = _UNKNOWN_COLOR
                icon_name = "network-server-symbolic"
                name = prefix or _("Unknown")
                type_label = _("Unknown repeater")
                hop_num += 1
                badge = str(hop_num)
            else:
                r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5))
                icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic")
                name = contact.name
                type_label = contact.type_label
                hop_num += 1
                badge = str(hop_num)

            is_interpolated = (contact is None or not contact.has_location) and prefix is not None
            detail_cb = (lambda c=contact: self._window.show_contact_detail(c)) if contact else None
            widget = MapView._build_marker_widget(r, g, b, icon_name, "", badge_text=badge)
            widget.set_tooltip_text(name)
            click = Gtk.GestureClick()
            click.connect(
                "pressed",
                lambda g,
                n,
                x,
                y,
                w=widget,
                nm=name,
                la=lat,
                lo=lon,
                s=snr,
                t=type_label,
                ip=is_interpolated,
                od=detail_cb: MapView.show_marker_popover(
                    w, nm, la, lo, s, t, interpolated=ip, on_detail=od
                ),
            )
            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)

        # SNR labels at midpoints
        for i in range(1, len(hops)):
            if hops[i][0] is None or hops[i - 1][0] is None:
                continue
            snr = hops[i][4]
            if snr is None:
                continue
            mid_lat = (hops[i - 1][0] + hops[i][0]) / 2
            mid_lon = (hops[i - 1][1] + hops[i][1]) / 2
            snr_widget = MapView.build_snr_widget(snr)
            snr_marker = Shumate.Marker()
            snr_marker.set_child(snr_widget)
            snr_marker.set_location(mid_lat, mid_lon)
            marker_layer.add_marker(snr_marker)

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

    def _on_discover_nearby(self, *args):
        """Send a NODE_DISCOVER_REQ and show responding nodes live."""
        from meshy.models import ContactType

        dialog = Adw.Dialog()
        dialog.set_title(_("Discover Nearby Nodes"))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

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

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

        # Status with spinner
        status_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER)
        spinner = Gtk.Spinner(spinning=True)
        status_box.append(spinner)
        status_label = Gtk.Label(label=_("Scanning... {}s remaining").format(30))
        status_label.add_css_class("dim-label")
        status_box.append(status_label)
        content.append(status_box)

        # List of responding nodes
        scroll = Gtk.ScrolledWindow(vexpand=True)
        list_wrapper = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            margin_start=8,
            margin_end=8,
            margin_top=8,
            margin_bottom=8,
        )
        list_box = Gtk.ListBox()
        list_box.set_selection_mode(Gtk.SelectionMode.NONE)
        list_box.add_css_class("boxed-list")

        placeholder = Adw.StatusPage(
            icon_name="edit-find-symbolic",
            title=_("Listening..."),
            description=_("Waiting for nearby nodes to respond"),
        )
        list_box.set_placeholder(placeholder)

        list_wrapper.append(list_box)
        scroll.set_child(list_wrapper)
        content.append(scroll)

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

        seen = set()
        seconds_left = [30]
        timer_id = [None]

        type_icons = {
            ContactType.CHAT: "avatar-default-symbolic",
            ContactType.REPEATER: "network-server-symbolic",
            ContactType.ROOM: "user-home-symbolic",
            ContactType.SENSOR: "weather-clear-symbolic",
        }
        type_labels = {
            ContactType.CHAT: _("Chat"),
            ContactType.REPEATER: _("Repeater"),
            ContactType.ROOM: _("Room Server"),
            ContactType.SENSOR: _("Sensor"),
        }

        # Track rows by key prefix so adverts can update discover rows
        rows_by_prefix = {}  # key_prefix → row

        # Companion location for distance calculation
        dev = self._window.device_info
        self_lat = dev.latitude if dev else None
        self_lon = dev.longitude if dev else None
        has_self_loc = (
            self_lat is not None
            and self_lon is not None
            and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6)
        )
        log.debug("Discover: companion loc=%s/%s has_self_loc=%s", self_lat, self_lon, has_self_loc)

        def _distance_str(contact):
            if not has_self_loc or not contact or not contact.has_location:
                return ""
            from meshy.utils import haversine_distance

            dist = haversine_distance(self_lat, self_lon, contact.latitude, contact.longitude)
            if dist < 1.0:
                return f"{dist * 1000:.0f} m"
            return f"{dist:.1f} km"

        def _add_node_button(row):
            """Create an Add to Contacts button for unknown nodes."""
            btn = Gtk.Button(icon_name="list-add-symbolic", valign=Gtk.Align.CENTER)
            btn.set_tooltip_text(_("Add to Contacts"))

            def on_add(_btn):
                info = row._node_info
                self._window.add_contact(info["pub_key"], info["type"], info["name"])
                _btn.set_sensitive(False)
                _btn.set_icon_name("object-select-symbolic")
                self._window.show_toast(_("Added {}").format(info["name"]))

            btn.connect("clicked", on_add)
            row.add_suffix(btn)

        def on_discover_response(result):
            """Handle a NODE_DISCOVER_RESP."""
            pub_key_hex = result.get("pub_key_hex", "")
            if not pub_key_hex:
                return
            if pub_key_hex in seen:
                return
            seen.add(pub_key_hex)

            snr = result.get("snr", 0)
            node_type = result.get("node_type", 1)
            snr_str = f"SNR: {snr:.1f} dB"

            contact = self._window.find_contact_by_key_prefix(pub_key_hex)
            is_known = contact is not None and self._window.is_contact_known(contact.public_key_hex)
            if contact:
                name = contact.name
                label = f"{contact.type_label} \u00b7 {snr_str}"
                icon = type_icons.get(contact.type, "avatar-default-symbolic")
                log.debug(
                    "Discover resp: %s found has_loc=%s", pub_key_hex[:16], contact.has_location
                )
            else:
                name = f"0x{pub_key_hex[:16].upper()}"
                label = "{} \u00b7 {}".format(type_labels.get(node_type, _("Node")), snr_str)
                icon = type_icons.get(node_type, "network-server-symbolic")
                log.debug("Discover resp: %s not found", pub_key_hex[:16])

            dist = _distance_str(contact)
            if dist:
                label += f" \u00b7 {dist}"

            row = Adw.ActionRow(title=name, subtitle=label)
            row.add_prefix(Gtk.Image.new_from_icon_name(icon))

            is_discovered = contact is not None and not is_known
            if is_discovered:
                row._node_info = {"pub_key": contact.public_key, "type": contact.type, "name": name}
                _add_node_button(row)

            rows_by_prefix[pub_key_hex] = row
            list_box.append(row)

        def on_advert(contact):
            """Also listen for adverts triggered by the discover."""
            # Check if we already have a row for this node from discover response
            existing_row = None
            for prefix, row in rows_by_prefix.items():
                if contact.public_key_hex.startswith(prefix) or prefix.startswith(
                    contact.public_key_hex[:16]
                ):
                    existing_row = row
                    break

            if existing_row:
                # Update existing row with advert data (name, distance)
                adv_name = contact.name or existing_row.get_title()
                existing_row.set_title(adv_name)
                dist = _distance_str(contact)
                if dist:
                    cur_sub = existing_row.get_subtitle() or ""
                    if "km" not in cur_sub and " m" not in cur_sub:
                        existing_row.set_subtitle(f"{cur_sub} · {dist}")
                is_known = self._window.is_contact_known(contact.public_key_hex)
                if hasattr(existing_row, "_node_info"):
                    existing_row._node_info["name"] = adv_name
                    existing_row._node_info["pub_key"] = contact.public_key
                    existing_row._node_info["type"] = contact.type
                elif not is_known:
                    existing_row._node_info = {
                        "pub_key": contact.public_key,
                        "type": contact.type,
                        "name": adv_name,
                    }
                    _add_node_button(existing_row)
                seen.add(contact.public_key_hex)
                return

            if contact.public_key_hex in seen:
                return
            seen.add(contact.public_key_hex)

            icon = type_icons.get(contact.type, "avatar-default-symbolic")
            is_known = self._window.is_contact_known(contact.public_key_hex)
            log.debug(
                "Discover advert: %s has_loc=%s", contact.public_key_hex[:16], contact.has_location
            )

            subtitle = contact.type_label
            dist = _distance_str(contact)
            if dist:
                subtitle += f" · {dist}"
            row = Adw.ActionRow(
                title=contact.name or _("Unknown"),
                subtitle=subtitle,
            )
            row.add_prefix(Gtk.Image.new_from_icon_name(icon))
            if not is_known:
                row._node_info = {
                    "pub_key": contact.public_key,
                    "type": contact.type,
                    "name": contact.name or _("Unknown"),
                }
                _add_node_button(row)
            rows_by_prefix[contact.public_key_hex] = row
            list_box.append(row)

        def on_tick():
            seconds_left[0] -= 1
            if seconds_left[0] <= 0:
                stop_scanning()
                return False
            status_label.set_label(_("Scanning... {}s remaining").format(seconds_left[0]))
            return True

        def stop_scanning():
            self._window.stop_discover()
            self._window.set_advert_listener(None)
            spinner.set_spinning(False)
            count = len(seen)
            status_label.set_label(
                _("Done. {} nodes found.").format(count)
                if count != 1
                else _("Done. {} node found.").format(count)
            )
            if timer_id[0]:
                GLib.source_remove(timer_id[0])
                timer_id[0] = None

        def on_dialog_closed(d):
            stop_scanning()

        dialog.connect("closed", on_dialog_closed)

        # Start scanning - send both discover req and zero-hop advert
        self._window.set_advert_listener(on_advert)
        self._window.send_discover_req(on_discover_response)
        # Also send a zero-hop advert - this is the proven method
        # that triggers nearby nodes to respond with their adverts
        GLib.timeout_add(200, lambda: self._window.send_advert(flood=False, silent=True) or False)
        timer_id[0] = GLib.timeout_add_seconds(1, on_tick)

        dialog.present(self._window)

    def _on_rx_log(self, *args):
        """Open the Rx Log dialog showing received radio packets."""
        builder = Gtk.Builder.new_from_resource("/page/codeberg/sesivany/Meshy/ui/rx-log-dialog.ui")
        dialog = builder.get_object("rx_log_dialog")
        list_box = builder.get_object("rx_log_list")
        clear_btn = builder.get_object("clear_btn")
        placeholder = builder.get_object("rx_log_placeholder")
        list_box.set_placeholder(placeholder)

        sf = self._window.device_info.radio_sf or 12

        def _make_row(entry):
            box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
            box.set_margin_start(12)
            box.set_margin_end(12)
            box.set_margin_top(8)
            box.set_margin_bottom(8)

            header_box = Gtk.Box(spacing=8)
            title = Gtk.Label(
                label=f"{entry.route_type_name} {entry.payload_type_name}", xalign=0, hexpand=True
            )
            title.add_css_class("heading")
            header_box.append(title)
            snr_icon = Gtk.Image.new_from_icon_name(snr_to_icon(entry.snr, sf))
            header_box.append(snr_icon)
            snr_label = Gtk.Label(label=f"{entry.snr:.2f}dB", xalign=1)
            snr_label.add_css_class("dim-label")
            header_box.append(snr_label)
            box.append(header_box)

            def _detail(text, wrap=False):
                lbl = Gtk.Label(label=text, xalign=0)
                lbl.add_css_class("caption")
                lbl.add_css_class("dim-label")
                if wrap:
                    lbl.set_wrap(True)
                    lbl.set_wrap_mode(Pango.WrapMode.WORD_CHAR)
                box.append(lbl)

            _detail(
                f'{entry.timestamp.strftime("%H:%M:%S")} \u00b7 '
                f'{_("Size")}: {entry.size} {_("bytes")}'
            )
            if entry.pkt_hash:
                _detail(f"Hash: {entry.pkt_hash.upper()}")
            path_csv = ",".join(entry.path) if entry.path else ""
            _detail(f'{_("Path")}: {len(entry.path)} {_("hops")} ' f'[{path_csv}]', wrap=True)
            _detail(f'{_("Path Hashes")}: {entry.path_hash_size}-{_("byte per hop")}')
            if entry.transport_from and entry.transport_to:
                _detail(f"From: <{entry.transport_from}> " f"To: <{entry.transport_to}>")

            return box

        for entry in reversed(self._window.rx_log):
            list_box.append(_make_row(entry))

        def on_new_entry(entry):
            list_box.prepend(_make_row(entry))

        self._window.set_rx_log_callback(on_new_entry)

        def on_clear(*_):
            self._window.rx_log.clear()
            child = list_box.get_first_child()
            while child:
                next_c = child.get_next_sibling()
                list_box.remove(child)
                child = next_c

        clear_btn.connect("clicked", on_clear)

        def on_closed(d):
            self._window.set_rx_log_callback(None)

        dialog.connect("closed", on_closed)
        dialog.present(self._window)

    # ─── Firmware update check ──────────────────────────────

    @staticmethod
    def _parse_version(ver_str):
        ver = ver_str.strip().lstrip("v")
        ver = ver.split("-")[0]
        try:
            parts = [int(x) for x in ver.split(".")]
            while len(parts) < 3:
                parts.append(0)
            return tuple(parts)
        except (ValueError, AttributeError):
            return (0, 0, 0)

    def _check_firmware_update(self, current_ver):
        if self._latest_fw_version is not None:
            self._show_update_if_needed(current_ver, self._latest_fw_version)
            return

        def fetch():
            try:
                req = urllib.request.Request(_RELEASES_URL, headers={"User-Agent": "Meshy"})
                with urllib.request.urlopen(req, timeout=10) as resp:
                    releases = json.loads(resp.read())
                for entry in releases:
                    if entry.get("type") == "companion":
                        latest = entry["version"]
                        GLib.idle_add(self._on_releases_fetched, current_ver, latest)
                        return
            except Exception as e:
                log.debug("Firmware update check failed: %s", e)

        threading.Thread(target=fetch, daemon=True).start()

    def _on_releases_fetched(self, current_ver, latest_ver):
        self._latest_fw_version = latest_ver
        self._show_update_if_needed(current_ver, latest_ver)
        return False

    def _show_update_if_needed(self, current_ver, latest_ver):
        current = self._parse_version(current_ver)
        latest = self._parse_version(latest_ver)
        if latest > current:
            self._fw_update_btn.set_visible(True)
            self._fw_update_btn.set_tooltip_text(
                _("Update available: {version}").format(version=latest_ver)
            )
        else:
            self._fw_update_btn.set_visible(False)

    def _on_fw_update_clicked(self, btn):
        launcher = Gtk.UriLauncher.new(_FLASHER_URL)
        launcher.launch(self._window, None, None)
