# 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 gettext
import hashlib


def ngettext(singular, plural, n):
    return gettext.dngettext("meshy", singular, plural, n)


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 (
    clear_children,
    extract_emoji,
    format_number,
    format_time_ago,
    linkify,
    parse_shared_contact,
    parse_shared_location,
)
from meshy.views import _add_hover_swap, _format_date_text
from meshy.views.chat_helpers import (
    CONTACT_TYPE_ICONS,
    ensure_loc_map,
    open_location_external,
    share_contact_dialog,
    share_location,
    share_location_map,
    show_location_map,
)
from meshy.views.chat_mixin import ChatScrollMixin

_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
        self._max_hops_val = -1

    @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

    @GObject.Property(type=int)
    def max_hops(self):
        return self._max_hops_val

    @max_hops.setter
    def max_hops(self, value):
        self._max_hops_val = value


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

    __gtype_name__ = "MeshyChannelChatWidget"

    _stack = Gtk.Template.Child("stack")
    _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: Channel | None = 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")
        extra_menu.append(_("Share Location from Map"), "entry.share-location-map")
        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)
        loc_map_a = Gio.SimpleAction.new("share-location-map", None)
        loc_map_a.connect("activate", self._on_share_location_map)
        entry_group.add_action(loc_map_a)
        self._text_entry.insert_action_group("entry", entry_group)

        # Scroll state tracking
        adj = self._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._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)

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/channel-message-item.ui"
        )
        clamp = builder.get_object("clamp")
        outer = builder.get_object("outer")

        # Cursor and gesture must be set in Python
        loc_map_frame = builder.get_object("loc_map_frame")
        loc_map_frame.set_cursor(Gdk.Cursor.new_from_name("pointer"))
        loc_map_gesture = Gtk.GestureClick()
        loc_map_frame.add_controller(loc_map_gesture)

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

        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)
                    )
                outer._sc_icon.set_from_icon_name(
                    CONTACT_TYPE_ICONS.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}")
                    ensure_loc_map(outer, lat, lon)
                    outer._loc_map_click_handler = outer._loc_map_gesture.connect(
                        "released",
                        lambda g, n, x, y, la=lat, lo=lon: show_location_map(self._window, la, lo),
                    )
                    outer._loc_open_handler = outer._loc_open_btn.connect(
                        "clicked", 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:
                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)
                self._update_hops_label_widget(outer, item)
                h1 = item.connect(
                    "notify::max-hops",
                    lambda obj, pspec, w=outer: self._update_hops_label_widget(w, obj),
                )
                outer._handler_ids = [h1]

        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_map_click_handler is not None:
            outer._loc_map_gesture.disconnect(outer._loc_map_click_handler)
            outer._loc_map_click_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"))

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

    @staticmethod
    def _update_repeat_label_widget(outer, item):
        count = item.repeat_count
        if count > 0:
            outer._repeat_lbl.set_label(
                ngettext("\u00b7 %d repeat", "\u00b7 %d repeats", count) % count
            )
            outer._repeat_lbl.set_visible(True)
        else:
            outer._repeat_lbl.set_label("")
            outer._repeat_lbl.set_visible(False)

    @staticmethod
    def _update_hops_label_widget(outer, item):
        hops = item.max_hops
        if hops == 0:
            outer._repeat_lbl.set_label("\u00b7 " + _("direct"))
            outer._repeat_lbl.set_visible(True)
        elif hops == 255 or hops == -1:
            outer._repeat_lbl.set_label("")
            outer._repeat_lbl.set_visible(False)
        elif hops > 0:
            outer._repeat_lbl.set_label("\u00b7 " + ngettext("%d hop", "%d hops", hops) % hops)
            outer._repeat_lbl.set_visible(True)
        else:
            outer._repeat_lbl.set_label("")
            outer._repeat_lbl.set_visible(False)

    @staticmethod
    def _update_resend_label_widget(outer, item):
        count = item.resend_count
        if count > 0:
            outer._resend_lbl.set_label(
                ngettext("\u00b7 %d resend", "\u00b7 %d resends", count) % count
            )
            outer._resend_lbl.set_visible(True)
        else:
            outer._resend_lbl.set_label("")
            outer._resend_lbl.set_visible(False)

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

    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._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 _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_share_contact(self, action, param):
        if not self._current_channel:
            return
        channel_idx = self._current_channel.index

        def _send(c):
            text = f"<{c.public_key_hex}:{c.type}:{c.name}>"
            self._window.send_channel_message(channel_idx, text)

        share_contact_dialog(self._window, self._window.contacts, _send)

    def _on_share_location(self, action, param):
        if not self._current_channel:
            return
        channel_idx = self._current_channel.index
        share_location(
            self._window, lambda text: self._window.send_channel_message(channel_idx, text)
        )

    def _on_share_location_map(self, action, param):
        if not self._current_channel:
            return
        channel_idx = self._current_channel.index
        share_location_map(
            self._window, lambda text: self._window.send_channel_message(channel_idx, 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 and 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) -> float | None:
        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._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
            paths = self._incoming_paths.get(key, [])
            if paths:
                item._max_hops_val = max(p.get("path_len", -1) for p in paths)

        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

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/message-paths-dialog.ui"
        )
        dialog = builder.get_object("dialog")
        content = builder.get_object("content")

        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: {format_number(snr)} 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("repeater-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)

        dialog.present(self._window)

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

        gi.require_version("Shumate", "1.0")

        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 = "repeater-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,
        )

    # ─── 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 _update_item_max_hops(self, key, path_info):
        item = self._incoming_items.get(key)
        if item and path_info:
            new_len = path_info.get("path_len", -1)
            if new_len > item.max_hops:
                item.props.max_hops = new_len

    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)
                self._update_item_max_hops(key, 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]
        self._update_item_max_hops(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_bar = Gtk.Template.Child("search_bar")
    _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_bar.connect_entry(self._search_entry)
        self._search_bar.connect("notify::search-mode-enabled", self._on_search_mode_changed)
        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_bar,
            "search-mode-enabled",
            GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE,
        )

    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
            or f == self.FILTER_HASHTAG
            and not channel.is_hashtag_channel
            or f == self.FILTER_PRIVATE
            and (channel.is_public_channel or channel.is_hashtag_channel)
        ):
            return False
        search = self._search_entry.get_text().lower()
        return not (search and search not in (channel.name or "").lower())

    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_mode_changed(self, search_bar, pspec):
        if not search_bar.get_search_mode():
            self._search_entry.set_text("")

    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)
            self._window._set_channel_title_widget(row._channel)
            self._window.split_view.set_show_content(True)
            self.mark_unread(row._channel.index, False)

    def update_channels(self, channels: list[Channel]):
        clear_children(self._channel_list)

        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)
        self._window.update_nav_unread_channels()

    def _show_channel_detail(self, channel: Channel):
        """Show channel detail dialog with rename, PSK, notifications, and remove."""
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/channel-detail-dialog.ui"
        )
        dialog = builder.get_object("dialog")
        content = builder.get_object("content")
        dialog.set_title(channel.name or _("Channel {}").format(channel.index))

        # 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)

        # Chat group — participants
        participants = (
            self._window.storage.get_channel_participants(channel.index)
            if self._window.storage
            else []
        )
        chat_group = Adw.PreferencesGroup(title=_("Chat"))
        sender_count = len(participants)
        participants_row = Adw.ActionRow(
            title=_("Participants"),
            subtitle=ngettext("{} participant", "{} participants", sender_count).format(
                sender_count
            ),
        )
        participants_row.add_suffix(Gtk.Image(icon_name="go-next-symbolic"))
        participants_row.set_activatable(True)

        def _on_participants_activated(*_args):
            self._show_channel_participants(channel, dialog)

        participants_row.connect("activated", _on_participants_activated)
        chat_group.add(participants_row)

        retention_row = Adw.ComboRow(title=_("Message Retention"))
        retention_list = Gtk.StringList()
        retention_options = [
            (1, _("1 Day")),
            (7, _("1 Week")),
            (30, _("1 Month")),
            (90, _("3 Months")),
            (0, _("Forever")),
        ]
        for _days, label in retention_options:
            retention_list.append(label)
        retention_row.set_model(retention_list)

        days_to_index = {days: i for i, (days, _) in enumerate(retention_options)}
        retention_row.set_selected(days_to_index.get(channel.retention_days, 4))

        def _on_retention_changed(*_args):
            selected = retention_row.get_selected()
            days = retention_options[selected][0]
            channel.retention_days = days
            if self._window.storage:
                self._window.storage.set_channel_retention_days(channel.index, days)

        retention_row.connect("notify::selected", _on_retention_changed)
        chat_group.add(retention_row)

        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)
        chat_group.add(notif_row)

        content.append(chat_group)

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

        if available_regions:
            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)
        else:
            hint_row = Adw.ActionRow(
                title=_("No regions defined"),
                subtitle=_("Add regions in Settings to assign them to channels"),
            )
            hint_row.add_suffix(Gtk.Image(icon_name="go-next-symbolic"))
            hint_row.set_activatable(True)

            def _on_hint_activated(*args):
                dialog.connect(
                    "closed", lambda *a: self._window._nav_buttons["settings"].set_active(True)
                )
                dialog.close()

            hint_row.connect("activated", _on_hint_activated)
            region_group.add(hint_row)

        content.append(region_group)

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

        clear_row = Adw.ActionRow(title=_("Clear Messages"))
        clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic", valign=Gtk.Align.CENTER)
        clear_btn.add_css_class("destructive-action")

        def _on_clear(*_args):
            confirm = Adw.AlertDialog(
                heading=_("Clear Messages?"),
                body=_('Delete all messages in "{}"?').format(
                    channel.name or _("Channel {}").format(channel.index)
                ),
            )
            confirm.add_response("cancel", _("Cancel"))
            confirm.add_response("clear", _("Clear"))
            confirm.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)

            def _on_confirm(d, response):
                if response != "clear":
                    return
                self._window.storage.clear_channel_messages(channel.index)
                self._reload_channel_chat(channel)
                self._window.add_toast(_("Messages cleared"), timeout=2)

            confirm.connect("response", _on_confirm)
            confirm.present(self._window)

        clear_btn.connect("clicked", _on_clear)
        clear_row.add_suffix(clear_btn)
        clear_row.set_activatable_widget(clear_btn)
        actions_group.add(clear_row)

        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)

        dialog.present(self._window)

    def _show_channel_participants(self, channel: Channel, parent_dialog):
        """Show a dialog listing all senders seen in a channel with block/unblock."""
        import time as _time

        storage = self._window.storage
        if not storage:
            return

        participants = storage.get_channel_participants(channel.index)

        # Wrapper object for the list store
        class _SenderItem(GObject.Object):
            def __init__(self, name, message_count, last_seen_ts, is_blocked):
                super().__init__()
                self.sender_name = name
                self.message_count = message_count
                self.last_seen_ts = last_seen_ts
                self.is_blocked = is_blocked

        # Build dialog
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/channel-participants-dialog.ui"
        )
        dialog = builder.get_object("dialog")
        search_btn = builder.get_object("search_btn")
        filter_btn = builder.get_object("filter_btn")
        sort_btn = builder.get_object("sort_btn")
        search_entry = builder.get_object("search_entry")
        list_view = builder.get_object("list_view")
        status_label = builder.get_object("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))

        # Filter menu
        filter_menu = Gio.Menu()
        for fid, label in (("all", _("All")), ("blocked", _("Blocked"))):
            filter_menu.append_item(Gio.MenuItem.new(label, f"participants.filter::{fid}"))
        filter_btn.set_menu_model(filter_menu)

        # Sort menu
        sort_menu = Gio.Menu()
        for sid, label in (
            ("name", _("Name")),
            ("last-seen", _("Last Seen")),
            ("messages", _("Messages")),
        ):
            sort_menu.append_item(Gio.MenuItem.new(label, f"participants.sort::{sid}"))
        sort_btn.set_menu_model(sort_menu)

        # Actions
        action_group = Gio.SimpleActionGroup()
        filter_action = Gio.SimpleAction.new_stateful(
            "filter", GLib.VariantType.new("s"), GLib.Variant.new_string("all")
        )
        sort_action = Gio.SimpleAction.new_stateful(
            "sort", GLib.VariantType.new("s"), GLib.Variant.new_string("name")
        )
        action_group.add_action(filter_action)
        action_group.add_action(sort_action)
        dialog.insert_action_group("participants", action_group)

        # Model
        store = Gio.ListStore(item_type=_SenderItem)
        for p in participants:
            store.append(
                _SenderItem(
                    p["sender_name"],
                    p["message_count"],
                    p["last_seen"],
                    p["is_blocked"],
                )
            )

        # Filter
        custom_filter = Gtk.CustomFilter.new(
            lambda item, ud: self._participants_filter_func(item, ud),
            (search_entry, filter_action),
        )

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

        def _on_filter_activated(_action, param):
            filter_action.set_state(param)
            _on_filter_or_search_changed()

        search_entry.connect("search-changed", _on_filter_or_search_changed)
        filter_action.connect("activate", _on_filter_activated)
        filter_model = Gtk.FilterListModel(model=store, filter=custom_filter)

        # Sorter
        custom_sorter = Gtk.CustomSorter.new(
            lambda a, b, ud: self._participants_sort_func(a, b, ud),
            sort_action,
        )

        def _on_sort_activated(_action, param):
            sort_action.set_state(param)
            custom_sorter.changed(Gtk.SorterChange.DIFFERENT)

        sort_action.connect("activate", _on_sort_activated)
        sort_model = Gtk.SortListModel(model=filter_model, sorter=custom_sorter)
        selection = Gtk.NoSelection(model=sort_model)
        list_view.set_model(selection)

        def _update_status():
            total = store.get_n_items()
            blocked_count = sum(1 for i in range(total) if store.get_item(i).is_blocked)
            visible = filter_model.get_n_items()
            if blocked_count > 0:
                status_label.set_label(
                    ngettext("{} participant", "{} participants", total).format(total)
                    + ", "
                    + ngettext("{} blocked", "{} blocked", blocked_count).format(blocked_count)
                )
            else:
                status_label.set_label(
                    ngettext("{} participant", "{} participants", total).format(total)
                )

        # Factory
        def _on_setup(factory, list_item):
            row = Adw.ActionRow()
            icon = Gtk.Image()
            row.add_prefix(icon)
            btn = Gtk.Button(valign=Gtk.Align.CENTER)
            btn.set_visible(False)
            row.add_suffix(btn)
            row._icon = icon
            row._action_btn = btn
            row._action_handler_id = None

            hover = Gtk.EventControllerMotion()
            hover.connect("enter", lambda *_a: btn.set_visible(True))
            hover.connect("leave", lambda *_a: btn.set_visible(False))
            row.add_controller(hover)
            list_item.set_child(row)

        def _apply_blocked_style(row, item):
            btn = row._action_btn
            if item.is_blocked:
                row._icon.set_from_icon_name("action-unavailable-symbolic")
                row._icon.add_css_class("dim-label")
                btn.set_label(_("Unblock"))
                btn.remove_css_class("destructive-action")
                btn.add_css_class("suggested-action")
            else:
                row._icon.set_from_icon_name("avatar-default-symbolic")
                row._icon.remove_css_class("dim-label")
                btn.set_label(_("Block"))
                btn.remove_css_class("suggested-action")
                btn.add_css_class("destructive-action")

        def _on_bind(factory, list_item):
            row = list_item.get_child()
            item = list_item.get_item()
            row.set_title(GLib.markup_escape_text(item.sender_name))
            subtitle = (
                ngettext("{} message", "{} messages", item.message_count).format(item.message_count)
                + " · "
                + (
                    format_time_ago(_time.time() - item.last_seen_ts)
                    if item.last_seen_ts
                    else _("Never")
                )
            )
            row.set_subtitle(subtitle)
            _apply_blocked_style(row, item)

            def _on_block_clicked(_btn):
                if item.is_blocked:
                    storage.unblock_channel_sender(item.sender_name)
                    item.is_blocked = False
                    self._window._frame_handler.update_blocked_senders()
                    self._window.show_toast(_("Unblocked {}").format(item.sender_name), timeout=2)
                    _apply_blocked_style(row, item)
                    _update_status()
                else:
                    self._confirm_block_sender(
                        item, channel, row, dialog, _apply_blocked_style, _update_status
                    )

            if row._action_handler_id is not None:
                row._action_btn.disconnect(row._action_handler_id)
            row._action_handler_id = row._action_btn.connect("clicked", _on_block_clicked)

        def _on_unbind(factory, list_item):
            row = list_item.get_child()
            if row._action_handler_id is not None:
                row._action_btn.disconnect(row._action_handler_id)
                row._action_handler_id = None

        factory = Gtk.SignalListItemFactory()
        factory.connect("setup", _on_setup)
        factory.connect("bind", _on_bind)
        factory.connect("unbind", _on_unbind)
        list_view.set_factory(factory)

        # Prevent GC
        dialog._refs = (
            store,
            custom_filter,
            filter_model,
            custom_sorter,
            sort_model,
            selection,
            factory,
            action_group,
        )

        _update_status()
        dialog.present(self._window)

    def _confirm_block_sender(
        self, item, channel, row, participants_dialog, apply_style_func, update_status_func
    ):
        storage = self._window.storage

        confirm = Adw.AlertDialog(
            heading=_("Block {}?").format(item.sender_name),
            body=_(
                "Channel messages don't include sender identity. "
                "This block matches by name only — the sender could "
                "change their name to bypass it."
            ),
        )
        confirm.add_response("cancel", _("Cancel"))
        confirm.add_response("block", _("Block"))
        confirm.add_response("block-delete", _("Block and Delete Messages"))
        confirm.set_response_appearance("block", Adw.ResponseAppearance.DESTRUCTIVE)
        confirm.set_response_appearance("block-delete", Adw.ResponseAppearance.DESTRUCTIVE)

        def _on_response(d, response):
            if response == "cancel":
                return
            storage.block_channel_sender(item.sender_name)
            item.is_blocked = True
            self._window._frame_handler.update_blocked_senders()
            apply_style_func(row, item)
            update_status_func()

            if response == "block-delete":
                storage.delete_channel_messages_by_sender(channel.index, item.sender_name)
                self._reload_channel_chat(channel)

            self._window.show_toast(_("Blocked {}").format(item.sender_name), timeout=2)

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

    def _reload_channel_chat(self, channel):
        w = self._chat_widget
        if w._current_channel and w._current_channel.index == channel.index:
            w._load_messages(channel)

    @staticmethod
    def _participants_filter_func(item, user_data):
        search_entry, filter_action = user_data
        search = search_entry.get_text().lower()
        if search and search not in item.sender_name.lower():
            return False
        f = filter_action.get_state().get_string()
        if f == "blocked" and not item.is_blocked:
            return False
        return True

    @staticmethod
    def _participants_sort_func(a, b, user_data):
        sort_action = user_data
        mode = sort_action.get_state().get_string()
        if mode == "last-seen":
            ta = a.last_seen_ts or 0
            tb = b.last_seen_ts or 0
            if ta != tb:
                return (tb > ta) - (tb < ta)
        elif mode == "messages":
            if a.message_count != b.message_count:
                return (b.message_count > a.message_count) - (b.message_count < a.message_count)
        na = a.sender_name.lower()
        nb = b.sender_name.lower()
        return (na > nb) - (na < nb)

    def _find_next_empty_index(self) -> int | None:
        """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."),
        )
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/create-private-channel-content.ui"
        )
        dialog.set_extra_child(builder.get_object("content"))
        name_row = builder.get_object("name_row")
        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."),
        )
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/join-private-channel-content.ui"
        )
        dialog.set_extra_child(builder.get_object("content"))
        name_row = builder.get_object("name_row")
        psk_row = builder.get_object("psk_row")
        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."),
        )
        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/join-hashtag-channel-content.ui"
        )
        dialog.set_extra_child(builder.get_object("content"))
        hashtag_row = builder.get_object("hashtag_row")
        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)
