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

"""Channels list and chat view."""

import hashlib
from typing import Optional

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

from meshy.models import Channel, ChannelMessage, NotificationLevel
from meshy.protocol import max_channel_text_bytes
from meshy.utils import linkify, extract_emoji, parse_shared_contact, parse_shared_location
from meshy.views import (
    _add_hover_swap, _format_date_text, create_shumate_map, _ContactPickItem,
    _contact_pick_factory_setup, _contact_pick_factory_bind,
    _contact_pick_factory_unbind,
)

_BATCH_SIZE = 30


class ChannelChatItem(GObject.Object):
    """Model object for one row in the channel chat ListView."""
    KIND_MESSAGE = 0
    KIND_UNREAD_DIVIDER = 1
    KIND_DATE_DIVIDER = 2

    def __init__(self, kind=0, message=None, date_text=''):
        super().__init__()
        self.kind = kind
        self.message = message
        self.date_text = date_text
        self._repeat_count_val = 0
        self._resend_count_val = 0

    @GObject.Property(type=int)
    def repeat_count(self):
        return self._repeat_count_val

    @repeat_count.setter
    def repeat_count(self, value):
        self._repeat_count_val = value

    @GObject.Property(type=int)
    def resend_count(self):
        return self._resend_count_val

    @resend_count.setter
    def resend_count(self, value):
        self._resend_count_val = value


