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

"""Map view - shows contacts with known locations on an OpenStreetMap."""

import math

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, GLib, Gtk, Pango, Shumate
from meshy.models import Contact, ContactType

# Marker colors by contact type (R, G, B)
_MARKER_COLORS = {
    ContactType.CHAT: (0.26, 0.52, 0.96),  # Blue
    ContactType.REPEATER: (0.30, 0.69, 0.31),  # Green
    ContactType.ROOM: (0.61, 0.32, 0.88),  # Purple
    ContactType.SENSOR: (1.0, 0.60, 0.0),  # Orange
}

_MARKER_ICONS = {
    ContactType.CHAT: "avatar-default-symbolic",
    ContactType.REPEATER: "network-server-symbolic",
    ContactType.ROOM: "user-home-symbolic",
    ContactType.SENSOR: "weather-clear-symbolic",
}

# Self marker color (Red) - MeshCore companion GPS
_SELF_COLOR = (0.90, 0.16, 0.22)

# Unknown/interpolated hop color (grey)
_UNKNOWN_COLOR = (0.55, 0.55, 0.55)

# Cluster color (dark grey)
_CLUSTER_COLOR = (0.35, 0.35, 0.40)

# Route colors for multi-path map display (smart routing)
_ROUTE_COLORS = [
    (0.13, 0.59, 0.95),  # Blue
    (0.96, 0.26, 0.21),  # Red
    (0.30, 0.69, 0.31),  # Green
    (0.98, 0.74, 0.02),  # Amber
    (0.61, 0.15, 0.69),  # Purple
    (1.00, 0.34, 0.13),  # Deep Orange
    (0.00, 0.59, 0.53),  # Teal
    (0.91, 0.12, 0.39),  # Pink
]

# Max distance (km) between companion and nearest hop before hiding companion
_COMPANION_MAX_DISTANCE_KM = 30

# Minimum pixel distance between markers before clustering
_CLUSTER_RADIUS_PX = 60


_MAX_LATITUDE = 85.0511287798


def companion_too_far(self_lat, self_lon, raw_hops):
    """Return True if the companion is > _COMPANION_MAX_DISTANCE_KM from every located hop."""
    from meshy.utils import haversine_distance

    for lat, lon, _contact, _prefix, _snr in raw_hops:
        if lat is not None:
            if haversine_distance(self_lat, self_lon, lat, lon) <= _COMPANION_MAX_DISTANCE_KM:
                return False
    return True


def _lat_lon_to_pixels(lat, lon, zoom):
    """Convert lat/lon to pixel coordinates at a given zoom level."""
    lat = max(-_MAX_LATITUDE, min(_MAX_LATITUDE, lat))
    n = 2.0**zoom
    x = (lon + 180.0) / 360.0 * n * 256
    lat_rad = math.radians(lat)
    y = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n * 256
    return x, y


