# 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')
from gi.repository import Adw, Gdk, Gtk, GLib, 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 parse_cayenne_lpp, LPP_GPS, LPP_VOLTAGE, LPP_TEMPERATURE, LPP_ALTITUDE
        from meshy.models import ContactType

        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
        map_widget, marker_layer, viewport = create_shumate_map(
            zoom_level=15, lat=lat, lon=lon)
        map_widget.set_size_request(-1, 350)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
                      halign=Gtk.Align.CENTER)
        dot = Gtk.Label()
        dot.set_markup('<span color="#3584e4" size="14000">\u25CF</span>')
        box.append(dot)
        lbl = Gtk.Label(label=name)
        lbl.add_css_class('caption')
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
        lbl.set_attributes(attrs)
        box.append(lbl)
        marker = Shumate.Marker()
        marker.set_child(box)
        marker.set_location(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.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_RADIO, STATS_TYPE_PACKETS

        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
                try:
                    status_map_btn.disconnect_by_func(status_map_btn._handler)
                except (AttributeError, TypeError):
                    pass
                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 (
            MapView, _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, _UNKNOWN_COLOR,
        )

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

        # Build full hop list: (lat, lon, contact, prefix, snr)
        raw_hops = []
        if has_self:
            raw_hops.append((self_lat, self_lon, None, None, None))
        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))

        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'
            has_full_key = len(pub_key_hex) >= 64

            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)
