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

"""Chat conversation view - shown in the content panel."""

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 Contact, Message, MessageStatus
from meshy.protocol import max_dm_text_bytes
from meshy.utils import linkify, parse_shared_contact, parse_shared_location
from meshy.views import (
    _contact_pick_factory_bind,
    _contact_pick_factory_setup,
    _contact_pick_factory_unbind,
    _ContactPickItem,
    _format_date_text,
    create_shumate_map,
)
from meshy.views.chat_mixin import ChatScrollMixin

_BATCH_SIZE = 30


class ChatItem(GObject.Object):
    """Model object for one row in the 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._status_val = int(message.status) if message else int(MessageStatus.PENDING)
        self.status_detail = ""

    @GObject.Property(type=int)
    def status(self):
        return self._status_val

    @status.setter
    def status(self, value):
        self._status_val = value


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/chat-view.ui")
class ChatView(ChatScrollMixin, Gtk.Box):
    __gtype_name__ = "MeshyChatView"

    _stack = Gtk.Template.Child("stack")
    _scroll = Gtk.Template.Child("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._contact: Contact | None = None
        self._item_by_msg_id: dict[str, ChatItem] = {}

        # 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=ChatItem)
        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 menu for outgoing messages
        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
        ctx_menu = Gio.Menu()
        ctx_menu.append(_("Send Again"), "msg.resend")
        ctx_menu.append(_("Delete"), "msg.delete")
        self._context_popover.set_menu_model(ctx_menu)
        self._context_item = None

        ctx_group = Gio.SimpleActionGroup()
        resend_a = Gio.SimpleAction.new("resend", None)
        resend_a.connect("activate", lambda a, p: self._resend_context_item())
        ctx_group.add_action(resend_a)
        delete_a = Gio.SimpleAction.new("delete", None)
        delete_a.connect("activate", lambda a, p: self._delete_context_item())
        ctx_group.add_action(delete_a)
        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[str, 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)
        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)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

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

        # Sender name (for room messages)
        sender_lbl = Gtk.Label(xalign=0, max_width_chars=40)
        sender_lbl.add_css_class("caption")
        sender_lbl.add_css_class("accent")
        sender_lbl.set_margin_start(4)
        sender_lbl.set_visible(False)
        msg_box.append(sender_lbl)

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

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

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

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

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

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

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

        content_stack.add_named(sc_box, "contact")

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

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

        loc_map_frame = Gtk.Frame()
        loc_map_frame.add_css_class("card")
        loc_map_frame.set_overflow(Gtk.Overflow.HIDDEN)
        loc_map_frame.set_cursor(Gdk.Cursor.new_from_name("pointer"))
        loc_map_gesture = Gtk.GestureClick()
        loc_map_frame.add_controller(loc_map_gesture)
        loc_box.append(loc_map_frame)

        loc_open_btn = Gtk.Button(label=_("Open in Maps App"), margin_top=4)
        loc_box.append(loc_open_btn)

        content_stack.add_named(loc_box, "location")

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

        # Meta line: time + status
        meta_box = Gtk.Box(spacing=4)
        time_lbl = Gtk.Label()
        time_lbl.add_css_class("dim-label")
        time_lbl.add_css_class("caption")
        meta_box.append(time_lbl)

        status_lbl = Gtk.Label()
        status_lbl.add_css_class("caption")
        status_lbl.set_visible(False)
        meta_box.append(status_lbl)

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

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

        # Store widget references
        outer._msg_box = msg_box
        outer._sender_lbl = sender_lbl
        outer._text_lbl = text_lbl
        outer._frame = frame
        outer._time_lbl = time_lbl
        outer._status_lbl = status_lbl
        outer._meta_box = meta_box
        outer._div_box = div_box
        outer._div_lbl = div_lbl
        outer._div_line_left = line_left
        outer._div_line_right = line_right
        outer._content_stack = content_stack
        outer._sc_header_lbl = sc_header_lbl
        outer._sc_icon = sc_icon
        outer._sc_name_lbl = sc_name_lbl
        outer._sc_key_lbl = sc_key_lbl
        outer._sc_add_btn = sc_add_btn
        outer._sc_add_handler_id = None
        outer._loc_header_lbl = loc_header_lbl
        outer._loc_coords_lbl = loc_coords_lbl
        outer._loc_map_frame = loc_map_frame
        outer._loc_open_btn = loc_open_btn
        outer._loc_map_gesture = loc_map_gesture
        outer._loc_map_click_handler = None
        outer._loc_open_handler = None
        outer._chat_item = None
        outer._status_handler_id = None

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

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

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

            # Alignment
            if msg.is_outgoing:
                outer._msg_box.set_halign(Gtk.Align.END)
                outer._meta_box.set_halign(Gtk.Align.END)
            else:
                outer._msg_box.set_halign(Gtk.Align.START)
                outer._meta_box.set_halign(Gtk.Align.START)

            # Sender name (room messages)
            if msg.room_sender_name and not msg.is_outgoing:
                outer._sender_lbl.set_label(msg.room_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 = self._contact.name if self._contact else "?"
                    outer._sc_header_lbl.set_label(
                        _("{name} shared a contact with you:").format(name=sender)
                    )
                icon_map = {
                    1: "avatar-default-symbolic",
                    2: "network-server-symbolic",
                    3: "user-home-symbolic",
                    4: "weather-clear-symbolic",
                }
                outer._sc_icon.set_from_icon_name(
                    icon_map.get(contact_type, "avatar-default-symbolic")
                )
                outer._sc_name_lbl.set_label(contact_name)
                outer._sc_key_lbl.set_label(f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}")
                if msg.is_outgoing:
                    outer._sc_add_btn.set_visible(False)
                else:
                    outer._sc_add_btn.set_visible(True)
                    is_known = self._window.is_contact_known(pub_key_hex)
                    outer._sc_add_btn.set_sensitive(not is_known)
                    outer._sc_add_btn.set_label(
                        _("Already added") if is_known else _("Add Contact")
                    )
                    if outer._sc_add_handler_id is not None:
                        outer._sc_add_btn.disconnect(outer._sc_add_handler_id)
                    outer._sc_add_handler_id = outer._sc_add_btn.connect(
                        "clicked",
                        self._on_shared_contact_add,
                        pub_key_hex,
                        contact_type,
                        contact_name,
                    )
            else:
                loc = parse_shared_location(msg.text)
                if loc:
                    lat, lon = loc
                    outer._content_stack.set_visible_child_name("location")
                    if msg.is_outgoing:
                        outer._loc_header_lbl.set_label(_("You shared a location:"))
                    else:
                        sender = self._contact.name if self._contact else "?"
                        outer._loc_header_lbl.set_label(
                            _("{name} shared a location:").format(name=sender)
                        )
                    outer._loc_coords_lbl.set_label(f"{lat:.6f}, {lon:.6f}")
                    self._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: self._on_show_location_map(la, lo),
                    )
                    outer._loc_open_handler = outer._loc_open_btn.connect(
                        "clicked", self._on_open_location_external, lat, lon
                    )
                else:
                    outer._content_stack.set_visible_child_name("text")
                    outer._text_lbl.set_markup(linkify(msg.text))

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

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

            # Status (outgoing only)
            if msg.is_outgoing:
                outer._status_lbl.set_visible(True)
                self._apply_status(
                    outer._status_lbl,
                    MessageStatus(item.status),
                    item.status_detail,
                )
                hid = item.connect(
                    "notify::status",
                    lambda obj, pspec, w=outer: self._apply_status(
                        w._status_lbl,
                        MessageStatus(obj.status),
                        obj.status_detail,
                    ),
                )
                outer._status_handler_id = hid
            else:
                outer._status_lbl.set_visible(False)

        elif item.kind == ChatItem.KIND_DATE_DIVIDER:
            outer._msg_box.set_visible(False)
            outer._div_box.set_visible(True)
            outer._div_lbl.set_label(item.date_text)
            # Date divider styling
            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 == ChatItem.KIND_UNREAD_DIVIDER:
            outer._msg_box.set_visible(False)
            outer._div_box.set_visible(True)
            outer._div_lbl.set_label(_("New Messages"))
            # Unread divider styling
            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
        if outer._status_handler_id is not None:
            outer._chat_item.disconnect(outer._status_handler_id)
            outer._status_handler_id = None
        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._loc_map_frame.set_child(None)
        outer._chat_item = None

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

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

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

    @staticmethod
    def _ensure_loc_map(outer, lat, lon):
        from meshy.views.map_view import MapView

        loc_map, loc_marker_layer, loc_viewport = create_shumate_map(zoom_level=14)
        loc_map.set_vexpand(False)
        loc_map.set_hexpand(True)
        loc_map.set_size_request(100, 150)
        loc_map.get_scale().set_visible(False)
        loc_map.set_show_zoom_buttons(False)
        loc_map.get_license().set_visible(False)

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

        loc_viewport.set_zoom_level(14)
        loc_viewport.set_location(lat, lon)
        loc_marker.set_location(lat, lon)
        outer._loc_map_frame.set_child(loc_map)

    def _on_show_location_map(self, lat, lon):
        from meshy.views.map_view import MapView

        dialog, map_w, m_layer, vp = MapView.open_map_dialog(self._window, _("Shared Location"))
        marker = MapView.create_marker(
            0.26, 0.52, 0.96, "find-location-symbolic", _("Shared"), lat, lon
        )
        m_layer.add_marker(marker)

        dev = self._window.device_info
        if dev and dev.latitude is not None and dev.longitude is not None:
            self_marker = MapView.create_marker(
                0.90,
                0.16,
                0.22,
                "avatar-default-symbolic",
                dev.name or _("My Device"),
                dev.latitude,
                dev.longitude,
            )
            m_layer.add_marker(self_marker)

        vp.set_zoom_level(14)
        vp.set_location(lat, lon)
        dialog.present(self._window)

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

    # ─── Status helpers ──────────────────────────────────────

    @staticmethod
    def _apply_status(label, status, detail=""):
        symbol, css = {
            MessageStatus.PENDING: (_("Sending"), "dim-label"),
            MessageStatus.SENT: (_("Sending"), "dim-label"),
            MessageStatus.DELIVERED: ("\u2713", "success"),
            MessageStatus.FAILED: ("\u2717", "error"),
        }.get(status, ("...", "dim-label"))
        text = f"{symbol} {detail}".strip() if detail else symbol
        label.set_label(text)
        for c in ("dim-label", "success", "error"):
            label.remove_css_class(c)
        label.add_css_class(css)

    # ─── 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 == ChatItem.KIND_MESSAGE
            and item.message
            and item.message.is_outgoing
        ):
            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()
            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 _resend_context_item(self):
        if self._context_item and self._context_item.message:
            self.resend_message(self._context_item.message)

    def _delete_context_item(self):
        if self._context_item and self._context_item.message:
            self.delete_message(self._context_item.message)

    def _on_share_contact(self, action, param):
        if not self._contact:
            return
        contacts = [
            c for c in self._window.contacts if c.public_key_hex != self._contact.public_key_hex
        ]
        if not contacts:
            return

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

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

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

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

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

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

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

        def _on_share(contact):
            text = f"<{contact.public_key_hex}:{contact.type}:{contact.name}>"
            self._window.send_message(self._contact, text)
            dialog.close()

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

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

        list_view.connect("activate", _on_activate)

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

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

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

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

    def _on_share_location_map(self, action, param):
        if not self._contact:
            return
        from meshy.views import open_map_picker

        dev = self._window.device_info
        lat = dev.latitude if dev and dev.latitude is not None else 0.0
        lon = dev.longitude if dev and dev.longitude is not None else 0.0
        contact = self._contact
        open_map_picker(
            self._window,
            _("Share Location"),
            _("Share"),
            lat,
            lon,
            lambda la, lo: self._window.send_message(contact, f"{la:.6f},{lo:.6f}"),
        )

    # ─── Contact & messages ──────────────────────────────────

    def set_contact(self, contact: Contact):
        if self._contact:
            adj = self._scroll.get_vadjustment()
            self._scroll_positions[self._contact.public_key_hex] = adj.get_value()

        self._contact = contact
        self._stack.set_visible_child_name("chat")
        self._load_messages()

    def _load_messages(self):
        self._item_by_msg_id.clear()
        self._unread_pos = -1
        self._messages_offset = 0
        self._all_loaded = False
        self._loading_more = False

        if not self._contact:
            self._store.remove_all()
            return

        messages = self._window.get_messages_for_contact(self._contact, limit=_BATCH_SIZE)
        self._messages_offset = len(messages)
        self._all_loaded = len(messages) < _BATCH_SIZE
        last_read_at = self._window.get_contact_last_read_at(self._contact.public_key_hex)

        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(
                    ChatItem(
                        kind=ChatItem.KIND_DATE_DIVIDER,
                        date_text=_format_date_text(msg.timestamp),
                    )
                )
            last_date = msg_date

            # Insert unread divider before first unread incoming message
            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(ChatItem(kind=ChatItem.KIND_UNREAD_DIVIDER))
                self._unread_pos = len(new_items) - 1
                divider_inserted = True

            item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=msg)
            new_items.append(item)
            if msg.message_id:
                self._item_by_msg_id[msg.message_id] = item

        saved_pos = self._scroll_positions.get(self._contact.public_key_hex)
        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_contact_read(self._contact.public_key_hex)

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

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

        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(
                    ChatItem(
                        kind=ChatItem.KIND_DATE_DIVIDER,
                        date_text=_format_date_text(msg.timestamp),
                    )
                )
            last_date = msg_date
            item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=msg)
            older_items.append(item)
            if msg.message_id:
                self._item_by_msg_id[msg.message_id] = item

        # 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 == ChatItem.KIND_MESSAGE:
                    if first.message.timestamp.date() != last_date:
                        older_items.append(
                            ChatItem(
                                kind=ChatItem.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

    def _on_text_changed(self, entry):
        text = entry.get_text()
        used = len(text.encode("utf-8"))
        limit = max_dm_text_bytes()
        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")

    def _on_send(self, *args):
        if not self._contact:
            return
        text = self._text_entry.get_text().strip()
        if not text:
            return
        if len(text.encode("utf-8")) > max_dm_text_bytes():
            return
        self._text_entry.set_text("")
        self._window.send_message(self._contact, text)

    def on_message_received(self, contact: Contact, message: Message):
        """Called when a new message is received."""
        if self._contact and contact.public_key_hex == self._contact.public_key_hex:
            item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=message)
            # If sticky, the notify::upper handler will auto-scroll
            self._store.append(item)

    def on_message_sent(self, message: Message):
        """Called when a message is sent."""
        # Force sticky so notify::upper handler scrolls to the new message
        self._is_sticky = True
        item = ChatItem(kind=ChatItem.KIND_MESSAGE, message=message)
        self._store.append(item)
        if message.message_id:
            self._item_by_msg_id[message.message_id] = item

    def resend_message(self, message: Message):
        """Re-send a message to the current contact."""
        if self._contact and message.message_id:
            self._window.resend_message(self._contact, message)

    def delete_message(self, message: Message):
        """Delete a message from the view and storage."""
        if not message.message_id:
            return
        for i in range(self._store.get_n_items()):
            item = self._store.get_item(i)
            if (
                item.kind == ChatItem.KIND_MESSAGE
                and item.message
                and item.message.message_id == message.message_id
            ):
                self._store.remove(i)
                break
        self._item_by_msg_id.pop(message.message_id, None)
        self._window.delete_message(message.message_id)

    def update_message_status(self, message_id: str, status: MessageStatus, detail: str = ""):
        """Update the status of a sent message."""
        if message_id in self._item_by_msg_id:
            item = self._item_by_msg_id[message_id]
            item.status_detail = detail
            # Setting status triggers notify::status -> bound widget updates
            item.props.status = int(status)