class MapView(Gtk.Box):
    """Full map widget showing contacts with known locations."""

    def __init__(self, window):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)
        self._window = window
        self._located_contacts: list[Contact] = []
        self._self_lat = None
        self._self_lon = None
        self._last_zoom = -1
        self._zoom_handler_id = None
        self._recluster_timeout_id = None
        self._expanded_clusters: list[list[Contact]] = []  # clusters forced open
        self._all_contacts: list[Contact] = []  # all contacts with location (unfiltered)
        self._discovered_contacts: list[Contact] = []
        self._show_discovered: bool = False
        self._trace_path_layer: Shumate.PathLayer | None = None
        self._trace_marker_layer: Shumate.MarkerLayer | None = None
        self._trace_anim_id: int | None = None
        self._visible_types: set[int] = {
            ContactType.CHAT,
            ContactType.REPEATER,
            ContactType.ROOM,
            ContactType.SENSOR,
        }
        self._build_ui()

    def cleanup(self):
        """Break reference cycles so GC can collect this view and its map."""
        self.clear_trace_path()
        if self._recluster_timeout_id:
            GLib.source_remove(self._recluster_timeout_id)
            self._recluster_timeout_id = None
        if self._zoom_handler_id is not None:
            self._map.get_viewport().disconnect(self._zoom_handler_id)
            self._zoom_handler_id = None
        self._marker_layer.remove_all()
        self._located_contacts.clear()
        self._all_contacts.clear()
        self._discovered_contacts.clear()
        self._expanded_clusters.clear()
        self._window = None

    def _build_ui(self):
        from meshy.views import create_shumate_map

        self._map, self._marker_layer, viewport = create_shumate_map(zoom_level=3)
        self._map.set_hexpand(True)

        # Listen for zoom changes to recluster
        self._zoom_handler_id = viewport.connect("notify::zoom-level", self._on_zoom_changed)

        self.append(self._map)

        # Legend overlay at bottom
        legend = Gtk.Box(spacing=12, halign=Gtk.Align.CENTER, margin_bottom=8, margin_top=4)
        legend.add_css_class("dim-label")
        legend.add_css_class("caption")
        for ct, label in [
            (ContactType.CHAT, _("User")),
            (ContactType.REPEATER, _("Repeater")),
            (ContactType.ROOM, _("Room")),
            (ContactType.SENSOR, _("Sensor")),
        ]:
            item = Gtk.Box(spacing=4)
            dot = Gtk.DrawingArea()
            dot.set_content_width(10)
            dot.set_content_height(10)
            r, g, b = _MARKER_COLORS[ct]
            dot.set_draw_func(
                lambda area, cr, w, h, _r=r, _g=g, _b=b: (
                    cr.set_source_rgb(_r, _g, _b),
                    cr.arc(w / 2, h / 2, min(w, h) / 2, 0, 6.2832),
                    cr.fill(),
                )
            )
            item.append(dot)
            item.append(Gtk.Label(label=label))
            legend.append(item)
        self.append(legend)

    def update_contacts(self, contacts: list[Contact]):
        """Refresh markers from the current contact list."""
        self._all_contacts = [c for c in contacts if c.has_location]

        # Check self location
        device_info = self._window.device_info
        self._self_lat = getattr(device_info, "latitude", None)
        self._self_lon = getattr(device_info, "longitude", None)
        if self._self_lat and self._self_lon:
            if abs(self._self_lat) < 1e-6 and abs(self._self_lon) < 1e-6:
                self._self_lat = None
                self._self_lon = None

        self._apply_filters(fit_bounds=True)

    def update_discovered(self, contacts: list[Contact]):
        """Set the list of discovered contacts available for map display."""
        for c in contacts:
            c.is_discovered = True
        self._discovered_contacts = [c for c in contacts if c.has_location]
        if self._show_discovered:
            self._apply_filters()

    def _apply_filters(self, fit_bounds=False):
        """Filter contacts by visible types and refresh the map."""
        self._located_contacts = [c for c in self._all_contacts if c.type in self._visible_types]
        if self._show_discovered:
            contact_keys = {c.public_key_hex for c in self._located_contacts}
            self._located_contacts += [
                c
                for c in self._discovered_contacts
                if c.type in self._visible_types and c.public_key_hex not in contact_keys
            ]
        self._expanded_clusters.clear()
        self.clear_trace_path()

        if fit_bounds and (self._located_contacts or (self._self_lat and self._self_lon)):
            self._fit_bounds()

        self._last_zoom = -1  # force recluster
        self._recluster()
        self._update_status_label()

    def _update_status_label(self):
        """Update the status label with contact counts."""
        if hasattr(self, "_status_label"):
            visible = len(self._located_contacts)
            total = len(self._all_contacts)
            all_contacts = len(self._window.contacts) if self._window.contacts else 0
            if self._show_discovered:
                disc_count = len(
                    [c for c in self._discovered_contacts if c.type in self._visible_types]
                )
                visible_contacts = visible - disc_count
            else:
                visible_contacts = visible
            if visible_contacts == total:
                text = _("{} of {} contacts on map").format(visible_contacts, all_contacts)
            else:
                text = _("{} of {} located on map").format(visible_contacts, total)
            if self._show_discovered:
                text += f"\n+ {disc_count} discovered"
            self._status_label.set_label(text)

    def build_sidebar_filters(self) -> Gtk.Widget:
        """Build the sidebar filter controls and contact count."""
        box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=8,
            margin_start=12,
            margin_end=12,
            margin_top=8,
        )

        # Filter group
        filter_group = Adw.PreferencesGroup()

        filter_items = [
            (ContactType.CHAT, _("Users"), "avatar-default-symbolic"),
            (ContactType.REPEATER, _("Repeaters"), "network-server-symbolic"),
            (ContactType.ROOM, _("Rooms"), "user-home-symbolic"),
            (ContactType.SENSOR, _("Sensors"), "weather-clear-symbolic"),
        ]

        for ct, label, icon_name in filter_items:
            row = Adw.SwitchRow(title=label)
            row.add_prefix(Gtk.Image.new_from_icon_name(icon_name))
            row.set_active(ct in self._visible_types)

            def _on_toggled(switch_row, pspec, contact_type=ct):
                if switch_row.get_active():
                    self._visible_types.add(contact_type)
                else:
                    self._visible_types.discard(contact_type)
                self._apply_filters()

            row.connect("notify::active", _on_toggled)
            filter_group.add(row)

        box.append(filter_group)

        # Status label at bottom (same style as contacts view)
        self._status_label = Gtk.Label(label="")
        self._status_label.add_css_class("dim-label")
        self._status_label.add_css_class("caption")
        self._status_label.set_margin_top(4)
        self._status_label.set_margin_bottom(2)
        # Discovered nodes toggle
        disc_group = Adw.PreferencesGroup()
        disc_row = Adw.SwitchRow(title=_("Discovered Nodes"))
        disc_row.add_prefix(Gtk.Image.new_from_icon_name("edit-find-symbolic"))
        disc_row.set_active(self._show_discovered)

        def _on_disc_toggled(switch_row, pspec):
            self._show_discovered = switch_row.get_active()
            self._apply_filters()

        disc_row.connect("notify::active", _on_disc_toggled)
        disc_group.add(disc_row)
        box.append(disc_group)

        self._update_status_label()
        box.append(self._status_label)

        return box

    def _on_zoom_changed(self, viewport, pspec):
        """Debounce zoom changes to avoid reclustering on every frame."""
        self._expanded_clusters.clear()
        if self._recluster_timeout_id:
            GLib.source_remove(self._recluster_timeout_id)
        self._recluster_timeout_id = GLib.timeout_add(150, self._recluster)

    def _recluster(self):
        """Recompute clusters at the current zoom level and rebuild markers."""
        self._recluster_timeout_id = None
        viewport = self._map.get_viewport()
        zoom = viewport.get_zoom_level()

        _LABEL_ZOOM = 10
        labels_changed = (self._last_zoom < _LABEL_ZOOM) != (zoom < _LABEL_ZOOM)
        if abs(zoom - self._last_zoom) < 0.3 and not self._expanded_clusters and not labels_changed:
            return False
        self._last_zoom = zoom

        self._marker_layer.remove_all()

        # Contacts that belong to expanded clusters get individual markers
        expanded_keys = set()
        for group in self._expanded_clusters:
            for c in group:
                expanded_keys.add(c.public_key_hex)

        # Cluster only the non-expanded contacts
        to_cluster = [c for c in self._located_contacts if c.public_key_hex not in expanded_keys]
        clusters = self._compute_clusters(to_cluster, zoom)

        for cluster in clusters:
            if len(cluster) == 1:
                marker = self._create_contact_marker(cluster[0])
            else:
                marker = self._create_cluster_marker(cluster, self._zoom_or_expand_cluster)
            self._marker_layer.add_marker(marker)

        # Add expanded contacts as individual markers spread in a circle
        for group in self._expanded_clusters:
            avg_lat = sum(c.latitude for c in group) / len(group)
            sum(c.longitude for c in group) / len(group)
            for i, contact in enumerate(group):
                angle = 2 * math.pi * i / len(group)
                # Offset in degrees (~30m radius at equator, scales with zoom)
                offset = 0.0003 * (2 ** (15 - zoom))
                offset = max(offset, 0.00005)  # minimum offset
                dlat = offset * math.cos(angle)
                dlon = offset * math.sin(angle) / max(math.cos(math.radians(avg_lat)), 0.01)
                marker = self._create_contact_marker(contact)
                marker.set_location(contact.latitude + dlat, contact.longitude + dlon)
                self._marker_layer.add_marker(marker)

        # Add self marker (MeshCore device GPS)
        if self._self_lat and self._self_lon:
            self._marker_layer.add_marker(self._create_self_marker(self._self_lat, self._self_lon))

        return False  # don't repeat timeout

    @staticmethod
    def _compute_clusters(contacts: list[Contact], zoom: float) -> list[list[Contact]]:
        """Group contacts that are too close at the current zoom into clusters."""
        if not contacts:
            return []

        # Convert all contacts to pixel positions
        points = []
        for c in contacts:
            px, py = _lat_lon_to_pixels(c.latitude, c.longitude, zoom)
            points.append((px, py, c))

        clustered = [False] * len(points)
        clusters: list[list[Contact]] = []
        radius_sq = _CLUSTER_RADIUS_PX**2

        for i, (px, py, contact) in enumerate(points):
            if clustered[i]:
                continue
            group = [contact]
            clustered[i] = True

            # Find all unclustered points within radius
            for j in range(i + 1, len(points)):
                if clustered[j]:
                    continue
                dx = points[j][0] - px
                dy = points[j][1] - py
                if dx * dx + dy * dy < radius_sq:
                    group.append(points[j][2])
                    clustered[j] = True

            clusters.append(group)

        return clusters

    def _create_contact_marker(self, contact: Contact) -> Shumate.Marker:
        """Create a colored circle marker with icon for a contact."""
        discovered = getattr(contact, "is_discovered", False)
        if discovered:
            r, g, b = _UNKNOWN_COLOR
        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")
        zoom = self._map.get_viewport().get_zoom_level()

        widget = self._build_marker_widget(
            r, g, b, icon_name, contact.name, boxed_label=True, show_label=zoom >= 10
        )

        widget.set_tooltip_text(
            f"{contact.name}\n{contact.type_label}\n{contact.latitude:.5f}, {contact.longitude:.5f}"
        )

        click = Gtk.GestureClick()
        if discovered:
            click.connect(
                "pressed",
                lambda g, n, x, y, w=widget, c=contact: MapView.show_marker_popover(
                    w, c.name, c.latitude, c.longitude, contact_type=c.type_label
                ),
            )
        else:
            click.connect("pressed", lambda g, n, x, y, c=contact: self._on_marker_clicked(c))
        widget.add_controller(click)

        marker = Shumate.Marker()
        marker.set_child(widget)
        marker.set_location(contact.latitude, contact.longitude)
        return marker

    @staticmethod
    def _create_cluster_marker(contacts: list[Contact], on_cluster_click=None) -> Shumate.Marker:
        """Create a cluster marker showing the count of grouped contacts."""
        avg_lat = sum(c.latitude for c in contacts) / len(contacts)
        avg_lon = sum(c.longitude for c in contacts) / len(contacts)

        count = len(contacts)
        size = min(48, 32 + count)

        r, g, b = _CLUSTER_COLOR

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0, halign=Gtk.Align.CENTER)

        circle = Gtk.DrawingArea()
        circle.set_content_width(size)
        circle.set_content_height(size)
        radius = size / 2 - 1

        def draw_cluster(area, cr, w, h):
            cx, cy = w / 2, h / 2
            cr.set_source_rgba(r, g, b, 0.3)
            cr.arc(cx, cy, radius, 0, 6.2832)
            cr.fill()
            cr.set_source_rgb(r, g, b)
            cr.arc(cx, cy, radius * 0.75, 0, 6.2832)
            cr.fill()
            cr.set_source_rgb(1, 1, 1)
            cr.set_line_width(2)
            cr.arc(cx, cy, radius * 0.75, 0, 6.2832)
            cr.stroke()

        circle.set_draw_func(draw_cluster)

        overlay = Gtk.Overlay()
        overlay.set_child(circle)

        count_label = Gtk.Label(label=str(count))
        count_label.add_css_class("map-marker-icon")
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
        count_label.set_attributes(attrs)
        count_label.set_halign(Gtk.Align.CENTER)
        count_label.set_valign(Gtk.Align.CENTER)
        overlay.add_overlay(count_label)

        box.append(overlay)

        names = sorted(c.name for c in contacts)
        if len(names) > 8:
            tooltip = "\n".join(names[:8]) + "\n\u2026 " + _("and {} more").format(len(names) - 8)
        else:
            tooltip = "\n".join(names)
        box.set_tooltip_text(tooltip)

        if on_cluster_click:
            click = Gtk.GestureClick()
            click.connect(
                "pressed",
                lambda g, n, x, y, cs=contacts, lat=avg_lat, lon=avg_lon: on_cluster_click(
                    cs, lat, lon
                ),
            )
            box.add_controller(click)

        marker = Shumate.Marker()
        marker.set_child(box)
        marker.set_location(avg_lat, avg_lon)
        return marker

    def _zoom_or_expand_cluster(self, contacts, lat, lon):
        """Zoom in to split a cluster, or expand it if zooming won't help."""
        viewport = self._map.get_viewport()
        current_zoom = viewport.get_zoom_level()
        max_zoom = viewport.get_max_zoom_level()

        # Check if zooming in by 2 would split this cluster
        test_zoom = min(current_zoom + 2, max_zoom)
        test_clusters = self._compute_clusters(contacts, test_zoom)
        would_split = len(test_clusters) > 1

        if would_split and current_zoom < max_zoom:
            # Zooming will help — zoom in
            viewport.set_zoom_level(test_zoom)
            viewport.set_location(lat, lon)
        else:
            # Zooming won't help — expand the cluster into individual markers
            self._expanded_clusters.append(contacts)
            self._last_zoom = -1  # force rebuild
            self._recluster()

    def _create_self_marker(self, lat: float, lon: float) -> Shumate.Marker:
        """Create the self/device location marker."""
        r, g, b = _SELF_COLOR
        name = self._window.device_info.name or _("My Device")
        zoom = self._map.get_viewport().get_zoom_level()
        widget = self._build_marker_widget(
            r, g, b, "avatar-default-symbolic", name, boxed_label=True, show_label=zoom >= 10
        )
        widget.set_tooltip_text(f"{name}\n{lat:.5f}, {lon:.5f}")

        marker = Shumate.Marker()
        marker.set_child(widget)
        marker.set_location(lat, lon)
        return marker

    @staticmethod
    def _build_marker_widget(
        r, g, b, icon_name, label_text, badge_text="", boxed_label=False, show_label=True
    ):
        """Build a marker widget: colored circle with icon + name label below."""
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1, halign=Gtk.Align.CENTER)

        circle = Gtk.DrawingArea()
        circle.set_content_width(32)
        circle.set_content_height(32)

        def draw_circle(area, cr, w, h):
            cr.set_source_rgb(r, g, b)
            cr.arc(w / 2, h / 2, 14, 0, 6.2832)
            cr.fill()
            cr.set_source_rgb(1, 1, 1)
            cr.set_line_width(2)
            cr.arc(w / 2, h / 2, 14, 0, 6.2832)
            cr.stroke()

        circle.set_draw_func(draw_circle)

        overlay = Gtk.Overlay()
        overlay.set_child(circle)

        icon = Gtk.Image.new_from_icon_name(icon_name)
        icon.set_pixel_size(16)
        icon.add_css_class("map-marker-icon")
        icon.set_halign(Gtk.Align.CENTER)
        icon.set_valign(Gtk.Align.CENTER)
        overlay.add_overlay(icon)

        if badge_text:
            badge = Gtk.Label(label=badge_text)
            badge.add_css_class("map-marker-badge")
            badge.set_halign(Gtk.Align.END)
            badge.set_valign(Gtk.Align.START)
            overlay.add_overlay(badge)

        box.append(overlay)

        if label_text and show_label:
            if boxed_label:
                name_widget = MapView.build_map_label_widget(label_text)
                name_widget.get_first_child().set_max_width_chars(12)
                name_widget.get_first_child().set_ellipsize(3)
            else:
                name_widget = Gtk.Label(label=label_text)
                name_widget.add_css_class("caption")
                name_widget.set_max_width_chars(12)
                name_widget.set_ellipsize(3)  # PANGO_ELLIPSIZE_END
            box.append(name_widget)

        return box

    @staticmethod
    def create_marker(
        r, g, b, icon_name, label_text, lat, lon, boxed_label=True, show_label=True, badge_text=""
    ):
        """Create a positioned Shumate.Marker with standard styling."""
        widget = MapView._build_marker_widget(
            r,
            g,
            b,
            icon_name,
            label_text,
            badge_text=badge_text,
            boxed_label=boxed_label,
            show_label=show_label,
        )
        marker = Shumate.Marker()
        marker.set_child(widget)
        marker.set_location(lat, lon)
        return marker

    @staticmethod
    def create_dashed_path_layer(viewport, color_rgb=None, stroke_width=2):
        """Create a consistently styled dashed PathLayer for map overlays."""
        layer = Shumate.PathLayer.new(viewport)
        layer.set_stroke_width(stroke_width)
        layer.set_dash([6, 4])
        color = Gdk.RGBA()
        if color_rgb:
            r, g, b = color_rgb
            color.parse(f"rgba({int(r*255)}, {int(g*255)}, {int(b*255)}, 0.9)")
        else:
            color.parse("rgba(100, 100, 100, 0.9)")
        layer.set_stroke_color(color)
        return layer

    @staticmethod
    def build_map_label_widget(text):
        """Build a label with opaque background for map overlays."""
        label = Gtk.Label(label=text)
        label.add_css_class("caption")
        label.set_margin_start(6)
        label.set_margin_end(6)
        label.set_margin_top(2)
        label.set_margin_bottom(2)
        box = Gtk.Box()
        box.append(label)
        box.add_css_class("map-overlay-box")
        return box

    @staticmethod
    def build_snr_widget(snr):
        """Build a styled SNR label with opaque background for map overlays."""
        box = MapView.build_map_label_widget(f"{snr:+.1f} dB")
        label = box.get_first_child()
        if snr >= 0:
            label.add_css_class("success")
        elif snr >= -5:
            label.add_css_class("dim-label")
        else:
            label.add_css_class("error")
        return box

    @staticmethod
    def show_marker_popover(
        widget, name, lat, lon, snr=None, contact_type=None, interpolated=False, on_detail=None
    ):
        """Show a detail popover anchored to a map marker widget."""
        popover = Gtk.Popover()
        box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=4,
            margin_start=8,
            margin_end=8,
            margin_top=8,
            margin_bottom=8,
        )
        if on_detail is not None:
            name_btn = Gtk.Button(label=name)
            name_btn.add_css_class("flat")
            name_btn.add_css_class("heading")
            name_btn.set_halign(Gtk.Align.START)
            name_btn.connect("clicked", lambda *_: (popover.popdown(), on_detail()))
            box.append(name_btn)
        else:
            name_label = Gtk.Label(label=name, xalign=0)
            name_label.add_css_class("heading")
            box.append(name_label)
        if contact_type is not None:
            type_label = Gtk.Label(label=contact_type, xalign=0)
            type_label.add_css_class("dim-label")
            box.append(type_label)
        if interpolated:
            note = Gtk.Label(label=_("Position is illustrative — real location unknown"), xalign=0)
            note.add_css_class("dim-label")
            box.append(note)
        else:
            coords = Gtk.Label(label=f"{lat:.5f}, {lon:.5f}", xalign=0)
            coords.add_css_class("dim-label")
            box.append(coords)
        if snr is not None:
            snr_text = Gtk.Label(label=_("SNR: {:.1f} dB").format(snr), xalign=0)
            box.append(snr_text)
        popover.set_child(box)
        popover.set_parent(widget)
        popover.popup()

    def _on_marker_clicked(self, contact: Contact):
        """Navigate to the contact's detail view."""
        self._window.show_contact_detail(contact)

    @staticmethod
    def interpolate_unknown_hops(hops):
        """Fill in coordinates for hops without a known location.

        Input/output: list of (lat_or_none, lon_or_none, contact_or_none,
                               prefix_or_none, snr_or_none)
        Unknown hops between two located hops are linearly interpolated.
        Edge unknowns get a small offset from the nearest known hop.
        """
        n = len(hops)
        if n == 0:
            return hops

        result = list(hops)

        # Find runs of unknown-location hops and interpolate
        i = 0
        while i < n:
            if result[i][0] is not None:
                i += 1
                continue
            # Found an unknown hop — find the run
            run_start = i
            while i < n and result[i][0] is None:
                i += 1
            run_end = i  # exclusive

            # Find the nearest known hops before and after
            prev_lat, prev_lon = None, None
            for j in range(run_start - 1, -1, -1):
                if result[j][0] is not None:
                    prev_lat, prev_lon = result[j][0], result[j][1]
                    break
            next_lat, next_lon = None, None
            for j in range(run_end, n):
                if result[j][0] is not None:
                    next_lat, next_lon = result[j][0], result[j][1]
                    break

            run_len = run_end - run_start
            if prev_lat is not None and next_lat is not None:
                # Interpolate between two known points
                for k, idx in enumerate(range(run_start, run_end)):
                    frac = (k + 1) / (run_len + 1)
                    lat = prev_lat + frac * (next_lat - prev_lat)
                    lon = prev_lon + frac * (next_lon - prev_lon)
                    result[idx] = (lat, lon) + result[idx][2:]
            elif prev_lat is not None:
                # Edge unknowns at the end — offset from previous
                for k, idx in enumerate(range(run_start, run_end)):
                    lat = prev_lat + (k + 1) * 0.001
                    lon = prev_lon + (k + 1) * 0.001
                    result[idx] = (lat, lon) + result[idx][2:]
            elif next_lat is not None:
                # Edge unknowns at the start — offset from next
                for k, idx in enumerate(range(run_start, run_end)):
                    lat = next_lat - (run_len - k) * 0.001
                    lon = next_lon - (run_len - k) * 0.001
                    result[idx] = (lat, lon) + result[idx][2:]

        return result

    def show_trace_path(self, hop_contacts, snr_values, hop_prefixes=None):
        """Draw a trace path on the map from trace results (with SNR)."""
        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)

        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:
                hops.append((contact.latitude, contact.longitude, contact, prefix, snr))
            else:
                hops.append((None, None, contact, prefix, snr))

        if has_self and not companion_too_far(self_lat, self_lon, hops):
            hops.insert(0, (self_lat, self_lon, None, None, None))

        hops = self.interpolate_unknown_hops(hops)
        self._draw_path_on_map(hops)

    def _draw_path_on_map(self, hops):
        """Draw a path overlay: polyline + circle markers + SNR at midpoints.

        hops: list of (lat, lon, contact_or_none, prefix_or_none, snr_or_none)
              contact=None + prefix=None means self/device node.
              contact=None + prefix!=None means unknown/interpolated hop.
        """
        self.clear_trace_path()

        if not hops:
            return

        viewport = self._map.get_viewport()

        # Create path layer for the line
        self._trace_path_layer = MapView.create_dashed_path_layer(viewport)
        self._map.get_map().add_layer(self._trace_path_layer)

        # Create marker layer
        self._trace_marker_layer = Shumate.MarkerLayer.new(viewport)
        self._map.get_map().add_layer(self._trace_marker_layer)

        # Add path nodes and markers
        lats, lons = [], []
        hop_num = 0
        for lat, lon, contact, prefix, snr in hops:
            node = Shumate.Marker()
            node.set_location(lat, lon)
            self._trace_path_layer.add_node(node)

            if contact is None and prefix is None:
                # Self/device marker
                r, g, b = _SELF_COLOR
                icon_name = "network-wireless-symbolic"
                name = self._window.device_info.name or _("My Device")
                badge = ""
            elif contact is None:
                # Unknown/interpolated hop
                r, g, b = _UNKNOWN_COLOR
                icon_name = "network-server-symbolic"
                name = prefix
                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
                hop_num += 1
                badge = str(hop_num)

            widget = self._build_marker_widget(r, g, b, icon_name, "", badge_text=badge)
            widget.set_tooltip_text(name)

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

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

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

        # Add SNR labels at midpoints between consecutive hops
        for i in range(1, len(hops)):
            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_label = Gtk.Label(label=f"{snr:+.1f} dB")
            snr_label.add_css_class("caption")
            if snr >= 0:
                snr_label.add_css_class("success")
            elif snr >= -5:
                snr_label.add_css_class("dim-label")
            else:
                snr_label.add_css_class("error")
            snr_marker = Shumate.Marker()
            snr_marker.set_child(snr_label)
            snr_marker.set_location(mid_lat, mid_lon)
            self._trace_marker_layer.add_marker(snr_marker)

        # Fit map to path bounds
        MapView.fit_viewport(viewport, lats, lons)

    def clear_trace_path(self):
        """Remove any trace path overlay from the map."""
        if self._trace_anim_id:
            GLib.source_remove(self._trace_anim_id)
            self._trace_anim_id = None
        if self._trace_path_layer:
            self._map.get_map().remove_layer(self._trace_path_layer)
            self._trace_path_layer = None
        if self._trace_marker_layer:
            self._map.get_map().remove_layer(self._trace_marker_layer)
            self._trace_marker_layer = None

    @staticmethod
    def fit_viewport(viewport, lats, lons):
        """Zoom and center a viewport to fit the given coordinates."""
        if not lats:
            return
        if len(lats) == 1:
            viewport.set_zoom_level(14)
            viewport.set_location(lats[0], lons[0])
            return
        min_lat, max_lat = min(lats), max(lats)
        min_lon, max_lon = min(lons), max(lons)
        span = max(max_lat - min_lat, max_lon - min_lon)
        if span < 0.005:
            zoom = 14
        elif span < 0.05:
            zoom = 13
        elif span < 0.1:
            zoom = 12
        elif span < 0.5:
            zoom = 10
        elif span < 1.0:
            zoom = 9
        else:
            zoom = 7
        viewport.set_zoom_level(zoom)
        viewport.set_location((min_lat + max_lat) / 2, (min_lon + max_lon) / 2)

    @staticmethod
    def open_map_dialog(window, title=None):
        """Create and return (dialog, map_widget, marker_layer, viewport).

        The caller can add markers, path layers, etc. and then call
        dialog.present(window) to show it.
        """
        dialog = Adw.Dialog()
        dialog.set_title(title or _("Map"))
        # Size to nearly fill the parent window
        win_width = window.get_width() or 800
        win_height = window.get_height() or 600
        dialog.set_content_width(max(500, win_width - 60))
        dialog.set_content_height(max(400, win_height - 60))

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

        from meshy.views import create_shumate_map, deregister_map_widget

        map_widget, marker_layer, viewport = create_shumate_map()
        map_widget.set_hexpand(True)

        toolbar_view.set_content(map_widget)
        dialog.set_child(toolbar_view)

        dialog.connect("closed", lambda _d: deregister_map_widget(map_widget))

        return dialog, map_widget, marker_layer, viewport

    @staticmethod
    def _filter_outliers(lats, lons):
        """Remove geographic outliers using median + MAD (robust to skew)."""
        if len(lats) < 3:
            return lats, lons
        from math import sqrt
        from statistics import median

        med_lat, med_lon = median(lats), median(lons)
        dists = [
            sqrt((la - med_lat) ** 2 + (lo - med_lon) ** 2)
            for la, lo in zip(lats, lons, strict=False)
        ]
        med_dist = median(dists) or 0.01
        filtered = [
            (la, lo) for la, lo, d in zip(lats, lons, dists, strict=False) if d <= 3 * med_dist
        ]
        if not filtered:
            return lats, lons
        return [la for la, _ in filtered], [lo for _, lo in filtered]

    def _fit_bounds(self):
        """Zoom and center the map to show all located contacts."""
        lats = [c.latitude for c in self._located_contacts]
        lons = [c.longitude for c in self._located_contacts]
        if self._self_lat and self._self_lon:
            lats.append(self._self_lat)
            lons.append(self._self_lon)

        if not lats:
            return

        viewport = self._map.get_viewport()

        if len(lats) == 1:
            viewport.set_zoom_level(14)
            viewport.set_location(lats[0], lons[0])
            return

        lats, lons = self._filter_outliers(lats, lons)

        min_lat, max_lat = min(lats), max(lats)
        min_lon, max_lon = min(lons), max(lons)
        center_lat = (min_lat + max_lat) / 2
        center_lon = (min_lon + max_lon) / 2

        lat_span = max_lat - min_lat
        lon_span = max_lon - min_lon
        span = max(lat_span, lon_span)

        if span < 0.01:
            zoom = 14
        elif span < 0.05:
            zoom = 13
        elif span < 0.1:
            zoom = 12
        elif span < 0.5:
            zoom = 10
        elif span < 1.0:
            zoom = 9
        elif span < 5.0:
            zoom = 7
        elif span < 20.0:
            zoom = 5
        else:
            zoom = 3

        viewport.set_zoom_level(zoom)
        viewport.set_location(center_lat, center_lon)


