# 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."""

from typing import Optional

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

from meshy.models import 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 (
    _format_date_text, create_shumate_map, _ContactPickItem,
    _contact_pick_factory_setup, _contact_pick_factory_bind,
    _contact_pick_factory_unbind,
)

_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(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: Optional[Contact] = 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')
        self._text_entry.set_extra_menu(extra_menu)
        entry_group = Gio.SimpleActionGroup()
        share_a = Gio.SimpleAction.new('share-contact', None)
        share_a.connect('activate', self._on_share_contact)
        entry_group.add_action(share_a)
        loc_a = Gio.SimpleAction.new('share-location', None)
        loc_a.connect('activate', self._on_share_location)
        entry_group.add_action(loc_a)
        self._text_entry.insert_action_group('entry', entry_group)

        # Scroll state tracking
        adj = self._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.add_named(text_lbl, 'text')

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

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

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

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

        content_stack.add_named(sc_box, 'contact')

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

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

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

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

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

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

        content_stack.add_named(loc_box, 'location')

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

        # Meta line: time + 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_viewport = loc_viewport
        outer._loc_marker = loc_marker
        outer._loc_show_btn = loc_show_btn
        outer._loc_open_btn = loc_open_btn
        outer._loc_show_handler = None
        outer._loc_open_handler = None
        outer._chat_item = None
        outer._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}')
                    outer._loc_viewport.set_zoom_level(14)
                    outer._loc_viewport.set_location(lat, lon)
                    outer._loc_marker.set_location(lat, lon)
                    outer._loc_show_handler = outer._loc_show_btn.connect(
                        'clicked', self._on_show_location_map, lat, lon)
                    outer._loc_open_handler = outer._loc_open_btn.connect(
                        'clicked', self._on_open_location_external, lat, lon)
                else:
                    outer._content_stack.set_visible_child_name('text')
                    outer._text_lbl.set_markup(linkify(msg.text))

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

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

            # 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_show_handler is not None:
            outer._loc_show_btn.disconnect(outer._loc_show_handler)
            outer._loc_show_handler = None
        if outer._loc_open_handler is not None:
            outer._loc_open_btn.disconnect(outer._loc_open_handler)
            outer._loc_open_handler = None
        outer._chat_item = None

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

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

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

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

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

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

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

    # ─── 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 _find_outer_at(self, x, y):
        """Find the outer box widget at the given ListView coordinates."""
        target = self._list_view.pick(x, y, Gtk.PickFlags.DEFAULT)
        while target is not None and target is not self._list_view:
            if hasattr(target, '_chat_item'):
                return target
            target = target.get_parent()
        return None

    def _on_list_right_click(self, gesture, n_press, x, y):
        outer = self._find_outer_at(x, y)
        if not outer:
            return
        item = outer._chat_item
        if (item and item.kind == 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 _on_context_popover_closed(self, popover):
        self._context_menu_open = False
        self._is_sticky = self._is_at_bottom()

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

    def _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)

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

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

    def _on_share_contact(self, action, param):
        if not self._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)

    # ─── 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 _restore_scroll_position(self, value):
        adj = self._scroll.get_vadjustment()
        upper = adj.get_upper() - adj.get_page_size()
        adj.set_value(min(value, upper))
        self._restoring_scroll = False
        self._is_sticky = self._is_at_bottom()
        self._scroll_down_revealer.set_reveal_child(not self._is_sticky)
        return False

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

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

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

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

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

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

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

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

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

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

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

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

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

    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)