@Gtk.Template(resource_path='/page/codeberg/sesivany/Meshy/ui/channel-chat-widget.ui')
class ChannelChatWidget(Gtk.Box):
    """Channel chat content widget - shown in the content panel."""
    __gtype_name__ = 'MeshyChannelChatWidget'

    _stack = Gtk.Template.Child('stack')
    _chat_scroll = Gtk.Template.Child('chat_scroll')
    _list_view = Gtk.Template.Child('list_view')
    _scroll_down_revealer = Gtk.Template.Child('scroll_down_revealer')
    _unread_btn_revealer = Gtk.Template.Child('unread_btn_revealer')
    _char_label = Gtk.Template.Child('char_label')
    _emoji_btn = Gtk.Template.Child('emoji_btn')
    _text_entry = Gtk.Template.Child('text_entry')
    _send_btn = Gtk.Template.Child('send_btn')

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._current_channel: Optional[Channel] = None
        # Track outgoing items for repeat detection: (channel_idx, timestamp, text) -> item
        self._outgoing_items: dict[tuple, ChannelChatItem] = {}
        # Repeat info per outgoing message key: list of {path_hops, path_len}
        self._repeats: dict[tuple, list] = {}
        # Track incoming message path variants: msg_key -> list of path_info dicts
        self._incoming_paths: dict[tuple, list] = {}
        self._seen_channel_msgs: set[tuple] = set()
        self._incoming_items: dict[tuple, ChannelChatItem] = {}
        # Map resend timestamps to original outgoing message keys
        # so echoes of resent messages can be matched back
        self._resend_ts_map: dict[tuple, tuple] = {}
        self._mention_popover = None
        self._mention_listbox = None

        # Pango attributes for char counter
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_scale_new(0.85))
        self._char_label.set_attributes(attrs)

        # ListView model and factory
        self._store = Gio.ListStore(item_type=ChannelChatItem)
        selection = Gtk.NoSelection(model=self._store)
        self._factory = Gtk.SignalListItemFactory()
        self._factory.connect('setup', self._on_factory_setup)
        self._factory.connect('bind', self._on_factory_bind)
        self._factory.connect('unbind', self._on_factory_unbind)
        self._list_view.set_model(selection)
        self._list_view.set_factory(self._factory)

        # Context menus (single popover on the ListView, menu switches per item)
        self._context_popover = Gtk.PopoverMenu()
        self._context_popover.set_parent(self._list_view)
        self._context_popover.set_has_arrow(False)
        self._context_popover.connect('closed', self._on_context_popover_closed)
        self._context_menu_open = False
        self._frozen_scroll_value = 0.0
        self._context_item = None

        self._outgoing_menu = Gio.Menu()
        self._outgoing_menu.append(_('Heard Repeats'), 'msg.repeats')
        self._outgoing_menu.append(_('Send Again'), 'msg.resend')
        self._outgoing_menu.append(_('Delete'), 'msg.delete')

        self._incoming_menu = Gio.Menu()
        self._incoming_menu.append(_('Reply'), 'msg.reply')
        self._incoming_menu.append(_('Copy Text'), 'msg.copy')
        self._incoming_menu.append(_('View Message Paths'), 'msg.paths')
        self._incoming_menu.append(_('Delete'), 'msg.delete')

        ctx_group = Gio.SimpleActionGroup()
        for name, cb in [
            ('repeats', lambda a, p: self._show_repeats_context()),
            ('resend', lambda a, p: self._resend_context()),
            ('delete', lambda a, p: self._delete_context()),
            ('reply', lambda a, p: self._reply_context()),
            ('copy', lambda a, p: self._copy_context()),
            ('paths', lambda a, p: self._show_paths_context()),
        ]:
            action = Gio.SimpleAction.new(name, None)
            action.connect('activate', cb)
            ctx_group.add_action(action)
        self._list_view.insert_action_group('msg', ctx_group)

        # Gesture controllers
        right_click = Gtk.GestureClick(button=3)
        right_click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        right_click.connect('pressed', self._on_list_right_click)
        self._list_view.add_controller(right_click)
        long_press = Gtk.GestureLongPress()
        long_press.set_touch_only(True)
        long_press.set_exclusive(True)
        long_press.connect('pressed', self._on_list_long_press)
        self._list_view.add_controller(long_press)

        self._is_sticky = True
        self._scroll_positions: dict[int, float] = {}
        self._restoring_scroll = False
        self._unread_pos = -1
        self._messages_offset = 0
        self._all_loaded = False
        self._loading_more = False

        # Signal connections
        self._scroll_down_revealer.get_child().connect(
            'clicked', self._on_scroll_to_bottom_clicked)
        self._unread_btn_revealer.get_child().connect(
            'clicked', self._on_scroll_to_unread_clicked)
        self._emoji_btn.connect('clicked', self._on_emoji_clicked)
        self._text_entry.connect('activate', self._on_send)
        self._text_entry.connect('changed', self._on_text_changed)
        mention_key_ctrl = Gtk.EventControllerKey()
        mention_key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        mention_key_ctrl.connect('key-pressed', self._on_entry_key_pressed)
        self._text_entry.add_controller(mention_key_ctrl)
        self._send_btn.connect('clicked', self._on_send)

        # Extra context menu on text entry
        extra_menu = Gio.Menu()
        extra_menu.append(_('Share Contact'), 'entry.share-contact')
        extra_menu.append(_('Share Location'), 'entry.share-location')
        self._text_entry.set_extra_menu(extra_menu)
        entry_group = Gio.SimpleActionGroup()
        share_a = Gio.SimpleAction.new('share-contact', None)
        share_a.connect('activate', self._on_share_contact)
        entry_group.add_action(share_a)
        loc_a = Gio.SimpleAction.new('share-location', None)
        loc_a.connect('activate', self._on_share_location)
        entry_group.add_action(loc_a)
        self._text_entry.insert_action_group('entry', entry_group)

        # Scroll state tracking
        adj = self._chat_scroll.get_vadjustment()
        adj.connect('value-changed', self._on_scroll_value_changed)
        adj.connect('notify::upper', self._on_scroll_upper_changed)
        adj.connect('notify::page-size', self._on_scroll_upper_changed)
        self._chat_scroll.connect('edge-overshot', self._on_edge_overshot)

        self._stack.set_visible_child_name('placeholder')

    # ─── Factory callbacks ───────────────────────────────────

    def _on_factory_setup(self, factory, list_item):
        """Create the widget tree for one list item (recycled)."""
        list_item.set_activatable(False)
        list_item.set_selectable(False)
        list_item.set_focusable(False)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # ── Avatar ──
        avatar = Adw.Avatar(size=32, show_initials=True)
        avatar.set_valign(Gtk.Align.START)

        emoji_lbl = Gtk.Label()
        emoji_lbl.add_css_class('emoji-avatar')
        emoji_lbl.set_valign(Gtk.Align.START)

        avatar_stack = Gtk.Stack()
        avatar_stack.set_transition_type(Gtk.StackTransitionType.NONE)
        avatar_stack.add_named(avatar, 'initials')
        avatar_stack.add_named(emoji_lbl, 'emoji')
        avatar_stack.set_visible(False)

        # ── Message bubble ──
        msg_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=2,
            margin_end=12,
            margin_top=2, margin_bottom=2,
        )

        content_box = Gtk.Box(
            orientation=Gtk.Orientation.HORIZONTAL,
            spacing=8,
            margin_start=12,
        )
        content_box.append(avatar_stack)
        content_box.append(msg_box)

        # Sender name (incoming only)
        sender_lbl = Gtk.Label(xalign=0)
        sender_lbl.add_css_class('dim-label')
        sender_lbl.add_css_class('caption')
        sender_lbl.set_visible(False)
        msg_box.append(sender_lbl)

        # Card frame with text
        text_lbl = Gtk.Label(
            wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR,
            xalign=0, max_width_chars=40,
        )
        text_lbl.set_margin_start(12)
        text_lbl.set_margin_end(12)
        text_lbl.set_margin_top(8)
        text_lbl.set_margin_bottom(8)

        frame = Gtk.Frame()
        frame.add_css_class('card')

        content_stack = Gtk.Stack()
        content_stack.set_transition_type(Gtk.StackTransitionType.NONE)
        content_stack.set_vhomogeneous(False)
        content_stack.add_named(text_lbl, 'text')

        sc_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL, spacing=6,
            margin_start=12, margin_end=12, margin_top=8, margin_bottom=8,
        )
        sc_header_lbl = Gtk.Label(xalign=0, wrap=True)
        sc_header_lbl.add_css_class('dim-label')
        sc_header_lbl.add_css_class('caption')
        sc_box.append(sc_header_lbl)

        sc_name_box = Gtk.Box(spacing=6)
        sc_icon = Gtk.Image.new_from_icon_name('avatar-default-symbolic')
        sc_name_lbl = Gtk.Label(xalign=0)
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
        sc_name_lbl.set_attributes(attrs)
        sc_name_box.append(sc_icon)
        sc_name_box.append(sc_name_lbl)
        sc_box.append(sc_name_box)

        sc_key_lbl = Gtk.Label(xalign=0)
        sc_key_lbl.add_css_class('dim-label')
        sc_key_lbl.add_css_class('caption')
        sc_box.append(sc_key_lbl)

        sc_add_btn = Gtk.Button(label=_('Add Contact'))
        sc_add_btn.add_css_class('suggested-action')
        sc_add_btn.set_halign(Gtk.Align.START)
        sc_add_btn.set_margin_top(4)
        sc_box.append(sc_add_btn)

        content_stack.add_named(sc_box, 'contact')

        # ── Shared location card ──
        loc_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL, spacing=6,
            margin_start=12, margin_end=12, margin_top=8, margin_bottom=8,
        )
        loc_header_lbl = Gtk.Label(xalign=0, wrap=True)
        loc_header_lbl.add_css_class('dim-label')
        loc_header_lbl.add_css_class('caption')
        loc_box.append(loc_header_lbl)

        loc_coords_lbl = Gtk.Label(xalign=0, selectable=True)
        loc_coords_lbl.add_css_class('caption')
        loc_box.append(loc_coords_lbl)

        from meshy.views.map_view import MapView
        loc_map, loc_marker_layer, loc_viewport = create_shumate_map(zoom_level=14)
        loc_map.set_vexpand(False)
        loc_map.set_size_request(-1, 150)

        loc_marker_widget = MapView._build_marker_widget(
            0.26, 0.52, 0.96, 'find-location-symbolic', None,
            show_label=False)
        loc_marker = Shumate.Marker()
        loc_marker.set_child(loc_marker_widget)
        loc_marker_layer.add_marker(loc_marker)

        loc_map_frame = Gtk.Frame()
        loc_map_frame.add_css_class('card')
        loc_map_frame.set_child(loc_map)
        loc_box.append(loc_map_frame)

        loc_btn_box = Gtk.Box(spacing=6, margin_top=4)
        loc_show_btn = Gtk.Button(label=_('Show on Map'))
        loc_open_btn = Gtk.Button(label=_('Open in Maps App'))
        loc_btn_box.append(loc_show_btn)
        loc_btn_box.append(loc_open_btn)
        loc_box.append(loc_btn_box)

        content_stack.add_named(loc_box, 'location')

        frame.set_child(content_stack)
        msg_box.append(frame)

        # Meta line: time + resend + repeat
        meta_box = Gtk.Box(spacing=4)

        time_lbl = Gtk.Label()
        time_lbl.add_css_class('dim-label')
        time_lbl.add_css_class('caption')
        meta_box.append(time_lbl)

        resend_lbl = Gtk.Label(label='')
        resend_lbl.add_css_class('dim-label')
        resend_lbl.add_css_class('caption')
        resend_lbl.set_visible(False)
        meta_box.append(resend_lbl)

        repeat_lbl = Gtk.Label(label='')
        repeat_lbl.add_css_class('dim-label')
        repeat_lbl.add_css_class('caption')
        repeat_lbl.set_visible(False)
        meta_box.append(repeat_lbl)

        msg_box.append(meta_box)
        outer.append(content_box)

        # ── Divider ──
        div_box = Gtk.Box(
            spacing=8,
            margin_start=12, margin_end=12,
            margin_top=8, margin_bottom=8,
        )
        line_left = Gtk.Separator(hexpand=True, valign=Gtk.Align.CENTER)
        div_box.append(line_left)
        div_lbl = Gtk.Label()
        div_box.append(div_lbl)
        line_right = Gtk.Separator(hexpand=True, valign=Gtk.Align.CENTER)
        div_box.append(line_right)
        div_box.set_visible(False)
        outer.append(div_box)

        # Store widget references
        outer._content_box = content_box
        outer._avatar = avatar
        outer._emoji_lbl = emoji_lbl
        outer._avatar_stack = avatar_stack
        outer._msg_box = msg_box
        outer._sender_lbl = sender_lbl
        outer._text_lbl = text_lbl
        outer._frame = frame
        outer._time_lbl = time_lbl
        outer._resend_lbl = resend_lbl
        outer._repeat_lbl = repeat_lbl
        outer._meta_box = meta_box
        outer._div_box = div_box
        outer._div_lbl = div_lbl
        outer._div_line_left = line_left
        outer._div_line_right = line_right
        outer._content_stack = content_stack
        outer._sc_header_lbl = sc_header_lbl
        outer._sc_icon = sc_icon
        outer._sc_name_lbl = sc_name_lbl
        outer._sc_key_lbl = sc_key_lbl
        outer._sc_add_btn = sc_add_btn
        outer._sc_add_handler_id = None
        outer._loc_header_lbl = loc_header_lbl
        outer._loc_coords_lbl = loc_coords_lbl
        outer._loc_viewport = loc_viewport
        outer._loc_marker = loc_marker
        outer._loc_show_btn = loc_show_btn
        outer._loc_open_btn = loc_open_btn
        outer._loc_show_handler = None
        outer._loc_open_handler = None
        outer._chat_item = None
        outer._handler_ids = []

        # Wrap in per-item Clamp for width limiting
        clamp = Adw.Clamp(maximum_size=600, child=outer)
        clamp._outer = outer
        list_item.set_child(clamp)

    def _on_factory_bind(self, factory, list_item):
        """Populate widgets from the model item."""
        item = list_item.get_item()
        clamp = list_item.get_child()
        outer = clamp._outer
        outer._chat_item = item

        if item.kind == ChannelChatItem.KIND_MESSAGE:
            msg = item.message
            outer._msg_box.set_visible(True)
            outer._div_box.set_visible(False)

            # Alignment
            if msg.is_outgoing:
                outer._content_box.set_halign(Gtk.Align.END)
                outer._meta_box.set_halign(Gtk.Align.END)
                outer._avatar_stack.set_visible(False)
            else:
                outer._content_box.set_halign(Gtk.Align.START)
                outer._meta_box.set_halign(Gtk.Align.START)
                # Avatar
                emoji = extract_emoji(msg.sender_name) if msg.sender_name else None
                if emoji:
                    outer._emoji_lbl.set_label(emoji)
                    outer._avatar_stack.set_visible_child_name('emoji')
                else:
                    outer._avatar.set_text(msg.sender_name or '')
                    outer._avatar_stack.set_visible_child_name('initials')
                outer._avatar_stack.set_visible(True)

            # Sender name
            if not msg.is_outgoing and msg.sender_name:
                outer._sender_lbl.set_label(msg.sender_name)
                outer._sender_lbl.set_visible(True)
            else:
                outer._sender_lbl.set_visible(False)

            # Text / shared contact
            sc = parse_shared_contact(msg.text)
            if sc:
                pub_key_hex, contact_type, contact_name = sc
                outer._content_stack.set_visible_child_name('contact')
                if msg.is_outgoing:
                    outer._sc_header_lbl.set_label(
                        _('You shared a contact:'))
                else:
                    sender = msg.sender_name or '?'
                    outer._sc_header_lbl.set_label(
                        _('{name} shared a contact with you:').format(
                            name=sender))
                icon_map = {
                    1: 'avatar-default-symbolic',
                    2: 'network-server-symbolic',
                    3: 'user-home-symbolic',
                    4: 'weather-clear-symbolic',
                }
                outer._sc_icon.set_from_icon_name(
                    icon_map.get(contact_type, 'avatar-default-symbolic'))
                outer._sc_name_lbl.set_label(contact_name)
                outer._sc_key_lbl.set_label(
                    f'{pub_key_hex[:8]}...{pub_key_hex[-8:]}')
                if msg.is_outgoing:
                    outer._sc_add_btn.set_visible(False)
                else:
                    outer._sc_add_btn.set_visible(True)
                    is_known = self._window.is_contact_known(pub_key_hex)
                    outer._sc_add_btn.set_sensitive(not is_known)
                    outer._sc_add_btn.set_label(
                        _('Already added') if is_known else _('Add Contact'))
                    if outer._sc_add_handler_id is not None:
                        outer._sc_add_btn.disconnect(outer._sc_add_handler_id)
                    outer._sc_add_handler_id = outer._sc_add_btn.connect(
                        'clicked',
                        self._on_shared_contact_add,
                        pub_key_hex, contact_type, contact_name,
                    )
            else:
                loc = parse_shared_location(msg.text)
                if loc:
                    lat, lon = loc
                    outer._content_stack.set_visible_child_name('location')
                    if msg.is_outgoing:
                        outer._loc_header_lbl.set_label(
                            _('You shared a location:'))
                    else:
                        sender = msg.sender_name or '?'
                        outer._loc_header_lbl.set_label(
                            _('{name} shared a location:').format(
                                name=sender))
                    outer._loc_coords_lbl.set_label(
                        f'{lat:.6f}, {lon:.6f}')
                    outer._loc_viewport.set_zoom_level(14)
                    outer._loc_viewport.set_location(lat, lon)
                    outer._loc_marker.set_location(lat, lon)
                    outer._loc_show_handler = outer._loc_show_btn.connect(
                        'clicked', self._on_show_location_map, lat, lon)
                    outer._loc_open_handler = outer._loc_open_btn.connect(
                        'clicked', self._on_open_location_external, lat, lon)
                else:
                    outer._content_stack.set_visible_child_name('text')
                    outer._text_lbl.set_markup(linkify(msg.text))

            # Frame style
            if msg.is_outgoing:
                outer._frame.add_css_class('outgoing-message')
            else:
                outer._frame.remove_css_class('outgoing-message')

            # Timestamp
            outer._time_lbl.set_label(msg.timestamp.strftime('%H:%M'))

            # Outgoing: resend + repeat labels
            if msg.is_outgoing:
                outer._resend_lbl.set_visible(True)
                outer._repeat_lbl.set_visible(True)
                self._update_resend_label_widget(outer, item)
                self._update_repeat_label_widget(outer, item)
                h1 = item.connect(
                    'notify::repeat-count',
                    lambda obj, pspec, w=outer: self._update_repeat_label_widget(w, obj),
                )
                h2 = item.connect(
                    'notify::resend-count',
                    lambda obj, pspec, w=outer: self._update_resend_label_widget(w, obj),
                )
                outer._handler_ids = [h1, h2]
            else:
                outer._resend_lbl.set_visible(False)
                outer._repeat_lbl.set_visible(False)
                outer._handler_ids = []

        elif item.kind == ChannelChatItem.KIND_DATE_DIVIDER:
            outer._msg_box.set_visible(False)
            outer._div_box.set_visible(True)
            outer._div_lbl.set_label(item.date_text)
            outer._div_lbl.remove_css_class('unread-divider')
            outer._div_lbl.add_css_class('dim-label')
            outer._div_line_left.remove_css_class('unread-divider-line')
            outer._div_line_left.add_css_class('date-divider-line')
            outer._div_line_right.remove_css_class('unread-divider-line')
            outer._div_line_right.add_css_class('date-divider-line')

        elif item.kind == ChannelChatItem.KIND_UNREAD_DIVIDER:
            outer._msg_box.set_visible(False)
            outer._div_box.set_visible(True)
            outer._div_lbl.set_label(_('New Messages'))
            outer._div_lbl.remove_css_class('dim-label')
            outer._div_lbl.add_css_class('unread-divider')
            outer._div_line_left.remove_css_class('date-divider-line')
            outer._div_line_left.add_css_class('unread-divider-line')
            outer._div_line_right.remove_css_class('date-divider-line')
            outer._div_line_right.add_css_class('unread-divider-line')

    def _on_factory_unbind(self, factory, list_item):
        """Clean up signal connections when the widget is recycled."""
        clamp = list_item.get_child()
        outer = clamp._outer
        for hid in outer._handler_ids:
            if outer._chat_item:
                outer._chat_item.disconnect(hid)
        outer._handler_ids = []
        if outer._sc_add_handler_id is not None:
            outer._sc_add_btn.disconnect(outer._sc_add_handler_id)
            outer._sc_add_handler_id = None
        if outer._loc_show_handler is not None:
            outer._loc_show_btn.disconnect(outer._loc_show_handler)
            outer._loc_show_handler = None
        if outer._loc_open_handler is not None:
            outer._loc_open_btn.disconnect(outer._loc_open_handler)
            outer._loc_open_handler = None
        outer._chat_item = None

    # ─── Shared contact helpers ─────────────────────────────

    def _on_shared_contact_add(self, btn, pub_key_hex, contact_type, name):
        self._window.add_contact(
            bytes.fromhex(pub_key_hex), contact_type, name)
        btn.set_sensitive(False)
        btn.set_label(_('Already added'))

    # ─── Shared location helpers ──────────────────────────────

    def _on_show_location_map(self, btn, lat, lon):
        from meshy.views.map_view import MapView
        dialog, map_w, m_layer, vp = MapView.open_map_dialog(
            self._window, _('Shared Location'))
        marker = Shumate.Marker()
        widget = MapView._build_marker_widget(
            0.26, 0.52, 0.96, 'find-location-symbolic',
            _('Shared'), show_label=True)
        marker.set_child(widget)
        marker.set_location(lat, lon)
        m_layer.add_marker(marker)

        lats, lons = [lat], [lon]
        dev = self._window.device_info
        if dev and dev.latitude is not None and dev.longitude is not None:
            self_marker = Shumate.Marker()
            self_widget = MapView._build_marker_widget(
                0.90, 0.16, 0.22, 'avatar-default-symbolic',
                dev.name or _('My Device'), show_label=True)
            self_marker.set_child(self_widget)
            self_marker.set_location(dev.latitude, dev.longitude)
            m_layer.add_marker(self_marker)
            lats.append(dev.latitude)
            lons.append(dev.longitude)

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

    def _on_open_location_external(self, btn, lat, lon):
        launcher = Gtk.UriLauncher.new(f'geo:{lat},{lon}')
        launcher.launch(self._window, None, None, None)

    # ─── Label update helpers ────────────────────────────────

    @staticmethod
    def _update_repeat_label_widget(outer, item):
        count = item.repeat_count
        if count > 0:
            outer._repeat_lbl.set_label(
                f'\u00b7 {count} repeat{"s" if count != 1 else ""}')
        else:
            outer._repeat_lbl.set_label('')

    @staticmethod
    def _update_resend_label_widget(outer, item):
        count = item.resend_count
        if count > 0:
            outer._resend_lbl.set_label(
                f'\u00b7 {count} resend{"s" if count != 1 else ""}')
        else:
            outer._resend_lbl.set_label('')

    # ─── Context menu ───────────────────────────────────────

    def _find_outer_at(self, x, y):
        """Find the outer box widget at the given ListView coordinates."""
        target = self._list_view.pick(x, y, Gtk.PickFlags.DEFAULT)
        while target is not None and target is not self._list_view:
            if hasattr(target, '_chat_item'):
                return target
            target = target.get_parent()
        return None

    def _on_list_right_click(self, gesture, n_press, x, y):
        outer = self._find_outer_at(x, y)
        if not outer:
            return
        item = outer._chat_item
        if item and item.kind == ChannelChatItem.KIND_MESSAGE and item.message:
            if gesture:
                gesture.set_state(Gtk.EventSequenceState.CLAIMED)
            self._context_item = item
            self._context_menu_open = True
            self._is_sticky = False
            self._frozen_scroll_value = \
                self._chat_scroll.get_vadjustment().get_value()
            if item.message.is_outgoing:
                self._context_popover.set_menu_model(self._outgoing_menu)
            else:
                self._context_popover.set_menu_model(self._incoming_menu)
            rect = Gdk.Rectangle()
            rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
            self._context_popover.set_pointing_to(rect)
            self._context_popover.popup()

    def _on_context_popover_closed(self, popover):
        self._context_menu_open = False
        self._is_sticky = self._is_at_bottom()

    def _on_list_long_press(self, gesture, x, y):
        self._on_list_right_click(None, 1, x, y)

    def _show_repeats_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        msg = item.message
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        self._show_repeats_dialog(key)

    def _resend_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._resend_message(item.message, item)

    def _delete_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._delete_message_item(item)

    def _reply_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._reply_to_message(item.message)

    def _copy_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._copy_message_text(item.message)

    def _show_paths_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        msg = item.message
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        self._show_message_paths(key, msg)

    # ─── Emoji ───────────────────────────────────────────────

    def _on_emoji_clicked(self, btn):
        chooser = Gtk.EmojiChooser()
        chooser.set_parent(btn)
        chooser.connect('emoji-picked', lambda c, emoji: self._text_entry.insert_text(
            emoji, self._text_entry.get_position()
        ))
        chooser.connect('closed', lambda c: c.unparent())
        chooser.popup()

    def _on_share_contact(self, action, param):
        if not self._current_channel:
            return
        contacts = self._window.contacts
        if not contacts:
            return

        builder = Gtk.Builder.new_from_resource(
            '/page/codeberg/sesivany/Meshy/ui/share-contact-dialog.ui')
        dialog = builder.get_object('share_dialog')
        search_btn = builder.get_object('share_search_btn')
        search_entry = builder.get_object('share_search_entry')
        list_view = builder.get_object('share_list_view')
        status_label = builder.get_object('share_status_label')

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

        store = Gio.ListStore(item_type=_ContactPickItem)
        for c in sorted(contacts, key=lambda c: c.name.lower()):
            store.append(_ContactPickItem(c))

        custom_filter = Gtk.CustomFilter.new(
            self._share_filter_func, search_entry)

        def _on_search_changed(*_args):
            custom_filter.changed(Gtk.FilterChange.DIFFERENT)

        search_entry.connect('search-changed', _on_search_changed)

        filter_model = Gtk.FilterListModel(model=store, filter=custom_filter)
        selection = Gtk.NoSelection(model=filter_model)
        list_view.set_model(selection)

        def _on_share(contact):
            text = f'<{contact.public_key_hex}:{contact.type}:{contact.name}>'
            self._window.send_channel_message(
                self._current_channel.index, text)
            dialog.close()

        factory = Gtk.SignalListItemFactory()
        factory.connect('setup',
            lambda f, li: _contact_pick_factory_setup(f, li, _('Share')))
        factory.connect('bind',
            lambda f, li: _contact_pick_factory_bind(f, li, _on_share))
        factory.connect('unbind', _contact_pick_factory_unbind)
        list_view.set_factory(factory)

        def _on_activate(lv, pos):
            item = filter_model.get_item(pos)
            if item:
                _on_share(item.contact)

        list_view.connect('activate', _on_activate)

        total = store.get_n_items()
        status_label.set_label(
            _('{total} contacts').format(total=total) if total else
            _('No contacts'))

        dialog._refs = (store, custom_filter, filter_model, selection, factory)
        dialog.present(self._window)

    @staticmethod
    def _share_filter_func(item, search_entry):
        q = search_entry.get_text().lower()
        return not q or q in item.contact.name.lower()

    def _on_share_location(self, action, param):
        if not self._current_channel:
            return
        dev = self._window.device_info
        if not dev or dev.latitude is None or dev.longitude is None:
            dialog = Adw.AlertDialog(
                heading=_('Location Not Available'),
                body=_('Your companion does not have a GPS location.'),
            )
            dialog.add_response('ok', _('OK'))
            dialog.present(self._window)
            return
        text = f'{dev.latitude:.6f},{dev.longitude:.6f}'
        self._window.send_channel_message(
            self._current_channel.index, text)

    # ─── @Mention autocomplete ─────────────────────────────────

    def _get_channel_senders(self) -> list[str]:
        my_name = self._window.device_info.name or ''
        seen = set()
        result = []
        for i in range(self._store.get_n_items() - 1, -1, -1):
            item = self._store.get_item(i)
            if item.kind == ChannelChatItem.KIND_MESSAGE and item.message:
                name = item.message.sender_name
                if name and name != my_name and name not in seen:
                    seen.add(name)
                    result.append(name)
        return result

    def _update_mention_popover(self, prefix: str, at_char_idx: int):
        senders = self._get_channel_senders()
        filtered = [n for n in senders if n.lower().startswith(prefix.lower())] if prefix else senders

        if not filtered:
            if self._mention_popover:
                self._mention_popover.popdown()
            return

        if not self._mention_popover:
            self._mention_popover = Gtk.Popover()
            self._mention_popover.set_parent(self._text_entry)
            self._mention_popover.set_autohide(False)
            self._mention_popover.set_has_arrow(False)
            self._mention_popover.set_position(Gtk.PositionType.TOP)
            self._mention_popover.set_halign(Gtk.Align.START)
            self._mention_listbox = Gtk.ListBox()
            self._mention_listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
            self._mention_listbox.connect('row-activated', self._on_mention_selected)
            scrolled = Gtk.ScrolledWindow(
                propagate_natural_height=True,
                max_content_height=280,
                hscrollbar_policy=Gtk.PolicyType.NEVER)
            scrolled.set_child(self._mention_listbox)
            self._mention_popover.set_child(scrolled)

        try:
            delegate = self._text_entry.get_delegate()
            layout = delegate.get_layout()
            index_bytes = len(self._text_entry.get_text()[:at_char_idx].encode('utf-8'))
            strong_pos, _ = layout.get_cursor_pos(index_bytes)
            layout_x, _ = delegate.get_layout_offsets()
            cursor_x = layout_x + strong_pos.x // Pango.SCALE
        except Exception:
            cursor_x = 0

        rect = Gdk.Rectangle()
        rect.x = cursor_x
        rect.y = 0
        rect.width = 1
        rect.height = self._text_entry.get_height()
        self._mention_popover.set_pointing_to(rect)

        while row := self._mention_listbox.get_first_child():
            self._mention_listbox.remove(row)
        for name in filtered[:20]:
            label = Gtk.Label(
                label=name, xalign=0,
                margin_start=8, margin_end=8, margin_top=4, margin_bottom=4)
            self._mention_listbox.append(label)

        self._mention_popover.popup()

    def _check_mention_trigger(self, text: str, cursor_pos: int):
        text_before = text[:cursor_pos]
        at_idx = text_before.rfind('@')
        if at_idx == -1:
            if self._mention_popover:
                self._mention_popover.popdown()
            return
        if at_idx > 0 and text_before[at_idx - 1] not in (' ', '\t'):
            if self._mention_popover:
                self._mention_popover.popdown()
            return
        after_at = text_before[at_idx + 1:]
        if '[' in after_at:
            if self._mention_popover:
                self._mention_popover.popdown()
            return
        self._update_mention_popover(after_at, at_idx)

    def _on_mention_selected(self, listbox, row):
        label = row.get_child()
        name = label.get_label()
        text = self._text_entry.get_text()
        cursor_pos = self._text_entry.get_position()
        text_before = text[:cursor_pos]
        at_idx = text_before.rfind('@')
        if at_idx == -1:
            return
        mention = f'@[{name}] '
        new_text = text[:at_idx] + mention + text[cursor_pos:]
        self._text_entry.set_text(new_text)
        self._text_entry.set_position(at_idx + len(mention))
        self._mention_popover.popdown()

    def _on_entry_key_pressed(self, ctrl, keyval, keycode, state):
        if not self._mention_popover or not self._mention_popover.get_visible():
            return False
        if keyval == Gdk.KEY_Escape:
            self._mention_popover.popdown()
            return True
        if keyval in (Gdk.KEY_Down, Gdk.KEY_Up):
            selected = self._mention_listbox.get_selected_row()
            if keyval == Gdk.KEY_Down:
                if selected:
                    next_row = selected.get_next_sibling()
                    if next_row:
                        self._mention_listbox.select_row(next_row)
                else:
                    first = self._mention_listbox.get_first_child()
                    if first:
                        self._mention_listbox.select_row(first)
            elif keyval == Gdk.KEY_Up:
                if selected:
                    prev_row = selected.get_prev_sibling()
                    if prev_row:
                        self._mention_listbox.select_row(prev_row)
            return True
        if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_Tab):
            selected = self._mention_listbox.get_selected_row()
            if selected:
                self._on_mention_selected(self._mention_listbox, selected)
                return True
        return False

    # ─── Channel & messages ──────────────────────────────────

    def get_last_message_time(self, channel_index: int) -> Optional[float]:
        try:
            if not self._window.storage:
                return None
            return self._window.storage.get_max_channel_message_timestamp(channel_index)
        except Exception:
            return None

    def set_channel(self, channel: Channel):
        if self._mention_popover:
            self._mention_popover.popdown()
        if self._current_channel is not None:
            adj = self._chat_scroll.get_vadjustment()
            self._scroll_positions[self._current_channel.index] = adj.get_value()
        self._current_channel = channel
        self._stack.set_visible_child_name('chat')
        self._load_messages(channel)

    def _load_messages(self, channel: Channel):
        self._outgoing_items.clear()
        self._repeats.clear()
        self._incoming_paths.clear()
        self._seen_channel_msgs.clear()
        self._incoming_items.clear()
        self._resend_ts_map.clear()
        self._unread_pos = -1
        self._messages_offset = 0
        self._all_loaded = False
        self._loading_more = False

        messages = self._window.get_channel_messages(
            channel.index, limit=_BATCH_SIZE)
        self._messages_offset = len(messages)
        self._all_loaded = len(messages) < _BATCH_SIZE
        last_read_at = self._window.get_channel_last_read_at(channel.index)

        self._preload_paths(messages)

        new_items = []
        divider_inserted = False
        last_date = None
        for msg in messages:
            msg_date = msg.timestamp.date()
            if last_date is not None and msg_date != last_date:
                new_items.append(ChannelChatItem(
                    kind=ChannelChatItem.KIND_DATE_DIVIDER,
                    date_text=_format_date_text(msg.timestamp),
                ))
            last_date = msg_date
            if (not divider_inserted and last_read_at is not None
                    and not msg.is_outgoing
                    and msg.timestamp.timestamp() > last_read_at):
                new_items.append(ChannelChatItem(
                    kind=ChannelChatItem.KIND_UNREAD_DIVIDER))
                self._unread_pos = len(new_items) - 1
                divider_inserted = True
            new_items.append(self._create_message_item(msg))

        saved_pos = self._scroll_positions.get(channel.index)
        if saved_pos is not None:
            self._is_sticky = False
            self._restoring_scroll = True

        # Replace all items in a single atomic operation
        self._store.splice(0, self._store.get_n_items(), new_items)

        if saved_pos is not None:
            GLib.idle_add(
                lambda pos=saved_pos: self._restore_scroll_position(pos))
        else:
            GLib.idle_add(self._scroll_to_bottom)
        if self._unread_pos >= 0:
            GLib.idle_add(lambda: self._update_unread_btn_visibility() or False)

        # Mark as read now
        self._window.mark_channel_read(channel.index)

    def _preload_paths(self, messages):
        if not self._window.storage:
            return
        for msg in messages:
            key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
            if msg.is_outgoing:
                paths = self._window.storage.get_channel_message_paths(
                    msg.channel_index,
                    int(msg.timestamp.timestamp()),
                    msg.text,
                )
                if paths:
                    self._repeats[key] = [
                        {
                            'path_hops': self._split_path_hops(
                                p.get('path_bytes', ''),
                                p.get('path_len', 0),
                            ),
                            'path_len': p.get('path_len', 0),
                        }
                        for p in paths
                    ]
            else:
                if key not in self._incoming_paths:
                    paths = self._window.storage.get_channel_message_paths(
                        msg.channel_index,
                        int(msg.timestamp.timestamp()),
                        msg.text,
                    )
                    if paths:
                        self._incoming_paths[key] = paths

    def _load_older_messages(self):
        if (self._all_loaded or self._loading_more
                or not self._current_channel):
            return
        self._loading_more = True

        messages = self._window.get_channel_messages(
            self._current_channel.index,
            limit=_BATCH_SIZE, offset=self._messages_offset)
        if not messages:
            self._all_loaded = True
            self._loading_more = False
            return

        self._preload_paths(messages)

        older_items = []
        last_date = None
        for msg in messages:
            msg_date = msg.timestamp.date()
            if last_date is not None and msg_date != last_date:
                older_items.append(ChannelChatItem(
                    kind=ChannelChatItem.KIND_DATE_DIVIDER,
                    date_text=_format_date_text(msg.timestamp),
                ))
            last_date = msg_date
            older_items.append(self._create_message_item(msg))

        # Add boundary date divider if the dates differ
        if last_date is not None:
            for i in range(self._store.get_n_items()):
                first = self._store.get_item(i)
                if first.kind == ChannelChatItem.KIND_MESSAGE:
                    if first.message.timestamp.date() != last_date:
                        older_items.append(ChannelChatItem(
                            kind=ChannelChatItem.KIND_DATE_DIVIDER,
                            date_text=_format_date_text(
                                first.message.timestamp),
                        ))
                    break

        self._store.splice(0, 0, older_items)
        if self._unread_pos >= 0:
            self._unread_pos += len(older_items)
        self._messages_offset += len(messages)
        self._all_loaded = len(messages) < _BATCH_SIZE
        self._loading_more = False

    @staticmethod
    def _split_path_hops(path_bytes_hex: str, hop_count: int) -> list[str]:
        """Split concatenated path hex into individual hop prefixes.

        Supports 1-byte (2 hex), 2-byte (4 hex), and 3-byte (6 hex) hash sizes.
        The hash size is derived from total bytes / hop count.
        """
        if not path_bytes_hex or hop_count <= 0:
            return []
        total_bytes = len(path_bytes_hex) // 2
        if total_bytes % hop_count == 0:
            hash_size = total_bytes // hop_count
        else:
            hash_size = 1  # fallback
        chars_per_hop = hash_size * 2
        return [path_bytes_hex[i:i + chars_per_hop].upper()
                for i in range(0, len(path_bytes_hex), chars_per_hop)]

    def _make_msg_key(self, channel_index, timestamp, text):
        """Create a key for matching outgoing messages to repeats."""
        ts = int(timestamp.timestamp()) if hasattr(timestamp, 'timestamp') else int(timestamp)
        return (channel_index, ts, text)

    def _create_message_item(self, msg: ChannelMessage) -> ChannelChatItem:
        """Create a ChannelChatItem and register it in tracking dicts."""
        item = ChannelChatItem(kind=ChannelChatItem.KIND_MESSAGE, message=msg)
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)

        if msg.is_outgoing:
            self._outgoing_items[key] = item
            if key not in self._repeats:
                self._repeats[key] = []
            # Set initial repeat count from persisted data
            item._repeat_count_val = len(self._repeats.get(key, []))
        else:
            self._incoming_items[key] = item

        return item

    def _add_message_item(self, msg: ChannelMessage):
        """Create a ChannelChatItem and add it to the store."""
        item = self._create_message_item(msg)
        self._store.append(item)

    # ─── Dialogs (unchanged) ─────────────────────────────────

    def _show_message_paths(self, key, msg):
        """Show dialog with message path variants and repeater hops."""
        paths = self._incoming_paths.get(key, [])
        # Fallback to storage if no in-memory data
        if not paths and self._window.storage:
            paths = self._window.storage.get_channel_message_paths(
                msg.channel_index, int(msg.timestamp.timestamp()), msg.text,
            )
            if paths:
                self._incoming_paths[key] = paths

        if not paths:
            dialog = Adw.AlertDialog(
                heading=_('Message Paths'),
                body=_('No path information available. Path data is only '
                     'captured for messages received while connected.'),
            )
            dialog.add_response('ok', _('OK'))
            dialog.present(self._window)
            return

        dialog = Adw.Dialog()
        dialog.set_title(_('Message Paths'))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

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

        scroll = Gtk.ScrolledWindow(vexpand=True)
        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL, spacing=8,
            margin_start=12, margin_end=12, margin_top=12, margin_bottom=12,
        )

        info_label = Gtk.Label(
            label=f'Message from {msg.sender_name or "unknown"} '
                  f'received via {len(paths)} path{"s" if len(paths) != 1 else ""}:',
            wrap=True, xalign=0,
        )
        info_label.add_css_class('dim-label')
        content.append(info_label)

        for i, p in enumerate(paths, 1):
            snr = p.get('snr')
            path_len = p.get('path_len', -1)
            path_bytes_hex = p.get('path_bytes', '')

            if path_bytes_hex and path_len > 0:
                hops = self._split_path_hops(path_bytes_hex, path_len)
                num_hops = len(hops) if hops else path_len
                hops_str = f'{num_hops} hop{"s" if num_hops != 1 else ""}'
            elif path_len == 255 or path_len == -1:
                hops_str = 'flood'
            elif path_len == 0:
                hops_str = 'direct'
            else:
                hops_str = f'{path_len} hop{"s" if path_len != 1 else ""}'

            snr_str = f'SNR: {snr:.1f} dB' if snr is not None else ''
            title_parts = [f'Path {i}: {hops_str}']
            if snr_str:
                title_parts.append(snr_str)

            path_group = Adw.PreferencesGroup(
                title=' \u00b7 '.join(title_parts),
            )

            if path_bytes_hex and path_len > 0:
                hops = self._split_path_hops(path_bytes_hex, path_len)
                hop_contacts = self._window.resolve_hop_prefixes(hops)
                for hop_idx, prefix in enumerate(hops):
                    contact = hop_contacts[hop_idx]
                    name = contact.name if contact else _('Unknown repeater')
                    subtitle = f'Hop {hop_idx + 1} \u00b7 prefix {prefix}'

                    hop_row = Adw.ActionRow(
                        title=name,
                        subtitle=subtitle,
                    )
                    hop_row.add_prefix(
                        Gtk.Image.new_from_icon_name('network-server-symbolic')
                    )
                    path_group.add(hop_row)

                if any(c and c.has_location for c in hop_contacts):
                    map_btn = Gtk.Button(icon_name='map-symbolic',
                                         valign=Gtk.Align.CENTER)
                    map_btn.add_css_class('flat')
                    map_btn.set_tooltip_text(_('Show on Map'))
                    map_btn.connect('clicked',
                        lambda b, hc=hop_contacts, hp=hops:
                            self._show_channel_path_on_map(hc, hp))
                    path_group.set_header_suffix(map_btn)
            elif path_len == 0:
                direct_row = Adw.ActionRow(
                    title=_('Direct message'),
                    subtitle=_('No repeaters were used'),
                    sensitive=False,
                )
                direct_row.add_prefix(
                    Gtk.Image.new_from_icon_name('network-wireless-symbolic'))
                path_group.add(direct_row)
            else:
                no_path_row = Adw.ActionRow(
                    title=_('No repeater details'),
                    subtitle=_('Path data is only captured via LOG_DATA while connected'),
                    sensitive=False,
                )
                path_group.add(no_path_row)

            content.append(path_group)

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

    def _show_channel_path_on_map(self, hop_contacts, hop_prefixes=None):
        """Show a channel message path on the map."""
        from meshy.views.map_view import (
            MapView, _MARKER_COLORS, _MARKER_ICONS, _SELF_COLOR, _UNKNOWN_COLOR,
        )
        import gi
        gi.require_version('Shumate', '1.0')
        from gi.repository import Shumate

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

        # Build full hop list including unknowns
        raw_hops = []
        for i, c in enumerate(hop_contacts):
            prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None
            if c and c.has_location:
                raw_hops.append((c.latitude, c.longitude, c, prefix))
            else:
                raw_hops.append((None, None, c, prefix))

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

        # Interpolate unknown positions
        interp = []
        for lat, lon, contact, prefix in raw_hops:
            interp.append((lat, lon, contact, prefix, None))
        interp = MapView.interpolate_unknown_hops(interp)

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

        dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(
            self._window, title=_('Message 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 interp:
            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:
                # Self/device marker
                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:
                # Unknown/interpolated hop
                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,
                       t=type_label, ip=is_interpolated, od=detail_cb:
                    MapView.show_marker_popover(w, nm, la, lo,
                        contact_type=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)

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

    def _reply_to_message(self, msg):
        """Insert @[sender_name] into the text entry."""
        mention = f'@[{msg.sender_name}] ' if msg.sender_name else ''
        self._text_entry.set_text(mention)
        self._text_entry.set_position(len(mention))
        self._text_entry.grab_focus()

    def _copy_message_text(self, msg):
        """Copy message text to clipboard."""
        clipboard = self._window.get_clipboard()
        clipboard.set(msg.text)

    def _show_repeats_dialog(self, key):
        repeats = self._repeats.get(key, [])
        if not repeats:
            dialog = Adw.AlertDialog(
                heading=_('Heard Repeats'),
                body=_('No repeats heard yet.'),
            )
            dialog.add_response('ok', _('OK'))
            dialog.present(self._window)
            return

        dialog = Adw.AlertDialog(heading=_('Heard Repeats'))
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        for i, rep in enumerate(repeats, 1):
            hops = rep.get('path_hops', [])
            if hops:
                hop_names = []
                for prefix in hops:
                    contact = self._window.find_contact_by_key_prefix(prefix.lower())
                    hop_names.append(contact.name if contact else prefix)
                path_str = ' \u2192 '.join(hop_names)
            else:
                path_len = rep.get('path_len', 0)
                path_str = f'{path_len} hop{"s" if path_len != 1 else ""}' if path_len > 0 else 'direct'
            label = Gtk.Label(label=f'{i}. {path_str}', xalign=0, wrap=True)
            content.append(label)
        dialog.set_extra_child(content)
        dialog.add_response('ok', _('OK'))
        dialog.present(self._window)

    # ─── Message actions ─────────────────────────────────────

    def _resend_message(self, msg, item):
        if self._current_channel:
            new_ts = self._window.resend_channel_message(
                self._current_channel.index, msg.text)
            original_key = self._make_msg_key(
                msg.channel_index, msg.timestamp, msg.text)
            resend_key = (msg.channel_index, new_ts, msg.text)
            self._resend_ts_map[resend_key] = original_key
            item.props.resend_count = item.resend_count + 1

    def _delete_message_item(self, item):
        msg = item.message
        # Find and remove from store
        for i in range(self._store.get_n_items()):
            if self._store.get_item(i) is item:
                self._store.remove(i)
                break
        # Clean up tracking dicts
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        self._outgoing_items.pop(key, None)
        self._incoming_items.pop(key, None)
        self._repeats.pop(key, None)
        self._incoming_paths.pop(key, None)
        self._seen_channel_msgs.discard(key)
        # Delete from storage
        self._window.delete_channel_message(
            msg.channel_index,
            msg.timestamp.timestamp(),
            msg.text,
        )

    # ─── Scroll navigation ────────────────────────────────────

    def _is_at_bottom(self) -> bool:
        adj = self._chat_scroll.get_vadjustment()
        return adj.get_value() + adj.get_page_size() >= adj.get_upper() - 1.0

    def _on_edge_overshot(self, scrolled_window, pos):
        if pos == Gtk.PositionType.TOP:
            self._load_older_messages()

    def _on_scroll_value_changed(self, adj):
        """User (or programmatic) scroll — update sticky flag."""
        if self._restoring_scroll:
            return
        if self._context_menu_open:
            adj.set_value(self._frozen_scroll_value)
            return
        at_bottom = self._is_at_bottom()
        self._is_sticky = at_bottom
        self._scroll_down_revealer.set_reveal_child(not at_bottom)
        self._update_unread_btn_visibility()

    def _on_scroll_upper_changed(self, adj, *args):
        """Scrollable area grew (new item) or viewport resized.
        If sticky, follow the bottom automatically."""
        if self._restoring_scroll:
            return
        if self._context_menu_open:
            adj.set_value(self._frozen_scroll_value)
            return
        if self._is_sticky:
            self._scroll_down()
        else:
            self._scroll_down_revealer.set_reveal_child(not self._is_at_bottom())

    def _is_item_visible(self, position: int) -> bool:
        """Check whether a list item at *position* is within the viewport."""
        adj = self._chat_scroll.get_vadjustment()
        top = adj.get_value()
        bottom = top + adj.get_page_size()
        n = self._store.get_n_items()
        if position < 0 or position >= n or n == 0:
            return False
        # Estimate item offset proportionally (all rows roughly equal height)
        item_y = (position / n) * adj.get_upper()
        return top <= item_y < bottom

    def _update_unread_btn_visibility(self):
        if self._unread_pos < 0:
            self._unread_btn_revealer.set_reveal_child(False)
            return
        visible = self._is_item_visible(self._unread_pos)
        if visible:
            # User has scrolled to the divider — clear it
            self._unread_pos = -1
            self._unread_btn_revealer.set_reveal_child(False)
        else:
            self._unread_btn_revealer.set_reveal_child(True)

    def _scroll_to_item(self, position):
        n = self._store.get_n_items()
        if position < 0 or position >= n:
            return False
        self._list_view.scroll_to(position, Gtk.ListScrollFlags.FOCUS, None)
        return False

    def _on_scroll_to_bottom_clicked(self, btn):
        self._is_sticky = True
        self._scroll_down()

    def _on_scroll_to_unread_clicked(self, btn):
        if self._unread_pos >= 0:
            self._scroll_to_item(self._unread_pos)
            self._unread_pos = -1
            self._unread_btn_revealer.set_reveal_child(False)

    # ─── Scrolling ───────────────────────────────────────────

    def _scroll_down(self):
        """Scroll to the newest message. Uses idle_add so the ListView
        has time to create the widget before we focus it (Fractal pattern)."""
        if self._context_menu_open or self._is_at_bottom():
            return
        n = self._store.get_n_items()
        if n > 0:
            GLib.idle_add(self._do_scroll_down)

    def _do_scroll_down(self):
        if self._context_menu_open:
            return False
        n = self._store.get_n_items()
        if n > 0:
            self._list_view.scroll_to(
                n - 1, Gtk.ListScrollFlags.FOCUS, None)
        return False

    def _restore_scroll_position(self, value):
        adj = self._chat_scroll.get_vadjustment()
        upper = adj.get_upper() - adj.get_page_size()
        adj.set_value(min(value, upper))
        self._restoring_scroll = False
        self._is_sticky = self._is_at_bottom()
        self._scroll_down_revealer.set_reveal_child(not self._is_sticky)
        return False

    def _scroll_to_bottom(self):
        adj = self._chat_scroll.get_vadjustment()
        adj.set_value(adj.get_upper())
        return False

    # ─── Input ───────────────────────────────────────────────

    def _get_channel_limit(self) -> int:
        sender_name = self._window.device_info.name or ''
        return max_channel_text_bytes(sender_name)

    def _on_text_changed(self, entry):
        text = entry.get_text()
        used = len(text.encode('utf-8'))
        limit = self._get_channel_limit()
        if used == 0:
            self._char_label.set_label('')
            self._send_btn.set_sensitive(True)
            self._char_label.remove_css_class('error')
        else:
            self._char_label.set_label(f'{used}/{limit}')
            over = used > limit
            self._send_btn.set_sensitive(not over)
            if over:
                self._char_label.add_css_class('error')
            else:
                self._char_label.remove_css_class('error')
        self._check_mention_trigger(text, entry.get_position())

    def _on_send(self, *args):
        if not self._current_channel:
            return
        text = self._text_entry.get_text().strip()
        if not text:
            return
        if len(text.encode('utf-8')) > self._get_channel_limit():
            return
        self._text_entry.set_text('')
        self._window.send_channel_message(self._current_channel.index, text)

    # ─── External callbacks ──────────────────────────────────

    def on_channel_message_sent(self, msg: ChannelMessage, sender_ts: int):
        """Called when a channel message is sent by us."""
        if (self._current_channel and
                msg.channel_index == self._current_channel.index and
                self._stack.get_visible_child_name() == 'chat'):
            # Force sticky so notify::upper handler scrolls to the new message
            self._is_sticky = True
            self._add_message_item(msg)

    def on_path_variant_received(self, msg, path_info=None):
        """Check if this is a duplicate message via a different path.
        Returns True if it's a duplicate (already seen), False if new."""
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        if key in self._seen_channel_msgs:
            if path_info:
                self._incoming_paths.setdefault(key, []).append(path_info)
            return True
        self._seen_channel_msgs.add(key)
        if path_info:
            self._incoming_paths.setdefault(key, []).append(path_info)
        return False

    def add_path_info(self, msg, path_info):
        """Append path_info to an existing message without affecting dedup."""
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        if key in self._incoming_paths:
            self._incoming_paths[key].append(path_info)
        else:
            self._incoming_paths[key] = [path_info]

    def on_channel_message_received(self, msg: ChannelMessage, path_info=None):
        if (self._current_channel and
                msg.channel_index == self._current_channel.index and
                self._stack.get_visible_child_name() == 'chat'):
            # If sticky, the notify::upper handler will auto-scroll
            self._add_message_item(msg)

    def on_repeat_received(self, channel_idx, sender_ts, text, path_hops):
        """Called when an echo/repeat of our outgoing message is received.

        Returns the original message key (channel_idx, ts, text) if matched,
        so the caller can persist path info under the original timestamp.
        """
        repeat_entry = {
            'path_hops': path_hops or [],
            'path_len': len(path_hops) if path_hops else 0,
        }

        # Check if this echo matches a resend timestamp
        resend_key = (channel_idx, sender_ts, text)
        matched_key = self._resend_ts_map.pop(resend_key, None)

        if matched_key is None:
            # Fall back to heuristic: same channel + timestamp within 30s + matching text
            for k in self._outgoing_items:
                if k[0] != channel_idx:
                    continue
                ts_diff = abs(k[1] - sender_ts)
                if ts_diff > 30:
                    continue
                if k[2] == text or text.startswith(k[2]) or k[2].startswith(text):
                    matched_key = k
                    break

        if matched_key is None:
            key = (channel_idx, sender_ts, text)
            if key not in self._repeats:
                self._repeats[key] = []
            self._repeats[key].append(repeat_entry)
            return None

        if matched_key not in self._repeats:
            self._repeats[matched_key] = []
        self._repeats[matched_key].append(repeat_entry)
        item = self._outgoing_items.get(matched_key)
        if item:
            item.props.repeat_count = len(self._repeats[matched_key])
        return matched_key


@Gtk.Template(resource_path='/page/codeberg/sesivany/Meshy/ui/channels-view.ui')
class ChannelsView(Gtk.Box):
    """Channels list - shown in the sidebar."""
    __gtype_name__ = 'MeshyChannelsView'

    _channel_list = Gtk.Template.Child('channel_list')
    _search_entry = Gtk.Template.Child('search_entry')
    _status_label = Gtk.Template.Child('status_label')

    FILTER_ALL = 'all'
    FILTER_PUBLIC = 'public'
    FILTER_HASHTAG = 'hashtag'
    FILTER_PRIVATE = 'private'

    SORT_AZ = 'az'
    SORT_MESSAGES = 'messages'

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._chat_widget = ChannelChatWidget(window)
        self._unread_indices: set[int] = set()
        self._unread_counts: dict[int, int] = {}
        self._unread_dots: dict[int, Gtk.Label] = {}
        self._current_filter = self.FILTER_ALL
        settings = Gio.Settings(schema_id='page.codeberg.sesivany.Meshy')
        saved_sort = settings.get_string('channel-sort')
        self._current_sort = saved_sort if saved_sort in (self.SORT_AZ, self.SORT_MESSAGES) else self.SORT_MESSAGES
        self._total_channels = 0

        self._channel_list.connect('row-activated', self._on_row_selected)
        self._search_entry.connect('search-changed', self._on_search_changed)
        self._channel_list.set_filter_func(self._filter_func)

        # Actions for the add channel menu and filter/sort
        action_group = Gio.SimpleActionGroup()
        action = Gio.SimpleAction.new('create-private', None)
        action.connect('activate', self._on_create_private_channel)
        action_group.add_action(action)

        action = Gio.SimpleAction.new('join-private', None)
        action.connect('activate', self._on_join_private_channel)
        action_group.add_action(action)

        action = Gio.SimpleAction.new('join-public', None)
        action.connect('activate', self._on_join_public_channel)
        action_group.add_action(action)

        action = Gio.SimpleAction.new('join-hashtag', None)
        action.connect('activate', self._on_join_hashtag_channel)
        action_group.add_action(action)

        filter_action = Gio.SimpleAction.new_stateful(
            'filter', GLib.VariantType.new('s'),
            GLib.Variant.new_string(self._current_filter))
        filter_action.connect('activate', self._on_filter_changed)
        action_group.add_action(filter_action)
        sort_action = Gio.SimpleAction.new_stateful(
            'sort', GLib.VariantType.new('s'),
            GLib.Variant.new_string(self._current_sort))
        sort_action.connect('activate', self._on_sort_changed)
        action_group.add_action(sort_action)

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

        self.update_channels(self._window.channels)

    def get_chat_widget(self) -> ChannelChatWidget:
        return self._chat_widget

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

    def build_filter_menu(self):
        menu = Gio.Menu()
        for filter_id, label in (
            ('all', _('All')), ('public', _('Public')),
            ('hashtag', _('Hashtag')), ('private', _('Private')),
        ):
            item = Gio.MenuItem.new(label, None)
            item.set_action_and_target_value(
                'channel.filter', GLib.Variant.new_string(filter_id))
            menu.append_item(item)
        return menu

    def build_sort_menu(self):
        menu = Gio.Menu()
        for sort_id, label in (
            ('az', _('A–Z')), ('messages', _('Latest Messages')),
        ):
            item = Gio.MenuItem.new(label, None)
            item.set_action_and_target_value(
                'channel.sort', GLib.Variant.new_string(sort_id))
            menu.append_item(item)
        return menu

    def _filter_func(self, row) -> bool:
        if not hasattr(row, '_channel'):
            return True
        channel = row._channel
        f = self._current_filter
        if f == self.FILTER_PUBLIC and not channel.is_public_channel:
            return False
        elif f == self.FILTER_HASHTAG and not channel.is_hashtag_channel:
            return False
        elif f == self.FILTER_PRIVATE and (channel.is_public_channel or channel.is_hashtag_channel):
            return False
        search = self._search_entry.get_text().lower()
        if search and search not in (channel.name or '').lower():
            return False
        return True

    def _update_status_count(self):
        total = self._total_channels
        if self._current_filter != self.FILTER_ALL or self._search_entry.get_text():
            visible = 0
            row = self._channel_list.get_first_child()
            while row:
                if hasattr(row, '_channel') and self._filter_func(row):
                    visible += 1
                row = row.get_next_sibling()
            self._status_label.set_label(
                _('{visible}/{total} channels').format(visible=visible, total=total))
        else:
            self._status_label.set_label(
                _('{total} channels').format(total=total) if total != 1
                else _('{total} channel').format(total=total))

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

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

    def _on_sort_changed(self, action, param):
        self._current_sort = param.get_string()
        action.set_state(param)
        settings = Gio.Settings(schema_id='page.codeberg.sesivany.Meshy')
        settings.set_string('channel-sort', self._current_sort)
        self.update_channels(self._window.channels)

    def _channel_sort_key(self, channel: Channel):
        type_order = 0 if channel.is_public_channel else (1 if channel.is_hashtag_channel else 2)
        if self._current_sort == self.SORT_AZ:
            return (type_order, (channel.name or '').lower())
        else:
            last_msg = self._chat_widget.get_last_message_time(channel.index)
            return (type_order, -(last_msg or 0))

    def _on_row_selected(self, listbox, row):
        if row and hasattr(row, '_channel'):
            self._chat_widget.set_channel(row._channel)
            # Navigate to content panel (matters in collapsed/phone mode)
            title = row._channel.name or _('Channel {}').format(row._channel.index)
            if row._channel.region:
                title = _('{name} ({region})').format(name=title, region=row._channel.region)
            self._window.content_page.set_title(title)
            self._window.split_view.set_show_content(True)
            self.mark_unread(row._channel.index, False)

    def update_channels(self, channels: list[Channel]):
        child = self._channel_list.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            self._channel_list.remove(child)
            child = next_child

        non_empty = [c for c in channels if not c.is_empty]
        self._total_channels = len(non_empty)
        sorted_channels = sorted(non_empty, key=self._channel_sort_key)

        for channel in sorted_channels:
            row = Adw.ActionRow(
                title=channel.name or _('Channel {}').format(channel.index),
                subtitle=channel.region,
            )
            if channel.is_hashtag_channel:
                icon_name = 'hashtag-symbolic'
            elif channel.is_public_channel:
                icon_name = 'system-users-symbolic'
            else:
                icon_name = 'padlock2-symbolic'
            row.add_prefix(Gtk.Image.new_from_icon_name(icon_name))
            row.set_activatable(True)
            row._channel = channel

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

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

            self._channel_list.append(row)

        GLib.idle_add(self._update_status_count)

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

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

    def on_path_variant_received(self, msg, path_info=None):
        return self._chat_widget.on_path_variant_received(msg, path_info)

    def add_path_info(self, msg, path_info):
        self._chat_widget.add_path_info(msg, path_info)

    def on_channel_message_received(self, msg: ChannelMessage, path_info=None):
        self._chat_widget.on_channel_message_received(msg, path_info)

    def on_channel_message_sent(self, msg: ChannelMessage, sender_ts: int):
        self._chat_widget.on_channel_message_sent(msg, sender_ts)

    def on_repeat_received(self, channel_idx, sender_ts, text, path_hops):
        return self._chat_widget.on_repeat_received(channel_idx, sender_ts, text, path_hops)

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

    def _show_channel_detail(self, channel: Channel):
        """Show channel detail dialog with rename, PSK, notifications, and remove."""
        dialog = Adw.Dialog()
        dialog.set_title(channel.name or _('Channel {}').format(channel.index))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

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

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

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

        if channel.is_public_channel:
            name_entry = Adw.EntryRow(title=_('Name'))
            name_entry.set_text(channel.name)
            name_entry.set_show_apply_button(True)

            def _on_rename_apply(*args):
                new_name = name_entry.get_text().strip()
                if new_name:
                    self._window.set_channel(channel.index, new_name, channel.psk)

            name_entry.connect('apply', _on_rename_apply)
            info_group.add(name_entry)
        else:
            info_group.add(Adw.ActionRow(
                title=_('Name'), subtitle=channel.name or _('Channel {}').format(channel.index),
            ))

        info_group.add(Adw.ActionRow(
            title=_('Type'),
            subtitle=channel.channel_type,
        ))

        # Private Key row with copy button (only for private channels)
        if not channel.is_public_channel and not channel.is_hashtag_channel:
            psk_row = Adw.ActionRow(
                title=_('Private Key'),
                subtitle=channel.psk_hex,
            )
            psk_row.set_subtitle_selectable(True)
            copy_btn = Gtk.Button(icon_name='edit-copy-symbolic', valign=Gtk.Align.CENTER)
            copy_btn.add_css_class('flat')
            copy_btn.set_tooltip_text('Copy private key to clipboard')

            def _on_copy_psk(*args):
                clipboard = self._window.get_clipboard()
                clipboard.set(channel.psk_hex)
                self._window.show_toast(_('Private key copied to clipboard'), timeout=2)

            copy_btn.connect('clicked', _on_copy_psk)
            psk_row.add_suffix(copy_btn)
            info_group.add(psk_row)

        content.append(info_group)

        # Notifications group
        notif_group = Adw.PreferencesGroup(title=_('Notifications'))

        notif_row = Adw.ComboRow(title=_('Notification Level'))
        notif_list = Gtk.StringList()
        for label in [_('Default'), _('All Messages'), _('Mentions Only'), _('None')]:
            notif_list.append(label)
        notif_row.set_model(notif_list)

        level_to_index = {
            NotificationLevel.DEFAULT: 0,
            NotificationLevel.ALL: 1,
            NotificationLevel.MENTIONS: 2,
            NotificationLevel.NONE: 3,
        }
        notif_row.set_selected(level_to_index.get(channel.notification_level, 0))

        def _on_notif_changed(*args):
            selected = notif_row.get_selected()
            level = [NotificationLevel.DEFAULT, NotificationLevel.ALL, NotificationLevel.MENTIONS, NotificationLevel.NONE][selected]
            self._window.set_channel_notification_level(channel, level)

        notif_row.connect('notify::selected', _on_notif_changed)
        notif_group.add(notif_row)

        content.append(notif_group)

        # Region group
        available_regions = self._window.get_regions()
        if available_regions:
            region_group = Adw.PreferencesGroup(
                title=_('Region'),
                description=_('Limit flood messages to a specific region'),
            )

            region_row = Adw.ComboRow(title=_('Region'))
            region_list = Gtk.StringList()
            region_list.append(_('None'))
            for r in available_regions:
                region_list.append(r)
            region_row.set_model(region_list)

            if channel.region in available_regions:
                region_row.set_selected(available_regions.index(channel.region) + 1)
            else:
                region_row.set_selected(0)

            def _on_region_changed(*args):
                selected = region_row.get_selected()
                new_region = available_regions[selected - 1] if selected > 0 else ''
                channel.region = new_region
                if self._window.storage:
                    self._window.storage.set_channel_region(channel.index, new_region)
                self.update_channels(self._window._channels)

            region_row.connect('notify::selected', _on_region_changed)
            region_group.add(region_row)

            content.append(region_group)

        # Actions group
        actions_group = Adw.PreferencesGroup(title=_('Actions'))

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

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

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

        content.append(actions_group)

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

    def _find_next_empty_index(self) -> Optional[int]:
        """Find the next available channel index (0-39)."""
        used = {ch.index for ch in self._window.channels if not ch.is_empty}
        for i in range(40):
            if i not in used:
                return i
        return None

    def _show_no_slots_dialog(self):
        dialog = Adw.AlertDialog(
            heading=_('No Slots Available'),
            body=_('All 8 channel slots are in use.'),
        )
        dialog.add_response('ok', _('OK'))
        dialog.present(self._window)

    def _on_create_private_channel(self, *args):
        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        dialog = Adw.AlertDialog(heading=_('Create a Private Channel'), body=_('A random PSK will be generated. Share it with others so they can join.'))
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        name_row = Adw.EntryRow(title=_('Channel Name'))
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(name_row)
        content.append(fields_list)
        dialog.set_extra_child(content)
        dialog.add_response('cancel', _('Cancel'))
        dialog.add_response('create', _('Create'))
        dialog.set_response_appearance('create', Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == 'create':
                import os
                name = name_row.get_text().strip()
                if not name:
                    return
                psk = os.urandom(16)
                self._window.set_channel(idx, name, psk)

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

    def _on_join_private_channel(self, *args):
        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        dialog = Adw.AlertDialog(heading=_('Join a Private Channel'), body=_('Enter the channel name and private key shared with you.'))
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        name_row = Adw.EntryRow(title=_('Channel Name'))
        psk_row = Adw.EntryRow(title=_('Private Key (32 hex characters)'))
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(name_row)
        fields_list.append(psk_row)
        content.append(fields_list)
        dialog.set_extra_child(content)
        dialog.add_response('cancel', _('Cancel'))
        dialog.add_response('join', _('Join'))
        dialog.set_response_appearance('join', Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == 'join':
                try:
                    name = name_row.get_text().strip()
                    psk = bytes.fromhex(psk_row.get_text().strip())
                    if len(psk) == 16 and name:
                        self._window.set_channel(idx, name, psk)
                except ValueError:
                    pass

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

    def _on_join_public_channel(self, *args):
        # Check if already on the public channel
        for ch in self._window.channels:
            if ch.is_public_channel:
                dialog = Adw.AlertDialog(heading=_('Already Joined'), body=_('You are already on the public channel.'))
                dialog.add_response('ok', _('OK'))
                dialog.present(self._window)
                return

        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        psk = bytes.fromhex(Channel.PUBLIC_CHANNEL_PSK)
        self._window.set_channel(idx, _('Public'), psk)

    def _on_join_hashtag_channel(self, *args):
        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        dialog = Adw.AlertDialog(heading=_('Join a Hashtag Channel'), body=_('Enter a hashtag name. Anyone using the same hashtag can communicate.'))
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        hashtag_row = Adw.EntryRow(title=_('Hashtag (e.g. #general)'))
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class('boxed-list')
        fields_list.append(hashtag_row)
        content.append(fields_list)
        dialog.set_extra_child(content)
        dialog.add_response('cancel', _('Cancel'))
        dialog.add_response('join', _('Join'))
        dialog.set_response_appearance('join', Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == 'join':
                tag = hashtag_row.get_text().strip()
                if not tag:
                    return
                if not tag.startswith('#'):
                    tag = f'#{tag}'
                h = hashlib.sha256(tag.encode()).digest()
                psk = h[:16]
                name = tag
                self._window.set_channel(idx, name, psk)

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