def open_trace_picker_map(window, contacts, hash_width, on_done):
    """Open a map dialog for selecting trace route waypoints by clicking.

    on_done(hex_string) is called with comma-separated hop hashes when the
    user confirms the selection.
    """
    located = [
        c for c in contacts if c.type in (ContactType.REPEATER, ContactType.ROOM) and c.has_location
    ]
    if not located:
        return

    selected = []  # list of Contact objects (ordered, may repeat)

    dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(
        window, title=_("Pick Trace Route")
    )
    toolbar_view = dialog.get_child()

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

    badge_layer = Shumate.MarkerLayer.new(viewport)
    map_widget.get_map().add_layer(badge_layer)

    # Track badge labels per contact for multi-selection display
    badge_markers = {}  # pub_key_hex -> (Shumate.Marker, Gtk.Label)

    def _update_path_and_badges():
        # Update polyline (companion → waypoints → companion)
        path_layer.remove_all()
        if selected and self_node:
            path_layer.add_node(self_node)
        for c in selected:
            pt = Shumate.Coordinate()
            pt.set_location(c.latitude, c.longitude)
            path_layer.add_node(pt)
        if selected and self_node:
            end = Shumate.Coordinate()
            end.set_location(self_lat, self_lon)
            path_layer.add_node(end)

        # Rebuild badge markers
        for m, _lbl in badge_markers.values():
            badge_layer.remove_marker(m)
        badge_markers.clear()

        # Collect indices per contact
        indices = {}
        for i, c in enumerate(selected):
            indices.setdefault(c.public_key_hex, []).append(i + 1)

        for key_hex, nums in indices.items():
            contact = next(c for c in selected if c.public_key_hex == key_hex)
            text = ",".join(str(n) for n in nums)

            badge_widget = MapView.build_map_label_widget(text)

            marker = Shumate.Marker()
            marker.set_child(badge_widget)
            marker.set_location(contact.latitude, contact.longitude)
            badge_layer.add_marker(marker)
            badge_markers[key_hex] = (marker, badge_widget)

        # Update route label
        if selected:
            names = [c.name for c in selected]
            route_label.set_label(" → ".join(names))
            route_label.set_visible(True)
            done_btn.set_sensitive(True)
        else:
            route_label.set_label("")
            route_label.set_visible(False)
            done_btn.set_sensitive(False)

    def _on_contact_clicked(contact):
        selected.append(contact)
        _update_path_and_badges()

    # Companion (self) marker
    device = window._device_info if hasattr(window, "_device_info") else None
    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)
    self_node = None
    if has_self:
        self_node = Shumate.Coordinate()
        self_node.set_location(self_lat, self_lon)

    # Clustering state
    expanded_clusters = []
    last_zoom = [-1]
    recluster_timeout = [None]

    def _create_picker_contact_marker(contact, zoom):
        r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5))
        icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic")
        widget = MapView._build_marker_widget(
            r, g, b, icon_name, contact.name, boxed_label=True, show_label=zoom >= 10
        )
        widget.set_tooltip_text(contact.name)
        widget.set_cursor(Gdk.Cursor.new_from_name("pointer"))
        click = Gtk.GestureClick()
        click.connect("pressed", lambda g, n, x, y, c=contact: _on_contact_clicked(c))
        widget.add_controller(click)
        marker = Shumate.Marker()
        marker.set_child(widget)
        marker.set_location(contact.latitude, contact.longitude)
        return marker

    def _zoom_or_expand(contacts, lat, lon):
        current_zoom = viewport.get_zoom_level()
        test_zoom = min(current_zoom + 2, 20)
        test_clusters = MapView._compute_clusters(contacts, test_zoom)
        if len(test_clusters) > 1 and current_zoom < 18:
            viewport.set_zoom_level(test_zoom)
            viewport.set_location(lat, lon)
        else:
            expanded_clusters.append(contacts)
            last_zoom[0] = -1
            _rebuild_markers()

    def _rebuild_markers():
        zoom = viewport.get_zoom_level()
        _LABEL_ZOOM = 10
        labels_changed = (last_zoom[0] < _LABEL_ZOOM) != (zoom < _LABEL_ZOOM)
        if abs(zoom - last_zoom[0]) < 0.3 and not expanded_clusters and not labels_changed:
            return False
        last_zoom[0] = zoom

        marker_layer.remove_all()

        expanded_keys = set()
        for group in expanded_clusters:
            for c in group:
                expanded_keys.add(c.public_key_hex)

        to_cluster = [c for c in located if c.public_key_hex not in expanded_keys]
        clusters = MapView._compute_clusters(to_cluster, zoom)

        for cluster in clusters:
            if len(cluster) == 1:
                marker = _create_picker_contact_marker(cluster[0], zoom)
            else:
                marker = MapView._create_cluster_marker(cluster, _zoom_or_expand)
            marker_layer.add_marker(marker)

        for group in expanded_clusters:
            avg_lat = sum(c.latitude for c in group) / len(group)
            sum(c.longitude for c in group) / len(group)
            for i, contact in enumerate(group):
                angle = 2 * math.pi * i / len(group)
                offset = 0.0003 * (2 ** (15 - zoom))
                offset = max(offset, 0.00005)
                dlat = offset * math.cos(angle)
                dlon = offset * math.sin(angle) / max(math.cos(math.radians(avg_lat)), 0.01)
                marker = _create_picker_contact_marker(contact, zoom)
                marker.set_location(contact.latitude + dlat, contact.longitude + dlon)
                marker_layer.add_marker(marker)

        if has_self:
            self_name = getattr(device, "name", "") or _("My Device")
            r, g, b = _SELF_COLOR
            widget = MapView._build_marker_widget(
                r,
                g,
                b,
                "avatar-default-symbolic",
                self_name,
                boxed_label=True,
                show_label=zoom >= 10,
            )
            widget.set_tooltip_text(self_name)
            sm = Shumate.Marker()
            sm.set_child(widget)
            sm.set_location(self_lat, self_lon)
            marker_layer.add_marker(sm)

        return False

    def _on_zoom_changed(vp, pspec):
        expanded_clusters.clear()
        if recluster_timeout[0]:
            GLib.source_remove(recluster_timeout[0])
        recluster_timeout[0] = GLib.timeout_add(150, _rebuild_markers)

    viewport.connect("notify::zoom-level", _on_zoom_changed)
    _rebuild_markers()

    # Bottom bar with route info and controls
    bottom_bar = Gtk.Box(spacing=8, margin_start=8, margin_end=8, margin_top=6, margin_bottom=6)

    route_label = Gtk.Label(
        label="", xalign=0, hexpand=True, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR
    )
    route_label.add_css_class("caption")
    route_label.set_visible(False)
    bottom_bar.append(route_label)

    undo_btn = Gtk.Button(icon_name="edit-undo-symbolic", tooltip_text=_("Undo"))
    undo_btn.add_css_class("flat")

    def _on_undo(*_):
        if selected:
            selected.pop()
            _update_path_and_badges()

    undo_btn.connect("clicked", _on_undo)
    bottom_bar.append(undo_btn)

    clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic", tooltip_text=_("Clear"))
    clear_btn.add_css_class("flat")

    def _on_clear(*_):
        selected.clear()
        _update_path_and_badges()

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

    done_btn = Gtk.Button(label=_("Done"))
    done_btn.add_css_class("suggested-action")
    done_btn.set_sensitive(False)

    def _on_done(*_):
        hex_parts = [c.public_key[:hash_width].hex() for c in selected]
        on_done(",".join(hex_parts))
        dialog.close()

    done_btn.connect("clicked", _on_done)
    bottom_bar.append(done_btn)

    toolbar_view.add_bottom_bar(bottom_bar)

    # Main content: map + instruction hint — unparent map from toolbar_view first
    toolbar_view.set_content(None)
    content_overlay = Gtk.Overlay()
    content_overlay.set_child(map_widget)

    hint_label = Gtk.Label(label=_("Tap repeaters to build the trace route"))
    hint_label.add_css_class("caption")
    hint_label.set_halign(Gtk.Align.CENTER)
    hint_label.set_valign(Gtk.Align.START)
    hint_label.set_margin_top(8)
    hint_label.add_css_class("map-overlay-label")
    content_overlay.add_overlay(hint_label)

    toolbar_view.set_content(content_overlay)

    # Fit map to show all repeaters + companion
    lats = [c.latitude for c in located]
    lons = [c.longitude for c in located]
    if has_self:
        lats.append(self_lat)
        lons.append(self_lon)
    MapView.fit_viewport(viewport, lats, lons)

    dialog.present(window)
