"""PyQt6 GUI client for encrypted chat."""

import asyncio
import json
import logging
import os
from collections import OrderedDict

logger = logging.getLogger(__name__)
import re
import sys
from functools import partial

from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer, QUrl, QSize, QRect, QPoint, QPointF
from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
    QLineEdit, QLabel, QListWidget, QListWidgetItem, QTextEdit,
    QSplitter, QMessageBox, QInputDialog, QMenu, QStackedWidget,
    QDialog, QFileDialog, QScrollArea, QFrame, QSystemTrayIcon,
    QSizePolicy, QStyledItemDelegate,
)
from PyQt6.QtGui import QFont, QFontMetricsF, QAction, QPixmap, QImage, QDesktopServices, QIcon, QPainter, QColor, QBrush, QPen, QShortcut, QKeySequence
from PyQt6.QtWidgets import QGraphicsDropShadowEffect
from PyQt6.QtWidgets import QStyle

from chat_core import ChatClient, IdentityKeyChanged
from theme import ThemeManager, c, qss, tm

# H10: Image validation limits
MAX_IMAGE_DATA_SIZE = 10 * 1024 * 1024  # 10 MB max raw image data
MAX_IMAGE_DIMENSION = 8192  # 8K pixels max


def _safe_load_image(data: bytes) -> QImage | None:
    """Load image with size and dimension validation (H10)."""
    if not data or len(data) > MAX_IMAGE_DATA_SIZE:
        return None
    qimg = QImage.fromData(data)
    if qimg.isNull():
        return None
    if qimg.width() > MAX_IMAGE_DIMENSION or qimg.height() > MAX_IMAGE_DIMENSION:
        return None
    return qimg


def _safe_filename(name: str, default: str = "file") -> str:
    """Sanitize filename — strip path components, prevent traversal (H11)."""
    name = os.path.basename(name)
    name = name.replace("\x00", "")
    return name if name else default


_AVATAR_CACHE_MAX = 512  # max cached avatar pixmaps per cache


class _LRUPixmapCache:
    """Simple LRU cache for QPixmap objects with a fixed max size."""

    def __init__(self, maxsize: int = _AVATAR_CACHE_MAX):
        self._data: OrderedDict[str, QPixmap] = OrderedDict()
        self._maxsize = maxsize

    def get(self, key: str) -> QPixmap | None:
        if key in self._data:
            self._data.move_to_end(key)
            return self._data[key]
        return None

    def put(self, key: str, value: QPixmap) -> None:
        if key in self._data:
            self._data.move_to_end(key)
            self._data[key] = value
        else:
            self._data[key] = value
            if len(self._data) > self._maxsize:
                self._data.popitem(last=False)

    def __contains__(self, key: str) -> bool:
        return key in self._data

    def __getitem__(self, key: str) -> QPixmap:
        self._data.move_to_end(key)
        return self._data[key]

    def __setitem__(self, key: str, value: QPixmap) -> None:
        self.put(key, value)

    def clear(self) -> None:
        self._data.clear()


# URL regex: matches http:// and https:// URLs in raw (not-yet-escaped) text
_URL_RE = re.compile(
    r'(https?://[^\s<>"\')\]]+)',
    re.IGNORECASE,
)
_URL_TRAILING_PUNCT = re.compile(r'[.,;:!?]+$')


def _linkify_urls(raw_text: str, https_color: str | None = None,
                   http_color: str | None = None) -> str:
    """HTML-escape text and convert URLs into clickable <a> tags.

    HTTPS links get blue styling. HTTP links get orange + unlock icon warning.
    Processes raw (unescaped) text — returns HTML-safe string.
    """
    _https = https_color or c().link_https
    _http = http_color or c().link_http

    def _esc(s):
        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

    parts = _URL_RE.split(raw_text)
    result = []
    for i, part in enumerate(parts):
        if i % 2 == 1:
            # URL match — strip trailing sentence punctuation
            trail_m = _URL_TRAILING_PUNCT.search(part)
            if trail_m:
                url = part[:trail_m.start()]
                trail = part[trail_m.start():]
            else:
                url = part
                trail = ""
            url_esc = _esc(url)
            if url.lower().startswith("http://"):
                result.append(
                    f'<a href="{url_esc}" style="color:{_http}; text-decoration:underline;">'
                    f'\U0001f513 {url_esc}</a>'
                )
            else:
                result.append(
                    f'<a href="{url_esc}" style="color:{_https}; text-decoration:underline;">'
                    f'{url_esc}</a>'
                )
            if trail:
                result.append(_esc(trail))
        else:
            result.append(_esc(part))
    return "".join(result)


def setup_logging():
    level_name = os.getenv("LOG_LEVEL", "WARNING").upper()
    level = getattr(logging, level_name, logging.WARNING)
    logging.basicConfig(level=level, format="%(levelname)s: %(message)s")



MAX_INPUT_CHARS = int(os.getenv("MAX_INPUT_CHARS", "2000"))

# Custom item data roles for conversation list delegate
ROLE_CONV_ID = Qt.ItemDataRole.UserRole
ROLE_DISPLAY_NAME = Qt.ItemDataRole.UserRole + 1
ROLE_PREVIEW = Qt.ItemDataRole.UserRole + 2      # last message preview text
ROLE_TIMESTAMP = Qt.ItemDataRole.UserRole + 3     # last message relative time
ROLE_UNREAD = Qt.ItemDataRole.UserRole + 4        # int unread count
ROLE_IS_FAV = Qt.ItemDataRole.UserRole + 5        # bool
ROLE_AVATAR = Qt.ItemDataRole.UserRole + 6        # QPixmap (circular, 44px)
ROLE_VERIFIED = Qt.ItemDataRole.UserRole + 7      # str: "verified", "trusted", "" (DMs only)
ROLE_RECEIPT = Qt.ItemDataRole.UserRole + 8       # str: "read", "delivered", "sent", "" (own msgs only)


def _relative_time(ts: str) -> str:
    """Convert ISO timestamp to relative time string for conversation list."""
    if not ts or len(ts) < 16:
        return ""
    try:
        from datetime import datetime, timezone
        # Parse "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS"
        clean = ts.replace("T", " ")[:19]
        msg_time = datetime.strptime(clean, "%Y-%m-%d %H:%M:%S").replace(
            tzinfo=timezone.utc
        )
        now = datetime.now(timezone.utc)
        diff = now - msg_time
        secs = int(diff.total_seconds())
        if secs < 60:
            return "now"
        if secs < 3600:
            return f"{secs // 60}m"
        if secs < 86400:
            return f"{secs // 3600}h"
        days = secs // 86400
        if days == 1:
            return "Yesterday"
        if days < 7:
            return msg_time.strftime("%a")  # Mon, Tue, ...
        return msg_time.strftime("%d/%m")
    except Exception:
        return ts[11:16] if len(ts) >= 16 else ""


def _format_msg_time(ts: str) -> str:
    """Format timestamp for message bubbles: HH:MM today, 'Yesterday HH:MM',
    'Mon HH:MM' this week, 'DD.MM. HH:MM' older."""
    if not ts or len(ts) < 16:
        return ""
    try:
        from datetime import datetime, timezone
        clean = ts.replace("T", " ")[:19]
        msg_time = datetime.strptime(clean, "%Y-%m-%d %H:%M:%S").replace(
            tzinfo=timezone.utc
        )
        now = datetime.now(timezone.utc)
        hhmm = msg_time.strftime("%H:%M")
        if msg_time.date() == now.date():
            return hhmm
        diff_days = (now.date() - msg_time.date()).days
        if diff_days == 1:
            return f"Yesterday {hhmm}"
        if diff_days < 7:
            return f"{msg_time.strftime('%a')} {hhmm}"
        if msg_time.year == now.year:
            return f"{msg_time.day}.{msg_time.month}. {hhmm}"
        return f"{msg_time.day}.{msg_time.month}.{msg_time.year} {hhmm}"
    except Exception:
        return ts[11:16] if len(ts) >= 16 else ""


class ConversationDelegate(QStyledItemDelegate):
    """Custom delegate that paints Signal/Telegram-style conversation rows."""

    ITEM_HEIGHT = 68
    AVATAR_SIZE = 44
    BADGE_SIZE = 20
    HPAD = 10
    VPAD = 10

    def sizeHint(self, option, index):
        return QSize(option.rect.width(), self.ITEM_HEIGHT)

    def paint(self, painter, option, index):
        painter.save()
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        t = c()

        rect = option.rect
        is_selected = bool(option.state & QStyle.StateFlag.State_Selected)
        is_hover = bool(option.state & QStyle.StateFlag.State_MouseOver)

        # Background
        if is_selected:
            painter.fillRect(rect, QColor(t.bg_selected))
        elif is_hover:
            painter.fillRect(rect, QColor(t.bg_hover))

        # Data from item roles
        name = index.data(ROLE_DISPLAY_NAME) or ""
        preview = index.data(ROLE_PREVIEW) or ""
        timestamp = index.data(ROLE_TIMESTAMP) or ""
        unread = index.data(ROLE_UNREAD) or 0
        is_fav = index.data(ROLE_IS_FAV) or False
        avatar_pix = index.data(ROLE_AVATAR)
        verified = index.data(ROLE_VERIFIED) or ""

        x = rect.x() + self.HPAD
        y = rect.y() + self.VPAD
        avail_w = rect.width() - 2 * self.HPAD

        # -- Avatar (left) --
        av_y = rect.y() + (rect.height() - self.AVATAR_SIZE) // 2
        if avatar_pix and not avatar_pix.isNull():
            painter.drawPixmap(x, av_y, self.AVATAR_SIZE, self.AVATAR_SIZE,
                               avatar_pix)
        else:
            painter.setBrush(QBrush(QColor(t.bg_secondary)))
            painter.setPen(Qt.PenStyle.NoPen)
            painter.drawEllipse(x, av_y, self.AVATAR_SIZE, self.AVATAR_SIZE)
            painter.setPen(QColor(t.text_muted))
            f = QFont()
            f.setPointSize(14)
            f.setBold(True)
            painter.setFont(f)
            painter.drawText(
                QRect(x, av_y, self.AVATAR_SIZE, self.AVATAR_SIZE),
                Qt.AlignmentFlag.AlignCenter,
                name[0].upper() if name else "?",
            )

        text_x = x + self.AVATAR_SIZE + 10
        text_w = avail_w - self.AVATAR_SIZE - 10

        # -- Timestamp (top-right) --
        ts_w = 50
        painter.setPen(QColor(t.text_muted))
        ts_font = QFont()
        ts_font.setPointSize(8)
        painter.setFont(ts_font)
        ts_rect = QRect(
            rect.x() + rect.width() - self.HPAD - ts_w,
            y, ts_w, 18,
        )
        painter.drawText(ts_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, timestamp)

        name_w = text_w - ts_w - 8

        # -- Name (line 1) --
        name_font = QFont()
        name_font.setPointSize(10)
        if unread > 0:
            name_font.setBold(True)
        painter.setFont(name_font)
        painter.setPen(QColor(t.text_primary))
        display_name = name
        if is_fav:
            display_name = f"\u2605 {name}"
        # Elide name to fit
        fm = painter.fontMetrics()
        elided_name = fm.elidedText(display_name, Qt.TextElideMode.ElideRight, name_w)
        painter.drawText(text_x, y, name_w, 22,
                         Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
                         elided_name)

        # -- Verification badge (after name) --
        if verified == "verified":
            name_text_w = fm.horizontalAdvance(elided_name)
            badge_x = text_x + name_text_w + 4
            badge_y_center = y + 11
            painter.setPen(Qt.PenStyle.NoPen)
            painter.setBrush(QBrush(QColor(t.success)))
            painter.drawEllipse(badge_x, badge_y_center - 5, 10, 10)
            # Checkmark inside circle
            painter.setPen(QPen(QColor(t.bg_primary), 1.5))
            painter.drawLine(badge_x + 2, badge_y_center, badge_x + 4, badge_y_center + 2)
            painter.drawLine(badge_x + 4, badge_y_center + 2, badge_x + 8, badge_y_center - 3)

        # -- Preview (line 2) --
        preview_y = y + 24
        preview_font = QFont()
        preview_font.setPointSize(9)
        painter.setFont(preview_font)
        preview_w = text_w - (self.BADGE_SIZE + 8 if unread > 0 else 0)
        fm2 = painter.fontMetrics()
        receipt = index.data(ROLE_RECEIPT) or ""
        preview_x = text_x
        if receipt:
            check_color = QColor(t.success) if receipt == "read" else QColor(t.text_muted)
            painter.setPen(check_color)
            single_w = fm2.horizontalAdvance("\u2713")
            overlap = single_w * 0.4
            cy = preview_y + 10  # vertical center of 20px row
            # First check
            painter.drawText(int(preview_x), preview_y, int(single_w), 20,
                             Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
                             "\u2713")
            if receipt in ("delivered", "read"):
                # Second check, overlapping
                x2 = preview_x + single_w - overlap
                painter.drawText(int(x2), preview_y, int(single_w), 20,
                                 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
                                 "\u2713")
                total_w = single_w * 2 - overlap + 4
            else:
                total_w = single_w + 4
            preview_x += total_w
            preview_w -= total_w
        preview_x = int(preview_x)
        preview_w = int(preview_w)
        painter.setPen(QColor(t.text_muted))
        elided_preview = fm2.elidedText(preview, Qt.TextElideMode.ElideRight, preview_w)
        painter.drawText(preview_x, preview_y, preview_w, 20,
                         Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
                         elided_preview)

        # -- Unread badge (bottom-right) --
        if unread > 0:
            badge_x = rect.x() + rect.width() - self.HPAD - self.BADGE_SIZE
            badge_y = preview_y + 1
            painter.setBrush(QBrush(QColor(t.accent)))
            painter.setPen(Qt.PenStyle.NoPen)
            painter.drawRoundedRect(badge_x, badge_y, self.BADGE_SIZE, self.BADGE_SIZE, 10, 10)
            painter.setPen(QColor(t.accent_text))
            badge_font = QFont()
            badge_font.setPointSize(7)
            badge_font.setBold(True)
            painter.setFont(badge_font)
            badge_text = str(unread) if unread < 100 else "99+"
            painter.drawText(
                QRect(badge_x, badge_y, self.BADGE_SIZE, self.BADGE_SIZE),
                Qt.AlignmentFlag.AlignCenter, badge_text,
            )

        # -- Bottom separator line --
        painter.setPen(QColor(t.separator))
        painter.drawLine(text_x, rect.bottom(), rect.right() - self.HPAD, rect.bottom())

        painter.restore()


class _ReceiptFooter(QWidget):
    """Tiny widget that draws timestamp + receipt checkmarks with tight spacing."""

    def __init__(self, time_str: str, status: str,
                 time_color: str, check_color: str, read_color: str,
                 parent=None):
        super().__init__(parent)
        self._time = time_str
        self._status = status          # "", "sent", "delivered", "read"
        self._time_color = QColor(time_color)
        self._check_color = QColor(check_color)
        self._read_color = QColor(read_color)
        self._font = QFont()
        self._font.setPointSize(8)
        fm = QFontMetricsF(self._font)
        tw = fm.horizontalAdvance(self._time + " ")
        cw = fm.horizontalAdvance("\u2713")
        # 2nd check overlaps 1st by 40% of its width
        overlap = cw * 0.4
        checks_w = 0.0
        if status == "sent":
            checks_w = cw
        elif status in ("delivered", "read"):
            checks_w = cw * 2 - overlap
        total_w = tw + checks_w + 2
        h = fm.height() + 2
        self.setFixedSize(int(total_w + 1), int(h + 1))

    def paintEvent(self, event):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.setFont(self._font)
        fm = QFontMetricsF(self._font)
        y_base = fm.ascent() + 1

        # Draw time
        p.setPen(self._time_color)
        p.drawText(QPointF(0, y_base), self._time)
        x = fm.horizontalAdvance(self._time) + 4

        if not self._status:
            p.end()
            return

        cw = fm.horizontalAdvance("\u2713")
        overlap = cw * 0.4
        color = self._read_color if self._status == "read" else self._check_color

        # First check
        p.setPen(color)
        p.drawText(QPointF(x, y_base), "\u2713")

        # Second check (tight overlap)
        if self._status in ("delivered", "read"):
            p.drawText(QPointF(x + cw - overlap, y_base), "\u2713")

        p.end()


class MessageInput(QTextEdit):
    """Multiline message input: Enter sends, Shift+Enter inserts newline."""
    send_requested = pyqtSignal()
    file_dropped = pyqtSignal(str)

    @staticmethod
    def _style_normal():
        t = c()
        return (
            f"QTextEdit {{ background-color: {t.bg_secondary}; border: 1px solid {t.border}; "
            f"border-radius: 18px; padding: 8px 14px; color: {t.text_primary}; }}"
            f"QTextEdit:focus {{ border: 1px solid {t.border_focus}; }}"
        )

    @staticmethod
    def _style_drop():
        t = c()
        return (
            f"QTextEdit {{ background-color: {t.bg_secondary}; border: 2px dashed {t.accent}; "
            f"border-radius: 18px; padding: 8px 14px; color: {t.text_primary}; }}"
        )

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAcceptRichText(False)
        self.setPlaceholderText("Type a message...")
        self.setMinimumHeight(52)
        self.setMaximumHeight(120)
        self.setAcceptDrops(True)
        self.drop_enabled = False
        self.setStyleSheet(self._style_normal())
        self.textChanged.connect(self._auto_resize)
        # Tight line spacing — set on document default cursor format
        from PyQt6.QtGui import QTextBlockFormat
        fmt = QTextBlockFormat()
        fmt.setTopMargin(0)
        fmt.setBottomMargin(0)
        fmt.setLineHeight(0, 0)  # 0 = SingleHeight
        self._block_fmt = fmt
        # Apply to default block format
        cursor = self.textCursor()
        cursor.setBlockFormat(fmt)
        self.setTextCursor(cursor)

    def _auto_resize(self):
        doc_height = int(self.document().size().height()) + 16  # padding
        new_h = max(52, min(doc_height, 120))
        self.setFixedHeight(new_h)

    def keyPressEvent(self, event):
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
                # Insert plain newline instead of new paragraph
                self.textCursor().insertText("\n")
                return
            else:
                self.send_requested.emit()
                return
        super().keyPressEvent(event)

    def dragEnterEvent(self, event):
        if not self.drop_enabled:
            event.ignore()
            return
        if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()):
            event.acceptProposedAction()
            self.setStyleSheet(self._style_drop())
        else:
            super().dragEnterEvent(event)

    def dragMoveEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
        else:
            super().dragMoveEvent(event)

    def dragLeaveEvent(self, event):
        self.setStyleSheet(self._style_normal())
        super().dragLeaveEvent(event)

    def dropEvent(self, event):
        self.setStyleSheet(self._style_normal())
        if event.mimeData().hasUrls():
            for url in event.mimeData().urls():
                if url.isLocalFile():
                    self.file_dropped.emit(url.toLocalFile())
            event.acceptProposedAction()
        else:
            super().dropEvent(event)


class AsyncBridge(QThread):
    """Runs asyncio event loop in a background thread, emits Qt signals."""
    connected = pyqtSignal()
    connection_error = pyqtSignal(str)
    login_result = pyqtSignal(bool, str)
    register_result = pyqtSignal(bool, str)
    conversations_loaded = pyqtSignal(list)
    messages_loaded = pyqtSignal(str, list)  # conv_id, messages
    older_messages_loaded = pyqtSignal(str, list)  # conv_id, older messages
    message_sent = pyqtSignal(bool, str)
    message_sent_payload = pyqtSignal(str, dict)  # conv_id, message dict (for local append)
    new_notification = pyqtSignal(dict)  # decrypted payload
    pairing_code = pyqtSignal(str)
    pairing_complete = pyqtSignal(bool, str)
    add_member_result = pyqtSignal(bool, str)
    remove_member_result = pyqtSignal(bool, str)
    authorize_result = pyqtSignal(bool, str)
    rotate_result = pyqtSignal(bool, str)
    reencrypt_status = pyqtSignal(str)
    messages_read_notification = pyqtSignal(dict)
    message_delivered_notification = pyqtSignal(dict)
    message_deleted_notification = pyqtSignal(dict)
    image_sent = pyqtSignal(bool, str)
    image_downloaded = pyqtSignal(str, bytes)  # file_id, decrypted bytes
    delete_message_result = pyqtSignal(bool, str)
    reconnected = pyqtSignal()
    conversation_updated = pyqtSignal()
    connection_state_changed = pyqtSignal(str)  # "connected", "disconnected", "reconnecting"
    profile_loaded = pyqtSignal(dict)
    profile_updated = pyqtSignal(bool, str)
    avatar_loaded = pyqtSignal(str, bytes)  # user_id, avatar_bytes
    online_status_changed = pyqtSignal(str, bool)  # user_id, is_online
    online_users_loaded = pyqtSignal(list)  # list of user_ids
    invitations_loaded = pyqtSignal(list)  # list of invitation dicts
    invitation_result = pyqtSignal(bool, str)  # ok, message
    invitation_received = pyqtSignal(dict)  # invitation notification data
    group_avatar_loaded = pyqtSignal(str, bytes)  # conv_id, avatar_bytes
    group_avatar_updated = pyqtSignal(bool, str)  # ok, message
    session_reset_notification = pyqtSignal(str, str)  # from_user_id, from_device_id
    reaction_result = pyqtSignal(bool, str)        # ok, message
    reaction_notification = pyqtSignal(dict)        # {message_id, conversation_id, user_id, reaction, action}
    pin_notification = pyqtSignal(dict)             # {message_id, conversation_id, user_id, action=pin}
    unpin_notification = pyqtSignal(dict)           # {message_id, conversation_id, user_id, action=unpin}
    pinned_messages_loaded = pyqtSignal(str, list)  # conv_id, list of pinned msg dicts
    forward_result = pyqtSignal(bool, str)          # ok, message
    key_change_warning = pyqtSignal(str, str, str, bool, bytes)  # user_id, username, old_key_hex, was_verified, new_key_bytes
    password_changed = pyqtSignal(bool, str)  # ok, message
    username_changed = pyqtSignal(bool, str)  # ok, message

    def __init__(self):
        super().__init__()
        self.client = ChatClient()
        self.loop: asyncio.AbstractEventLoop | None = None
        self._running = True
        self.client._reencrypt_progress_cb = self._emit_reencrypt_status
        self.client._key_change_cb = self._emit_key_change_warning
        self._ready: asyncio.Event | None = None
        self._avatar_inflight: set[str] = set()
        self._group_avatar_inflight: set[str] = set()
        self._invitations_inflight = False

    def _emit_reencrypt_status(self, message: str):
        self.reencrypt_status.emit(message)

    def _emit_key_change_warning(self, user_id: str, username: str, old_key_hex: str, was_verified: bool, new_key_bytes: bytes = b""):
        self.key_change_warning.emit(user_id, username, old_key_hex, was_verified, new_key_bytes)

    def run(self):
        if sys.platform == "win32":
            self.loop = asyncio.SelectorEventLoop()
        else:
            self.loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self.loop)
        self._ready = asyncio.Event()
        try:
            self.loop.run_until_complete(self._run())
        except Exception as e:
            logger.error("AsyncBridge loop crashed: %s", e, exc_info=True)
        finally:
            self.loop.close()

    async def _run(self):
        try:
            await self.client.connect()
            self.client._listener_task = asyncio.create_task(self.client._background_listener())
            if self._ready:
                self._ready.set()
            self.connected.emit()
            self.connection_state_changed.emit("connected")
        except Exception as e:
            self.connection_error.emit(str(e))
            return

        # Process notifications
        await self._notification_loop()

    async def _notification_loop(self):
        while self._running:
            try:
                # Check if listener task died (connection lost)
                if (self.client._listener_task and self.client._listener_task.done()
                        and not self.client.connected):
                    self.connection_state_changed.emit("disconnected")
                    if self.client.session:
                        await self._auto_reconnect()
                    continue

                notif = await asyncio.wait_for(
                    self.client._notification_queue.get(), timeout=0.5
                )
                notif_type = notif.get("type", "")
                data = notif.get("data", {})
                if notif_type in ("conversation_created", "member_added", "member_removed",
                                  "conversation_renamed"):
                    self.conversation_updated.emit()
                elif notif_type == "group_invitation":
                    self.invitation_received.emit(data)
                elif notif_type == "user_online":
                    self.online_status_changed.emit(data.get("user_id", ""), True)
                elif notif_type == "user_offline":
                    self.online_status_changed.emit(data.get("user_id", ""), False)
                elif notif_type == "online_users":
                    self.online_users_loaded.emit(data.get("user_ids", []))
                elif notif_type == "messages_read":
                    self.messages_read_notification.emit(data)
                elif notif_type == "message_delivered":
                    self.message_delivered_notification.emit(data)
                elif notif_type == "message_deleted":
                    self.message_deleted_notification.emit(data)
                elif notif_type == "session_reset":
                    from_uid = data.get("from_user_id", "")
                    from_did = data.get("from_device_id", "")
                    self.client.handle_session_reset_notification(from_uid, from_did or None)
                    self.session_reset_notification.emit(from_uid, from_did)
                elif notif_type == "username_changed":
                    self.conversation_updated.emit()
                elif notif_type == "message_reacted":
                    self.reaction_notification.emit(data)
                elif notif_type == "message_pinned":
                    self.pin_notification.emit(data)
                elif notif_type == "message_unpinned":
                    self.unpin_notification.emit(data)
                elif notif_type == "new_message":
                    try:
                        payload = self.client.decrypt_notification(data)
                    except IdentityKeyChanged as ikc:
                        cached = self.client._user_cache.get(ikc.user_id)
                        uname = cached.get("username", "") if cached else ""
                        old_hex = ""
                        known = self.client._known_identity_keys.get(ikc.user_id)
                        if known:
                            old_hex = known.get("identity_key", "")
                        was_verified = ikc.status == "changed_verified"
                        self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes)
                        continue
                    if payload:
                        self.new_notification.emit(payload)
                    # None = control message (e.g. sender key distribution), skip silently
            except asyncio.TimeoutError:
                continue
            except Exception as e:
                logger.error("Notification loop exception: %s", e, exc_info=True)
                break

    async def _auto_reconnect(self):
        """Auto-reconnect with exponential backoff."""
        delay = 1
        while self._running and not self.client.connected:
            self.connection_state_changed.emit("reconnecting")
            try:
                await self.client.reconnect()
                if self.client.connected and self.client.session:
                    self.connection_state_changed.emit("connected")
                    self.conversation_updated.emit()
                    return
                if self.client.login_rejected:
                    self.connection_state_changed.emit("revoked")
                    return
            except Exception:
                pass
            await asyncio.sleep(delay)
            delay = min(delay * 2, 30)

    def schedule(self, coro):
        """Schedule a coroutine on the asyncio loop from the Qt thread."""
        if self.loop and self.loop.is_running():
            asyncio.run_coroutine_threadsafe(coro, self.loop)
        else:
            # Avoid "coroutine was never awaited" warnings if loop is down.
            try:
                coro.close()
            except Exception:
                pass

    async def _do_register(self, username, password, email):
        if self._ready:
            await self._ready.wait()
        ok, code_or_msg = await self.client.register(username, password, email=email)
        self.register_result.emit(ok, code_or_msg)

    async def _do_login(self, email, password):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.login(email, password)
        self.login_result.emit(ok, msg)

    async def _do_logout(self):
        if self._ready:
            self._ready.clear()
        try:
            await self.client.close()
        except Exception:
            pass
        self.client = ChatClient()
        self.client._reencrypt_progress_cb = self._emit_reencrypt_status
        try:
            await self.client.connect()
            self.client._listener_task = asyncio.create_task(self.client._background_listener())
            if self._ready:
                self._ready.set()
            self.reconnected.emit()
        except Exception as e:
            self.connection_error.emit(str(e))

    async def _do_load_conversations(self):
        if self._ready:
            await self._ready.wait()
        convs = await self.client.list_conversations()
        self.conversations_loaded.emit(convs)

    async def _do_load_messages(self, conv_id):
        if self._ready:
            await self._ready.wait()
        msgs = await self.client.get_messages(conv_id)
        self.messages_loaded.emit(conv_id, msgs)

    async def _do_load_older_messages(self, conv_id, offset):
        if self._ready:
            await self._ready.wait()
        msgs = await self.client.get_messages(conv_id, limit=50, offset=offset)
        self.older_messages_loaded.emit(conv_id, msgs)

    async def _do_send_message(self, conv_id, text, members, reply_to=None):
        if self._ready:
            await self._ready.wait()
        try:
            ok, result = await self.client.send_message(conv_id, text, members, reply_to=reply_to)
        except IdentityKeyChanged as ikc:
            cached = self.client._user_cache.get(ikc.user_id)
            uname = cached.get("username", "") if cached else ""
            old_hex = ""
            known = self.client._known_identity_keys.get(ikc.user_id)
            if known:
                old_hex = known.get("identity_key", "")
            was_verified = ikc.status == "changed_verified"
            self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes)
            self.message_sent.emit(False, "Identity key changed — accept new key first.")
            return
        except Exception as e:
            logger.error("send_message exception: %s", e, exc_info=True)
            self.message_sent.emit(False, str(e))
            return
        if ok and isinstance(result, dict):
            self.message_sent.emit(True, "Message sent.")
            self.message_sent_payload.emit(conv_id, result)
        else:
            self.message_sent.emit(ok, result if isinstance(result, str) else "Message sent.")

    async def _do_find_or_create_and_send(self, username, text):
        if self._ready:
            await self._ready.wait()
        try:
            conv_id, msg = await self.client.find_or_create_conversation(username)
            if not conv_id:
                self.message_sent.emit(False, msg)
                return
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)
            members = []
            for cv in convs:
                if cv["conversation_id"] == conv_id:
                    members = cv["members"]
                    break
            ok, result = await self.client.send_message(conv_id, text, members)
            if ok and isinstance(result, dict):
                self.message_sent.emit(True, "Message sent.")
                self.message_sent_payload.emit(conv_id, result)
            else:
                self.message_sent.emit(ok, result if isinstance(result, str) else "Message sent.")
        except IdentityKeyChanged as ikc:
            cached = self.client._user_cache.get(ikc.user_id)
            uname = cached.get("username", "") if cached else ""
            old_hex = ""
            known = self.client._known_identity_keys.get(ikc.user_id)
            if known:
                old_hex = known.get("identity_key", "")
            was_verified = ikc.status == "changed_verified"
            self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes)
            self.message_sent.emit(False, "Identity key changed — accept new key first.")
        except Exception as e:
            logger.error("find_or_create_and_send exception: %s", e, exc_info=True)
            self.message_sent.emit(False, str(e))

    async def _do_create_group(self, members, name=None):
        if self._ready:
            await self._ready.wait()
        conv_id, msg = await self.client.create_conversation(members, name=name)
        if conv_id:
            self.message_sent.emit(True, f"Group created")
        else:
            self.message_sent.emit(False, msg)
        convs = await self.client.list_conversations()
        self.conversations_loaded.emit(convs)

    async def _do_link_device(self, username, password):
        if self._ready:
            await self._ready.wait()
        ok, code_or_msg = await self.client.pairing_start(username)
        if not ok:
            self.pairing_complete.emit(False, code_or_msg)
            return
        code = code_or_msg
        self.pairing_code.emit(code)
        ok2, msg2 = await self.client.pairing_wait(code, username, password)
        self.pairing_complete.emit(ok2, msg2)

    async def _do_authorize_device(self, code):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.authorize_device(code)
        self.authorize_result.emit(ok, msg)

    async def _do_rotate_keys(self, username, password):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.rotate_keys(username, password)
        self.rotate_result.emit(ok, msg)

    def do_register(self, username, password, email):
        self.schedule(self._do_register(username, password, email))

    def do_login(self, email, password):
        self.schedule(self._do_login(email, password))

    def load_conversations(self):
        self.schedule(self._do_load_conversations())

    def load_messages(self, conv_id):
        self.schedule(self._do_load_messages(conv_id))

    def load_older_messages(self, conv_id, offset):
        self.schedule(self._do_load_older_messages(conv_id, offset))

    def send_message(self, conv_id, text, members, reply_to=None):
        self.schedule(self._do_send_message(conv_id, text, members, reply_to))

    def send_new_chat(self, username, text):
        self.schedule(self._do_find_or_create_and_send(username, text))

    def create_group(self, members, name=None):
        self.schedule(self._do_create_group(members, name=name))

    async def _do_add_member(self, conv_id, email):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.add_member(conv_id, email)
        self.add_member_result.emit(ok, msg)
        if ok:
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def add_member(self, conv_id, email):
        self.schedule(self._do_add_member(conv_id, email))

    async def _do_remove_member(self, conv_id, user_id):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.remove_member(conv_id, user_id)
        self.remove_member_result.emit(ok, msg)
        if ok:
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def remove_member(self, conv_id, user_id):
        self.schedule(self._do_remove_member(conv_id, user_id))

    group_left = pyqtSignal(bool, str)
    group_renamed = pyqtSignal(bool, str)
    conversation_deleted = pyqtSignal(bool, str)

    async def _do_leave_group(self, conv_id):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.leave_group(conv_id)
        self.group_left.emit(ok, msg)
        if ok:
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def leave_group(self, conv_id):
        self.schedule(self._do_leave_group(conv_id))

    async def _do_rename_conversation(self, conv_id, name):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.rename_conversation(conv_id, name)
        self.group_renamed.emit(ok, msg)
        if ok:
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def rename_conversation(self, conv_id, name):
        self.schedule(self._do_rename_conversation(conv_id, name))

    async def _do_delete_conversation(self, conv_id):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.delete_conversation(conv_id)
        self.conversation_deleted.emit(ok, msg)
        if ok:
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def delete_conversation(self, conv_id):
        self.schedule(self._do_delete_conversation(conv_id))

    def link_device(self, username, password):
        self.schedule(self._do_link_device(username, password))

    def authorize_device(self, code):
        self.schedule(self._do_authorize_device(code))

    def rotate_keys(self, username, password):
        self.schedule(self._do_rotate_keys(username, password))

    async def _do_change_password(self, old_password, new_password):
        if self._ready:
            await self._ready.wait()
        ok, msg = self.client.change_password(old_password, new_password)
        self.password_changed.emit(ok, msg)

    def change_password(self, old_password, new_password):
        self.schedule(self._do_change_password(old_password, new_password))

    async def _do_change_username(self, new_username):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.change_username(new_username)
        self.username_changed.emit(ok, msg)

    def change_username(self, new_username):
        self.schedule(self._do_change_username(new_username))

    async def _do_delete_message(self, message_id):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.delete_message(message_id)
        self.delete_message_result.emit(ok, msg)

    def delete_message(self, message_id):
        self.schedule(self._do_delete_message(message_id))

    def reset_session(self, peer_user_id, peer_device_id=None):
        self.schedule(self.client.reset_session(peer_user_id, peer_device_id))

    async def _do_send_image(self, conv_id, image_path, members, reply_to=None):
        if self._ready:
            await self._ready.wait()
        try:
            ok, result = await self.client.send_image(conv_id, image_path, members, reply_to=reply_to)
        except IdentityKeyChanged as ikc:
            cached = self.client._user_cache.get(ikc.user_id)
            uname = cached.get("username", "") if cached else ""
            old_hex = ""
            known = self.client._known_identity_keys.get(ikc.user_id)
            if known:
                old_hex = known.get("identity_key", "")
            was_verified = ikc.status == "changed_verified"
            self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes)
            self.image_sent.emit(False, "Identity key changed — accept new key first.")
            return
        except Exception as e:
            logger.error("send_image exception: %s", e, exc_info=True)
            self.image_sent.emit(False, str(e))
            return
        if ok and isinstance(result, dict):
            self.image_sent.emit(True, "Image sent.")
            self.message_sent_payload.emit(conv_id, result)
        else:
            self.image_sent.emit(ok, result if isinstance(result, str) else "Image sent.")

    def send_image(self, conv_id, image_path, members, reply_to=None):
        self.schedule(self._do_send_image(conv_id, image_path, members, reply_to))

    async def _do_download_image(self, file_id, image_info):
        if self._ready:
            await self._ready.wait()
        data = await self.client.download_image(file_id, image_info)
        if data:
            self.image_downloaded.emit(file_id, data)

    def download_image(self, file_id, image_info):
        self.schedule(self._do_download_image(file_id, image_info))

    file_sent = pyqtSignal(bool, str)
    file_downloaded = pyqtSignal(bytes, dict)  # decrypted_bytes, file_info

    async def _do_send_file(self, conv_id, file_path, members, reply_to=None):
        if self._ready:
            await self._ready.wait()
        try:
            ok, result = await self.client.send_file(conv_id, file_path, members, reply_to=reply_to)
        except IdentityKeyChanged as ikc:
            cached = self.client._user_cache.get(ikc.user_id)
            uname = cached.get("username", "") if cached else ""
            old_hex = ""
            known = self.client._known_identity_keys.get(ikc.user_id)
            if known:
                old_hex = known.get("identity_key", "")
            was_verified = ikc.status == "changed_verified"
            self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes)
            self.file_sent.emit(False, "Identity key changed — accept new key first.")
            return
        except Exception as e:
            logger.error("send_file exception: %s", e, exc_info=True)
            self.file_sent.emit(False, str(e))
            return
        if ok and isinstance(result, dict):
            self.file_sent.emit(True, "File sent.")
            self.message_sent_payload.emit(conv_id, result)
        else:
            self.file_sent.emit(ok, result if isinstance(result, str) else "File sent.")

    def send_file(self, conv_id, file_path, members, reply_to=None):
        self.schedule(self._do_send_file(conv_id, file_path, members, reply_to))

    async def _do_download_file(self, file_id, file_info):
        if self._ready:
            await self._ready.wait()
        data = await self.client.download_file(file_id, file_info)
        if data:
            self.file_downloaded.emit(data, file_info)

    def download_file(self, file_id, file_info):
        self.schedule(self._do_download_file(file_id, file_info))

    async def _do_get_profile(self, user_id=None):
        if self._ready:
            await self._ready.wait()
        profile = await self.client.get_profile(user_id)
        if profile:
            self.profile_loaded.emit(profile)

    def get_profile(self, user_id=None):
        self.schedule(self._do_get_profile(user_id))

    async def _do_update_profile(self, **fields):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.update_profile(**fields)
        self.profile_updated.emit(ok, msg)

    def update_profile(self, **fields):
        self.schedule(self._do_update_profile(**fields))

    async def _do_update_avatar(self, image_data):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.update_avatar(image_data)
        self.profile_updated.emit(ok, msg)

    def update_avatar(self, image_data):
        self.schedule(self._do_update_avatar(image_data))

    async def _do_get_avatar(self, user_id):
        if self._ready:
            await self._ready.wait()
        if not user_id or user_id in self._avatar_inflight:
            return
        self._avatar_inflight.add(user_id)
        try:
            data = await self.client.get_avatar(user_id)
            if data:
                self.avatar_loaded.emit(user_id, data)
        finally:
            self._avatar_inflight.discard(user_id)

    def get_avatar(self, user_id):
        self.schedule(self._do_get_avatar(user_id))

    async def _do_list_invitations(self):
        if self._ready:
            await self._ready.wait()
        if self._invitations_inflight:
            return
        self._invitations_inflight = True
        try:
            invitations = await self.client.list_invitations()
            self.invitations_loaded.emit(invitations)
        finally:
            self._invitations_inflight = False

    def list_invitations(self):
        self.schedule(self._do_list_invitations())

    async def _do_accept_invitation(self, conv_id):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.accept_invitation(conv_id)
        self.invitation_result.emit(ok, msg)
        if ok:
            invitations = await self.client.list_invitations()
            self.invitations_loaded.emit(invitations)
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def accept_invitation(self, conv_id):
        self.schedule(self._do_accept_invitation(conv_id))

    async def _do_decline_invitation(self, conv_id):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.decline_invitation(conv_id)
        self.invitation_result.emit(ok, msg)
        if ok:
            invitations = await self.client.list_invitations()
            self.invitations_loaded.emit(invitations)

    def decline_invitation(self, conv_id):
        self.schedule(self._do_decline_invitation(conv_id))

    async def _do_update_group_avatar(self, conv_id, image_data):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.update_group_avatar(conv_id, image_data)
        self.group_avatar_updated.emit(ok, msg)
        if ok:
            convs = await self.client.list_conversations()
            self.conversations_loaded.emit(convs)

    def update_group_avatar(self, conv_id, image_data):
        self.schedule(self._do_update_group_avatar(conv_id, image_data))

    async def _do_get_group_avatar(self, conv_id):
        if self._ready:
            await self._ready.wait()
        if not conv_id or conv_id in self._group_avatar_inflight:
            return
        self._group_avatar_inflight.add(conv_id)
        try:
            data = await self.client.get_group_avatar(conv_id)
            if data:
                self.group_avatar_loaded.emit(conv_id, data)
        finally:
            self._group_avatar_inflight.discard(conv_id)

    def get_group_avatar(self, conv_id):
        self.schedule(self._do_get_group_avatar(conv_id))

    # --- Reactions, Pins, Forwarding ---

    async def _do_react_message(self, message_id, reaction, action):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.react_message(message_id, reaction, action)
        self.reaction_result.emit(ok, msg)

    def react_message(self, message_id, reaction, action="add"):
        self.schedule(self._do_react_message(message_id, reaction, action))

    async def _do_pin_message(self, message_id, conversation_id, action):
        if self._ready:
            await self._ready.wait()
        ok, msg = await self.client.pin_message(message_id, conversation_id, action)
        if not ok:
            self.reaction_result.emit(False, msg)

    def pin_message(self, message_id, conversation_id, action="pin"):
        self.schedule(self._do_pin_message(message_id, conversation_id, action))

    async def _do_get_pinned_messages(self, conv_id):
        if self._ready:
            await self._ready.wait()
        pinned = await self.client.get_pinned_messages(conv_id)
        self.pinned_messages_loaded.emit(conv_id, pinned)

    def get_pinned_messages(self, conv_id):
        self.schedule(self._do_get_pinned_messages(conv_id))

    async def _do_forward_message(self, target_conv_id, original_msg, target_members):
        if self._ready:
            await self._ready.wait()
        try:
            ok, result = await self.client.forward_message(target_conv_id, original_msg, target_members)
        except IdentityKeyChanged as ikc:
            cached = self.client._user_cache.get(ikc.user_id)
            uname = cached.get("username", "") if cached else ""
            old_hex = ""
            known = self.client._known_identity_keys.get(ikc.user_id)
            if known:
                old_hex = known.get("identity_key", "")
            was_verified = ikc.status == "changed_verified"
            self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes)
            self.forward_result.emit(False, "Identity key changed — accept new key first.")
            return
        except Exception as e:
            logger.error("forward_message exception: %s", e, exc_info=True)
            self.forward_result.emit(False, str(e))
            return
        if ok and isinstance(result, dict):
            self.forward_result.emit(True, "Message forwarded.")
            self.message_sent_payload.emit(target_conv_id, result)
        else:
            self.forward_result.emit(ok, result if isinstance(result, str) else "Forwarded.")

    def forward_message(self, target_conv_id, original_msg, target_members):
        self.schedule(self._do_forward_message(target_conv_id, original_msg, target_members))

    def logout(self):
        self.schedule(self._do_logout())

    def stop(self):
        self._running = False
        if self.loop:
            asyncio.run_coroutine_threadsafe(self.client.close(), self.loop)


def _make_frameless(dlg: QDialog, title_text: str = ""):
    """Configure a QDialog as frameless with custom title bar, rounded container,
    and drop shadow.  Returns a QVBoxLayout for the dialog content area —
    callers just add their widgets to the returned layout.

    Usage::

        dlg = QDialog(self)
        dlg.setMinimumWidth(380)
        content_layout = _make_frameless(dlg, "My Title")
        content_layout.addWidget(QLabel("Hello!"))
        dlg.exec()
    """
    dlg.setWindowFlags(
        Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog
    )
    dlg.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
    dlg._drag_pos = None

    t = c()

    # -- Outer layout (transparent, holds container with margins for shadow) --
    outer = QVBoxLayout(dlg)
    outer.setContentsMargins(12, 12, 12, 12)
    outer.setSpacing(0)

    # -- Rounded container --
    container = QWidget()
    container.setObjectName("_framelessContainer")
    container.setStyleSheet(
        f"#_framelessContainer {{ background-color: {t.bg_primary}; border-radius: 12px; }}"
    )
    shadow = QGraphicsDropShadowEffect(container)
    shadow.setBlurRadius(24)
    shadow.setOffset(0, 4)
    shadow.setColor(QColor(0, 0, 0, 80))
    container.setGraphicsEffect(shadow)

    container_lay = QVBoxLayout(container)
    container_lay.setContentsMargins(0, 0, 0, 0)
    container_lay.setSpacing(0)

    # -- Title bar --
    title_bar = QWidget()
    title_bar.setFixedHeight(40)
    title_bar.setStyleSheet(
        f"background-color: {t.bg_secondary}; "
        f"border-top-left-radius: 12px; border-top-right-radius: 12px;"
    )
    bar_layout = QHBoxLayout(title_bar)
    bar_layout.setContentsMargins(16, 0, 8, 0)
    bar_layout.setSpacing(0)

    title_label = QLabel(title_text)
    title_label.setStyleSheet(
        f"color: {t.text_primary}; font-size: 11pt; font-weight: bold; "
        f"background: transparent;"
    )
    bar_layout.addWidget(title_label)
    bar_layout.addStretch()

    close_btn = QPushButton("\u2715")
    close_btn.setFixedSize(28, 28)
    close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
    close_btn.setStyleSheet(
        f"QPushButton {{ background: transparent; color: {t.text_muted}; "
        f"border: none; border-radius: 14px; font-size: 12pt; }}"
        f"QPushButton:hover {{ background-color: {t.error}; color: {t.accent_text}; }}"
    )
    close_btn.clicked.connect(dlg.reject)
    bar_layout.addWidget(close_btn)

    # Dragging via title bar
    def _mouse_press(event):
        if event.button() == Qt.MouseButton.LeftButton:
            dlg._drag_pos = event.globalPosition().toPoint() - dlg.frameGeometry().topLeft()
            event.accept()
    def _mouse_move(event):
        if dlg._drag_pos is not None and event.buttons() & Qt.MouseButton.LeftButton:
            dlg.move(event.globalPosition().toPoint() - dlg._drag_pos)
            event.accept()
    def _mouse_release(event):
        dlg._drag_pos = None
    title_bar.mousePressEvent = _mouse_press
    title_bar.mouseMoveEvent = _mouse_move
    title_bar.mouseReleaseEvent = _mouse_release

    container_lay.addWidget(title_bar)

    # -- Content widget --
    content = QWidget()
    content_layout = QVBoxLayout(content)
    content_layout.setContentsMargins(16, 12, 16, 16)
    content_layout.setSpacing(8)
    container_lay.addWidget(content)

    outer.addWidget(container)

    # Store refs for later theming
    dlg._frameless_container = container
    dlg._frameless_title_bar = title_bar
    dlg._frameless_title_label = title_label
    return content_layout


class UserProfileDialog(QDialog):
    """Dialog for viewing/editing user profiles."""

    def __init__(self, bridge: AsyncBridge, user_id: str, editable: bool = False, parent=None):
        super().__init__(parent)
        self.bridge = bridge
        self.user_id = user_id
        self.editable = editable
        self.setMinimumWidth(400)
        self._build_ui()
        self._connect_signals()
        self.bridge.get_profile(user_id)

    def _build_ui(self):
        t = c()
        title_text = "Edit Profile" if self.editable else "User Profile"
        self.layout_main = _make_frameless(self, title_text)
        self.layout_main.setSpacing(12)

        # Avatar
        self.avatar_label = QLabel()
        self.avatar_label.setFixedSize(80, 80)
        self.avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        t = c()
        self.avatar_label.setStyleSheet(
            f"background-color: {t.bg_secondary}; border-radius: 40px; "
            f"font-size: 21pt; color: {t.accent};"
        )
        self.avatar_label.setText("?")
        self.layout_main.addWidget(self.avatar_label, alignment=Qt.AlignmentFlag.AlignCenter)

        if self.editable:
            avatar_btn = QPushButton("Change Avatar")
            avatar_btn.setObjectName("secondaryBtn")
            avatar_btn.clicked.connect(self._on_change_avatar)
            self.layout_main.addWidget(avatar_btn, alignment=Qt.AlignmentFlag.AlignCenter)

        # Info fields
        self.username_label = QLabel("")
        self.username_label.setStyleSheet(f"font-size: 14pt; font-weight: bold; color: {t.accent};")
        self.username_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.layout_main.addWidget(self.username_label)

        self.info_area = QVBoxLayout()
        self.layout_main.addLayout(self.info_area)

        # Editable fields (only shown in edit mode)
        if self.editable:
            self.layout_main.addSpacing(8)

            form_label = QLabel("Profile Settings")
            form_label.setStyleSheet(f"font-weight: bold; color: {t.accent};")
            self.layout_main.addWidget(form_label)

            self.phone_input = QLineEdit()
            self.phone_input.setPlaceholderText("Phone number")
            self.layout_main.addWidget(self.phone_input)

            self.location_input = QLineEdit()
            self.location_input.setPlaceholderText("Location")
            self.layout_main.addWidget(self.location_input)

            from PyQt6.QtWidgets import QCheckBox
            self.email_visible_cb = QCheckBox("Email visible to others")
            self.email_visible_cb.setStyleSheet(f"color: {t.text_primary};")
            self.layout_main.addWidget(self.email_visible_cb)

            self.phone_visible_cb = QCheckBox("Phone visible to others")
            self.phone_visible_cb.setStyleSheet(f"color: {t.text_primary};")
            self.layout_main.addWidget(self.phone_visible_cb)

            self.location_visible_cb = QCheckBox("Location visible to others")
            self.location_visible_cb.setStyleSheet(f"color: {t.text_primary};")
            self.layout_main.addWidget(self.location_visible_cb)

            save_btn = QPushButton("Save")
            save_btn.clicked.connect(self._on_save)
            self.layout_main.addWidget(save_btn)

        # Security section (only when viewing another user, not own profile)
        if not self.editable:
            self._security_section = QVBoxLayout()
            self.layout_main.addLayout(self._security_section)

        close_btn = QPushButton("Close")
        close_btn.setObjectName("secondaryBtn")
        close_btn.clicked.connect(self.accept)
        self.layout_main.addWidget(close_btn)

    def _connect_signals(self):
        self.bridge.profile_loaded.connect(self._on_profile_loaded)
        self.bridge.avatar_loaded.connect(self._on_avatar_loaded)
        self.bridge.profile_updated.connect(self._on_profile_updated)

    def _on_profile_loaded(self, profile):
        if profile.get("user_id") != self.user_id:
            return
        username = profile.get("username", "?")
        self.username_label.setText(username)
        # Set avatar initial
        self.avatar_label.setText(username[0].upper() if username else "?")

        # Clear info area
        while self.info_area.count():
            item = self.info_area.takeAt(0)
            if item.widget():
                item.widget().deleteLater()

        # Email
        email = profile.get("email")
        if email:
            self.info_area.addWidget(QLabel(f"Email: {email}"))

        # Phone
        phone = profile.get("phone")
        if phone:
            self.info_area.addWidget(QLabel(f"Phone: {phone}"))

        # Location
        location = profile.get("location")
        if location:
            self.info_area.addWidget(QLabel(f"Location: {location}"))

        # Member since
        created_at = profile.get("created_at", "")
        if created_at:
            date_str = created_at[:10] if len(created_at) >= 10 else created_at
            label = QLabel(f"Member since: {date_str}")
            label.setStyleSheet(f"color: {c().text_muted};")
            self.info_area.addWidget(label)

        # Populate editable fields
        if self.editable:
            self.phone_input.setText(phone or "")
            self.location_input.setText(location or "")
            self.email_visible_cb.setChecked(bool(profile.get("email_visible", 1)))
            self.phone_visible_cb.setChecked(bool(profile.get("phone_visible", 0)))
            self.location_visible_cb.setChecked(bool(profile.get("location_visible", 0)))

        # Try to load avatar
        if profile.get("avatar_file"):
            self.bridge.get_avatar(self.user_id)

        # Security section (viewing another user, not self)
        my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
        if not self.editable and hasattr(self, "_security_section") and self.user_id != my_uid:
            self._populate_security_section()

    def _populate_security_section(self):
        """Populate security/verification info for a peer user."""
        t = c()
        sec = self._security_section
        # Clear previous contents
        while sec.count():
            item = sec.takeAt(0)
            if item.widget():
                item.widget().deleteLater()

        sec.addSpacing(8)
        header = QLabel("Security")
        header.setStyleSheet(f"font-weight: bold; color: {t.accent};")
        sec.addWidget(header)

        status = self.bridge.client.get_verification_status(self.user_id)
        if status == "verified":
            status_label = QLabel("\u2705 Identity verified")
            status_label.setStyleSheet(f"color: {t.success}; font-weight: bold;")
        elif status == "trusted":
            status_label = QLabel("\U0001f512 Trusted (first use)")
            status_label.setStyleSheet(f"color: {t.warning};")
        else:
            status_label = QLabel("\u26A0 Unverified")
            status_label.setStyleSheet(f"color: {t.text_muted};")
        sec.addWidget(status_label)

        fp = self.bridge.client.get_peer_fingerprint(self.user_id)
        if fp:
            fp_label = QLabel(f"Fingerprint:\n{fp}")
            fp_label.setStyleSheet(
                f"font-family: monospace; font-size: 8pt; color: {t.text_primary}; "
                f"background: {t.bg_secondary}; padding: 4px; border-radius: 4px;"
            )
            fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
            sec.addWidget(fp_label)

    def _on_avatar_loaded(self, user_id, data):
        if user_id != self.user_id:
            return
        qimg = _safe_load_image(data)
        if qimg is not None:
            pixmap = QPixmap.fromImage(qimg)
            # Circular crop
            size = 80
            scaled = pixmap.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
                                   Qt.TransformationMode.SmoothTransformation)
            result = QPixmap(size, size)
            result.fill(QColor(0, 0, 0, 0))
            painter = QPainter(result)
            painter.setRenderHint(QPainter.RenderHint.Antialiasing)
            painter.setBrush(QBrush(scaled))
            painter.setPen(Qt.PenStyle.NoPen)
            painter.drawEllipse(0, 0, size, size)
            painter.end()
            self.avatar_label.setPixmap(result)

    def _on_change_avatar(self):
        path, _ = QFileDialog.getOpenFileName(
            self, "Select Avatar", "",
            "Images (*.png *.jpg *.jpeg);;All Files (*)",
        )
        if not path:
            return
        try:
            with open(path, "rb") as f:
                data = f.read()
            if len(data) > 2 * 1024 * 1024:
                QMessageBox.warning(self, "Error", "Avatar too large (max 2 MB).")
                return
            self.bridge.update_avatar(data)
        except Exception as e:
            QMessageBox.warning(self, "Error", f"Failed to read file: {e}")

    def _on_save(self):
        fields = {
            "phone": self.phone_input.text().strip() or None,
            "location": self.location_input.text().strip() or None,
            "email_visible": 1 if self.email_visible_cb.isChecked() else 0,
            "phone_visible": 1 if self.phone_visible_cb.isChecked() else 0,
            "location_visible": 1 if self.location_visible_cb.isChecked() else 0,
        }
        self.bridge.update_profile(**fields)

    def _on_profile_updated(self, ok, msg):
        if ok:
            # Refresh profile
            self.bridge.get_profile(self.user_id)
        else:
            QMessageBox.warning(self, "Error", msg)

    def closeEvent(self, event):
        # Disconnect signals to avoid stale references
        try:
            self.bridge.profile_loaded.disconnect(self._on_profile_loaded)
            self.bridge.avatar_loaded.disconnect(self._on_avatar_loaded)
            self.bridge.profile_updated.disconnect(self._on_profile_updated)
        except Exception:
            pass
        super().closeEvent(event)

    def reject(self):
        try:
            self.bridge.profile_loaded.disconnect(self._on_profile_loaded)
            self.bridge.avatar_loaded.disconnect(self._on_avatar_loaded)
            self.bridge.profile_updated.disconnect(self._on_profile_updated)
        except Exception:
            pass
        super().reject()

    def accept(self):
        try:
            self.bridge.profile_loaded.disconnect(self._on_profile_loaded)
            self.bridge.avatar_loaded.disconnect(self._on_avatar_loaded)
            self.bridge.profile_updated.disconnect(self._on_profile_updated)
        except Exception:
            pass
        super().accept()


class VerificationDialog(QDialog):
    """Dialog for viewing safety numbers, fingerprints, QR codes, and verifying contacts."""

    def __init__(self, bridge: AsyncBridge, peer_user_id: str, peer_name: str, parent=None):
        super().__init__(parent)
        self.bridge = bridge
        self.peer_user_id = peer_user_id
        self.peer_name = peer_name
        self.setMinimumWidth(420)
        self._build_ui()

    def _build_ui(self):
        t = c()
        lay = _make_frameless(self, "Verify Contact")
        lay.setSpacing(10)

        # Peer name
        name_label = QLabel(self.peer_name)
        name_label.setStyleSheet(f"font-size: 14pt; font-weight: bold; color: {t.accent};")
        name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        lay.addWidget(name_label)

        # Verification status
        status = self.bridge.client.get_verification_status(self.peer_user_id)
        if status == "verified":
            status_text = "\u2705 Verified"
            status_color = t.success
        elif status == "trusted":
            status_text = "\U0001f512 Trusted (TOFU)"
            status_color = t.warning
        else:
            status_text = "\u26A0 Unverified"
            status_color = t.error
        self._status_label = QLabel(status_text)
        self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {status_color};")
        self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        lay.addWidget(self._status_label)

        lay.addSpacing(4)

        # Safety Number
        safety = self.bridge.client.get_safety_number(self.peer_user_id)
        if safety:
            sn_header = QLabel("Safety Number")
            sn_header.setStyleSheet(f"font-weight: bold; color: {t.text_secondary};")
            lay.addWidget(sn_header)

            sn_label = QLabel(safety)
            sn_label.setStyleSheet(
                f"font-family: monospace; font-size: 13pt; letter-spacing: 2px; "
                f"color: {t.text_primary}; background: {t.bg_secondary}; "
                f"padding: 12px; border-radius: 8px;"
            )
            sn_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
            sn_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
            lay.addWidget(sn_label)

        lay.addSpacing(4)

        # QR Code
        qr_data = self.bridge.client.get_verification_qr_data()
        if qr_data:
            qr_header = QLabel("Your QR Code (for peer to scan)")
            qr_header.setStyleSheet(f"font-weight: bold; color: {t.text_secondary};")
            lay.addWidget(qr_header)

            qr_pixmap = self._generate_qr_pixmap(qr_data)
            if qr_pixmap:
                qr_label = QLabel()
                qr_label.setPixmap(qr_pixmap)
                qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
                lay.addWidget(qr_label)

                save_qr_btn = QPushButton("Save QR Code")
                save_qr_btn.setObjectName("secondaryBtn")
                save_qr_btn.clicked.connect(lambda: self._save_qr(qr_pixmap))
                lay.addWidget(save_qr_btn)

        lay.addSpacing(4)

        # Fingerprints
        my_fp = self.bridge.client.get_my_fingerprint()
        peer_fp = self.bridge.client.get_peer_fingerprint(self.peer_user_id)

        if my_fp or peer_fp:
            fp_header = QLabel("Fingerprints")
            fp_header.setStyleSheet(f"font-weight: bold; color: {t.text_secondary};")
            lay.addWidget(fp_header)

            if my_fp:
                my_fp_label = QLabel(f"Yours:\n{my_fp}")
                my_fp_label.setStyleSheet(
                    f"font-family: monospace; font-size: 9pt; color: {t.text_primary}; "
                    f"background: {t.bg_secondary}; padding: 6px; border-radius: 4px;"
                )
                my_fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
                lay.addWidget(my_fp_label)

            if peer_fp:
                peer_name_esc = self.peer_name.replace("&", "&amp;").replace("<", "&lt;")
                peer_fp_label = QLabel(f"{peer_name_esc}:\n{peer_fp}")
                peer_fp_label.setStyleSheet(
                    f"font-family: monospace; font-size: 9pt; color: {t.text_primary}; "
                    f"background: {t.bg_secondary}; padding: 6px; border-radius: 4px;"
                )
                peer_fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
                lay.addWidget(peer_fp_label)

        lay.addSpacing(8)

        # Action buttons
        btn_row = QHBoxLayout()

        if status != "verified":
            verify_btn = QPushButton("Mark as Verified")
            verify_btn.setStyleSheet(
                f"QPushButton {{ background-color: {t.success}; color: {t.bg_primary}; "
                f"font-weight: bold; padding: 8px 16px; border-radius: 6px; }}"
                f"QPushButton:hover {{ opacity: 0.9; }}"
            )
            verify_btn.clicked.connect(self._on_verify)
            btn_row.addWidget(verify_btn)
        else:
            unverify_btn = QPushButton("Remove Verification")
            unverify_btn.setStyleSheet(
                f"QPushButton {{ background-color: {t.warning}; color: {t.bg_primary}; "
                f"font-weight: bold; padding: 8px 16px; border-radius: 6px; }}"
            )
            unverify_btn.clicked.connect(self._on_unverify)
            btn_row.addWidget(unverify_btn)

        # Scan QR button
        scan_btn = QPushButton("Scan QR Code")
        scan_btn.setObjectName("secondaryBtn")
        scan_btn.clicked.connect(self._on_scan_qr)
        btn_row.addWidget(scan_btn)

        lay.addLayout(btn_row)

        close_btn = QPushButton("Close")
        close_btn.setObjectName("secondaryBtn")
        close_btn.clicked.connect(self.accept)
        lay.addWidget(close_btn)

    def _generate_qr_pixmap(self, data: bytes) -> QPixmap | None:
        """Generate a QR code QPixmap from raw bytes (base64-encoded for scanner compat)."""
        try:
            import qrcode
            import base64
            from io import BytesIO
            # Encode as base64 — raw binary gets corrupted by QR readers (UTF-8 re-encoding)
            qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L,
                                box_size=4, border=2)
            qr.add_data(base64.b64encode(data).decode("ascii"))
            qr.make(fit=True)
            img = qr.make_image(fill_color="black", back_color="white")
            buf = BytesIO()
            img.save(buf, format="PNG")
            buf.seek(0)
            qimg = QImage()
            qimg.loadFromData(buf.getvalue())
            if qimg.isNull():
                return None
            return QPixmap.fromImage(qimg)
        except ImportError:
            return None
        except Exception:
            return None

    def _save_qr(self, pixmap: QPixmap):
        """Save QR code image to file."""
        path, _ = QFileDialog.getSaveFileName(
            self, "Save QR Code", "verification_qr.png",
            "PNG Images (*.png);;All Files (*)",
        )
        if path:
            pixmap.save(path, "PNG")

    def _on_verify(self):
        cached = self.bridge.client._user_cache.get(self.peer_user_id)
        if cached and cached.get("identity_key_bytes"):
            self.bridge.client.verify_contact(
                self.peer_user_id, cached["identity_key_bytes"], method="safety_number"
            )
            t = c()
            self._status_label.setText("\u2705 Verified")
            self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {t.success};")

    def _on_unverify(self):
        self.bridge.client.unverify_contact(self.peer_user_id)
        t = c()
        self._status_label.setText("\U0001f512 Trusted (TOFU)")
        self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {t.warning};")

    def _on_scan_qr(self):
        """Open file picker for QR code image, decode and verify."""
        path, _ = QFileDialog.getOpenFileName(
            self, "Select QR Code Image", "",
            "Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)",
        )
        if not path:
            return
        try:
            from PIL import Image
            pil_img = Image.open(path)
        except Exception as e:
            QMessageBox.warning(self, "Error", f"Failed to open image: {e}")
            return
        # Try pyzbar first, fall back to manual decode
        qr_text = None
        try:
            from pyzbar.pyzbar import decode as pyzbar_decode
            results = pyzbar_decode(pil_img)
            if results:
                qr_text = results[0].data
        except ImportError:
            pass
        if qr_text is None:
            QMessageBox.information(
                self, "QR Scan",
                "Could not decode QR code. Install 'pyzbar' for QR scanning support, "
                "or verify manually using the safety number above."
            )
            return
        # QR contains base64-encoded binary payload
        import base64
        try:
            qr_data = base64.b64decode(qr_text)
        except Exception:
            QMessageBox.warning(self, "Error", "Invalid QR code format.")
            return
        ok, user_id, message = self.bridge.client.verify_qr_code(qr_data)
        if ok:
            t = c()
            self._status_label.setText("\u2705 Verified")
            self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {t.success};")
            QMessageBox.information(self, "Verification", message)
        else:
            QMessageBox.warning(self, "Verification Failed", message)


class LoginWindow(QWidget):
    def __init__(self, bridge: AsyncBridge):
        super().__init__()
        self.bridge = bridge
        self.setWindowTitle("Encrypted Chat - Login")
        self.setFixedSize(500, 540)
        self._pair_email = ""
        self._pair_password = ""
        self._build_ui()
        tm().on_change(self._apply_theme)

    def _login_card_qss(self):
        t = c()
        return (
            f"#loginCard {{ background-color: {t.bg_primary}; border-radius: 16px; }}"
            f"#loginCard QWidget {{ background: transparent; }}"
            f"#loginCard QLabel {{ background: transparent; color: {t.text_primary}; }}"
            f"#loginCard QLineEdit {{"
            f"  background-color: {t.bg_secondary}; color: {t.text_primary};"
            f"  border: 1px solid {t.border}; border-radius: 6px; padding: 8px;"
            f"}}"
            f"#loginCard QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
            f"#loginCard QPushButton {{"
            f"  background-color: {t.accent}; color: {t.accent_text};"
            f"  border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold;"
            f"}}"
            f"#loginCard QPushButton:hover {{ background-color: {t.accent_hover}; }}"
        )

    def _tab_bar_qss(self):
        t = c()
        return (
            f"QTabBar {{ background: transparent; border: none; }}"
            f"QTabBar::tab {{ background: transparent; color: {t.text_muted}; "
            f"padding: 10px 24px; font-size: 10pt; border: none; "
            f"border-bottom: 2px solid transparent; }}"
            f"QTabBar::tab:selected {{ color: {t.accent}; font-weight: bold; "
            f"border-bottom: 2px solid {t.accent}; }}"
            f"QTabBar::tab:hover {{ color: {t.text_primary}; }}"
            f"QTabWidget::pane {{ border: none; background: transparent; }}"
        )

    def _apply_theme(self):
        QApplication.instance().setStyleSheet(qss())
        t = c()
        self.setStyleSheet(f"background-color: {t.bg_tertiary};")
        self._card.setStyleSheet(self._login_card_qss())
        self._subtitle.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; margin-bottom: 8px;")
        self._theme_btn.setText("\u2600" if tm().is_dark else "\U0001f319")
        self._theme_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; color: {t.text_muted}; "
            f"border: none; font-size: 14pt; }}"
            f"QPushButton:hover {{ color: {t.accent}; }}"
        )
        self._tabs.setStyleSheet(self._tab_bar_qss())
        # Verification page
        self._step_label.setStyleSheet(f"color: {t.accent}; font-weight: bold; font-size: 10pt;")
        self._info_label.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;")
        self.code_input.setStyleSheet(
            f"QLineEdit {{ font-size: 16pt; letter-spacing: 8px; text-align: center; "
            f"background-color: {t.bg_secondary}; border: 1px solid {t.border}; "
            f"border-radius: 6px; padding: 12px; color: {t.text_primary}; }}"
            f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
        )

    def _build_ui(self):
        from PyQt6.QtWidgets import QTabWidget
        outer = QVBoxLayout(self)
        outer.setContentsMargins(0, 0, 0, 0)
        t = c()

        # Background fills entire window
        self.setStyleSheet(f"background-color: {t.bg_tertiary};")

        # Theme toggle in top-right corner
        top_row = QHBoxLayout()
        top_row.setContentsMargins(12, 8, 12, 0)
        top_row.addStretch()
        self._theme_btn = QPushButton("\u2600" if tm().is_dark else "\U0001f319")
        self._theme_btn.setFixedSize(36, 36)
        self._theme_btn.setToolTip("Toggle light/dark mode")
        self._theme_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; color: {t.text_muted}; "
            f"border: none; font-size: 14pt; }}"
            f"QPushButton:hover {{ color: {t.accent}; }}"
        )
        self._theme_btn.clicked.connect(tm().toggle)
        top_row.addWidget(self._theme_btn)
        outer.addLayout(top_row)

        self.stack = QStackedWidget()
        outer.addWidget(self.stack)

        # --- Page 0: Login / Register form (card) ---
        page0 = QWidget()
        page0_layout = QVBoxLayout(page0)
        page0_layout.setContentsMargins(40, 0, 40, 20)
        page0_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

        self._card = QWidget()
        self._card.setObjectName("loginCard")
        self._card.setStyleSheet(self._login_card_qss())
        card_layout = QVBoxLayout(self._card)
        card_layout.setSpacing(10)
        card_layout.setContentsMargins(36, 28, 36, 28)

        title = QLabel("Encrypted Chat")
        title.setObjectName("title")
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)
        card_layout.addWidget(title)

        self._subtitle = QLabel("End-to-end encrypted messaging")
        self._subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._subtitle.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; margin-bottom: 8px;")
        card_layout.addWidget(self._subtitle)

        # --- Tabs: Login | Register | Link Device ---
        self._tabs = QTabWidget()
        self._tabs.setMinimumHeight(220)
        self._tabs.setStyleSheet(self._tab_bar_qss())

        # == Login tab ==
        login_tab = QWidget()
        login_lay = QVBoxLayout(login_tab)
        login_lay.setSpacing(12)
        login_lay.setContentsMargins(0, 12, 0, 4)

        self.email_input = QLineEdit()
        self.email_input.setPlaceholderText("Email")
        login_lay.addWidget(self.email_input)

        self.password_input = QLineEdit()
        self.password_input.setPlaceholderText("Password")
        self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
        self.password_input.returnPressed.connect(self._on_login)
        login_lay.addWidget(self.password_input)

        login_lay.addStretch()
        self.login_btn = QPushButton("Login")
        self.login_btn.setMinimumHeight(40)
        self.login_btn.clicked.connect(self._on_login)
        login_lay.addWidget(self.login_btn)

        self._tabs.addTab(login_tab, "Login")

        # == Register tab ==
        reg_tab = QWidget()
        reg_lay = QVBoxLayout(reg_tab)
        reg_lay.setSpacing(12)
        reg_lay.setContentsMargins(0, 12, 0, 4)

        self.username_input = QLineEdit()
        self.username_input.setPlaceholderText("Username (display name)")
        reg_lay.addWidget(self.username_input)

        self._reg_email_input = QLineEdit()
        self._reg_email_input.setPlaceholderText("Email")
        reg_lay.addWidget(self._reg_email_input)

        self._reg_password_input = QLineEdit()
        self._reg_password_input.setPlaceholderText("Password")
        self._reg_password_input.setEchoMode(QLineEdit.EchoMode.Password)
        self._reg_password_input.returnPressed.connect(self._on_register)
        reg_lay.addWidget(self._reg_password_input)

        reg_lay.addStretch()
        self.register_btn = QPushButton("Register")
        self.register_btn.setMinimumHeight(40)
        self.register_btn.clicked.connect(self._on_register)
        reg_lay.addWidget(self.register_btn)

        self._tabs.addTab(reg_tab, "Register")

        # == Link Device tab ==
        link_tab = QWidget()
        link_lay = QVBoxLayout(link_tab)
        link_lay.setSpacing(12)
        link_lay.setContentsMargins(0, 12, 0, 4)

        self._link_email_input = QLineEdit()
        self._link_email_input.setPlaceholderText("Email")
        link_lay.addWidget(self._link_email_input)

        self._link_password_input = QLineEdit()
        self._link_password_input.setPlaceholderText("Password")
        self._link_password_input.setEchoMode(QLineEdit.EchoMode.Password)
        self._link_password_input.returnPressed.connect(self._on_link_device)
        link_lay.addWidget(self._link_password_input)

        link_lay.addStretch()
        self.link_btn = QPushButton("Link Device")
        self.link_btn.setMinimumHeight(40)
        self.link_btn.clicked.connect(self._on_link_device)
        link_lay.addWidget(self.link_btn)

        self._tabs.addTab(link_tab, "Link Device")

        card_layout.addWidget(self._tabs)

        self.status_label = QLabel("")
        self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.status_label.setWordWrap(True)
        card_layout.addWidget(self.status_label)

        page0_layout.addWidget(self._card)
        self.stack.addWidget(page0)

        # --- Page 1: Verification code form ---
        page1 = QWidget()
        vl = QVBoxLayout(page1)
        vl.setSpacing(14)
        vl.setContentsMargins(50, 40, 50, 40)

        self._step_label = QLabel("Step 2 of 2")
        self._step_label.setStyleSheet(f"color: {t.accent}; font-weight: bold; font-size: 10pt;")
        self._step_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        vl.addWidget(self._step_label)

        self._info_label = QLabel("Enter the 6-digit verification code sent to your email")
        self._info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._info_label.setWordWrap(True)
        self._info_label.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;")
        vl.addWidget(self._info_label)

        vl.addSpacing(12)

        self.code_input = QLineEdit()
        self.code_input.setPlaceholderText("000000")
        self.code_input.setMaxLength(6)
        self.code_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.code_input.setStyleSheet(
            f"QLineEdit {{ font-size: 16pt; letter-spacing: 8px; text-align: center; "
            f"background-color: {t.bg_secondary}; border: 1px solid {t.border}; "
            f"border-radius: 6px; padding: 12px; color: {t.text_primary}; }}"
            f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
        )
        self.code_input.returnPressed.connect(self._on_confirm_code)
        vl.addWidget(self.code_input)

        vl.addSpacing(8)

        self.code_status_label = QLabel("")
        self.code_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.code_status_label.setWordWrap(True)
        vl.addWidget(self.code_status_label)

        code_btn_row = QHBoxLayout()
        self.back_btn = QPushButton("Back")
        self.back_btn.setObjectName("secondaryBtn")
        self.back_btn.clicked.connect(self._on_back_to_login)
        code_btn_row.addWidget(self.back_btn)

        self.confirm_btn = QPushButton("Confirm")
        self.confirm_btn.clicked.connect(self._on_confirm_code)
        code_btn_row.addWidget(self.confirm_btn)
        vl.addLayout(code_btn_row)

        vl.addStretch()
        self.stack.addWidget(page1)

    def show_verification_page(self, message=""):
        """Switch to verification code page."""
        self.code_input.clear()
        self.code_status_label.setText(message)
        self.code_status_label.setStyleSheet(f"color: {c().success};")
        self.stack.setCurrentIndex(1)
        self.code_input.setFocus()

    def _on_confirm_code(self):
        code = self.code_input.text().strip()
        if not code:
            self.code_status_label.setText("Please enter the code.")
            self.code_status_label.setStyleSheet(f"color: {c().error};")
            return
        self.code_status_label.setText("Confirming...")
        self.code_status_label.setStyleSheet(f"color: {c().success};")
        self.confirm_btn.setEnabled(False)
        self.back_btn.setEnabled(False)
        # Callback set by main() to handle confirmation
        if hasattr(self, '_confirm_callback'):
            self._confirm_callback(code)

    def _on_back_to_login(self):
        self.stack.setCurrentIndex(0)
        self._set_enabled(True)
        self.status_label.setText("")
        self.status_label.setStyleSheet("")

    def _on_register(self):
        username = self.username_input.text().strip()
        password = self._reg_password_input.text()
        email = self._reg_email_input.text().strip()
        if not username:
            self.show_error("Username required.")
            return
        if not email or not password:
            self.show_error("Email and password required.")
            return
        self.status_label.setText("Registering...")
        self._set_enabled(False)
        self.bridge.do_register(username, password, email)

    def _on_login(self):
        email = self.email_input.text().strip()
        password = self.password_input.text()
        if not email or not password:
            self.show_error("Email and password required.")
            return
        self.status_label.setText("Logging in...")
        self._set_enabled(False)
        self.bridge.do_login(email, password)

    def _on_link_device(self):
        email = self._link_email_input.text().strip()
        password = self._link_password_input.text()
        if not email or not password:
            self.show_error("Email and password required.")
            return
        self._pair_email = email
        self._pair_password = password
        self.status_label.setText("Generating pairing code...")
        self._set_enabled(False)
        self.bridge.link_device(email, password)

    def _set_enabled(self, enabled):
        self._tabs.setEnabled(enabled)
        self.username_input.setEnabled(enabled)
        self.email_input.setEnabled(enabled)
        self.password_input.setEnabled(enabled)
        self._reg_email_input.setEnabled(enabled)
        self._reg_password_input.setEnabled(enabled)
        self._link_email_input.setEnabled(enabled)
        self._link_password_input.setEnabled(enabled)
        self.register_btn.setEnabled(enabled)
        self.login_btn.setEnabled(enabled)
        self.link_btn.setEnabled(enabled)

    def show_error(self, msg):
        if self.stack.currentIndex() == 1:
            self.code_status_label.setText(msg)
            self.code_status_label.setStyleSheet(f"color: {c().error};")
            self.confirm_btn.setEnabled(True)
            self.back_btn.setEnabled(True)
        else:
            self.status_label.setText(msg)
            self.status_label.setStyleSheet(f"color: {c().error};")
            self._set_enabled(True)

    def show_success(self, msg):
        if self.stack.currentIndex() == 1:
            self.code_status_label.setText(msg)
            self.code_status_label.setStyleSheet(f"color: {c().success};")
        else:
            self.status_label.setText(msg)
            self.status_label.setStyleSheet(f"color: {c().success};")

    def reset(self):
        self.stack.setCurrentIndex(0)
        self._tabs.setCurrentIndex(0)
        self.status_label.setText("")
        self.status_label.setStyleSheet("")
        self.code_status_label.setText("")
        self.code_status_label.setStyleSheet("")
        self.username_input.clear()
        self.email_input.clear()
        self.password_input.clear()
        self._reg_email_input.clear()
        self._reg_password_input.clear()
        self._link_email_input.clear()
        self._link_password_input.clear()
        self.code_input.clear()
        self._set_enabled(True)
        self.confirm_btn.setEnabled(True)
        self.back_btn.setEnabled(True)


class MessageBubble(QFrame):
    """Chat message bubble with rounded corners drawn via QPainter."""

    def __init__(self, bg_color: str, parent=None):
        super().__init__(parent)
        self._bg_color = QColor(bg_color)
        self.setStyleSheet("background: transparent; border: none;")
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)

    def set_bg_color(self, color: str):
        self._bg_color = QColor(color)
        self.update()

    def paintEvent(self, event):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.setBrush(QBrush(self._bg_color))
        p.setPen(Qt.PenStyle.NoPen)
        p.drawRoundedRect(self.rect(), 14, 14)
        p.end()

    def contextMenuEvent(self, event):
        # Walk up to find _msg_index, then call main window handler
        idx = getattr(self, '_msg_index', None)
        if idx is None:
            p = self.parentWidget()
            while p:
                idx = getattr(p, '_msg_index', None)
                if idx is not None:
                    break
                p = p.parentWidget()
        main_win = self.window()
        if idx is not None and hasattr(main_win, '_show_msg_context_menu'):
            main_win._show_msg_context_menu(idx, event.globalPos())
        event.accept()


class MainWindow(QWidget):
    _AVATAR_REFRESH_BATCH = 8
    _GROUP_AVATAR_REFRESH_BATCH = 4
    _show_verification_dialog_signal = pyqtSignal(str, str)  # peer_uid, peer_name

    def __init__(self, bridge: AsyncBridge, on_logout):
        super().__init__()
        self.bridge = bridge
        self._on_logout_cb = on_logout
        self.setWindowTitle(f"Encrypted Chat - {bridge.client.username}")
        self.resize(900, 600)

        self.conversations: list[dict] = []
        self.current_conv_id: str | None = None
        self.current_messages: list[dict] = []
        self.reply_to_id: str | None = None
        self._unread_counts: dict[str, int] = {}
        self._has_more_messages: bool = True
        self._pending_image_download: dict | None = None  # {file_id, image_info}
        self._is_dm: bool = False
        self._online_users: set[str] = set()
        self._is_logout = False
        self._avatar_cache = _LRUPixmapCache()  # user_id -> pixmap
        self._group_avatar_cache = _LRUPixmapCache()  # conv_id -> pixmap
        self._avatar_requested: set[str] = set()
        self._group_avatar_requested: set[str] = set()
        self._avatar_refresh_cursor = 0
        self._group_avatar_refresh_cursor = 0
        self._pending_invitations: list[dict] = []
        self._favorites: set[str] = self._load_favorites()
        # Search state
        self._search_results: list[int] = []  # indices into current_messages
        self._search_current: int = -1
        self._search_query: str = ""
        self._search_active: bool = False

        self._privacy_enabled: bool = True  # Privacy overlay on/off
        self._last_message_cache: dict[str, tuple[str, str, str]] = {}  # conv_id -> (text, ts, receipt)

        self._build_ui()
        self._connect_signals()
        self._setup_tray_icon()
        self._setup_privacy_overlay()

        # Keyboard shortcuts
        QShortcut(QKeySequence("Ctrl+F"), self).activated.connect(self._toggle_search)
        QShortcut(QKeySequence("Ctrl+Shift+P"), self).activated.connect(self._toggle_privacy)

        self.bridge.load_conversations()
        self.bridge.list_invitations()

        # Periodic refresh: re-download avatars and conversation data
        self._refresh_timer = QTimer(self)
        self._refresh_timer.timeout.connect(self._on_periodic_refresh)
        self._refresh_timer.start(120_000)  # every 2 minutes

        tm().on_change(self._apply_theme)

    # -- Theme switching -------------------------------------------------------

    def _apply_theme(self):
        """Re-apply theme colours to all widgets after theme toggle."""
        app = QApplication.instance()
        if app:
            app.setStyleSheet(qss())
        t = c()
        # Sidebar
        self._sidebar_panel.setStyleSheet(f"#sidebarPanel {{ background-color: {t.bg_tertiary}; }}")
        self._conv_label.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent}; background: transparent;")
        self._settings_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; color: {t.text_secondary}; border: none; border-radius: 6px; padding: 8px 16px; }}"
            f"QPushButton:hover {{ background-color: {t.bg_hover}; }}"
        )
        self.conv_list.setStyleSheet(
            f"QListWidget {{ background-color: {t.bg_tertiary}; border: none; padding: 0px; }}"
            f"QListWidget::item {{ padding: 0px; border: none; }}"
            f"QListWidget::item:selected {{ background: transparent; border: none; }}"
            f"QListWidget::item:hover {{ background: transparent; }}"
        )
        # Invitation list
        self.inv_label.setStyleSheet(f"font-weight: bold; font-size: 9pt; color: {t.warning}; margin-top: 4px;")
        self.inv_list.setStyleSheet(
            f"QListWidget {{ background-color: {t.bg_primary}; border: 1px solid {t.warning}; border-radius: 6px; padding: 2px; }}"
            f"QListWidget::item {{ padding: 6px; color: {t.text_primary}; }}"
            f"QListWidget::item:hover {{ background-color: {t.bg_hover}; color: {t.text_primary}; }}"
        )
        # Chat header
        self._chat_header_widget.setStyleSheet(
            f"#chatHeader {{ border-bottom: 1px solid {t.separator}; }}"
            f"#chatHeader QLabel, #chatHeader QPushButton {{ border: none; }}"
        )
        self.chat_header.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent};")
        self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;")
        self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;")
        self.connection_dot.setStyleSheet(f"color: {t.success}; font-size: 11pt;")
        self._logout_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; color: {t.error}; border: none; "
            f"border-radius: 16px; font-size: 13pt; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.error}; color: {t.accent_text}; }}"
        )
        self.delete_conv_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; border: none; border-radius: 4px; padding: 4px; }}"
            f"QPushButton:hover {{ background-color: {t.error}; }}"
        )
        # Search bar
        self.search_input.setStyleSheet(
            f"QLineEdit {{ background-color: {t.bg_secondary}; color: {t.text_primary}; "
            f"border: 1px solid {t.border}; border-radius: 4px; padding: 4px 8px; font-size: 10pt; }}"
        )
        self.search_count_label.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; min-width: 40px;")
        # Pin banner
        self._pin_banner.setStyleSheet(f"background-color:{t.border}; border-bottom:2px solid {t.pin_color};")
        self._pin_banner_label.setStyleSheet(f"color:{t.text_primary}; font-size:9pt; background:transparent; border:none;")
        # Jump button
        self.jump_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; border-radius: 18px; "
            f"font-size: 14pt; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.accent_hover}; }}"
        )
        # Reply label
        self.reply_label.setStyleSheet(
            f"color: {t.accent}; font-style: italic; font-size: 9pt; "
            f"padding: 2px 4px; background: transparent;"
        )
        # Input area
        self._attach_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.bg_secondary}; border: none; "
            f"border-radius: 20px; font-size: 14pt; }}"
            f"QPushButton:hover {{ background-color: {t.bg_hover}; }}"
        )
        self._send_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; "
            f"border: none; border-radius: 20px; font-size: 14pt; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.accent_hover}; }}"
        )
        self.msg_input.setStyleSheet(self.msg_input._style_normal())
        # Counters
        self.char_counter.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt; padding: 0 4px;")
        self.reencrypt_label.setStyleSheet(
            f"background-color: {t.bg_secondary}; border-radius: 6px; "
            f"padding: 8px 12px; color: {t.success}; font-weight: bold;"
        )
        # Status bar
        self.status_bar.setStyleSheet(
            f"background-color: {t.bg_tertiary}; border-radius: 0px; "
            f"padding: 0 8px; color: {t.success}; font-size: 8pt;"
        )
        # Privacy overlay
        if hasattr(self, "_privacy_overlay"):
            self._privacy_overlay.setStyleSheet(f"background-color: {t.overlay};")
            self._lock_hint.setStyleSheet(f"font-size: 12pt; color: {t.text_muted}; background: transparent;")
            self._lock_input.setStyleSheet(
                f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; "
                f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; "
                f"color: {t.text_primary}; }}"
                f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
            )
            self._lock_error.setStyleSheet(f"font-size: 9pt; color: {t.error}; background: transparent;")
        # Mention popup
        if hasattr(self, "_mention_popup"):
            self._mention_popup.setStyleSheet(
                f"QListWidget {{ background:{t.bg_secondary}; color:{t.text_primary}; border:1px solid {t.border}; "
                f"border-radius:4px; font-size:10pt; }}"
                f"QListWidget::item {{ padding:4px 8px; }}"
                f"QListWidget::item:selected {{ background:{t.border}; }}"
            )
        # Message scroll area
        if hasattr(self, '_msg_scroll_area'):
            self._msg_scroll_area.setStyleSheet(
                f"QScrollArea {{ background-color: {t.bg_primary}; border: none; }}"
            )
            self._msg_container.setStyleSheet(
                f"background-color: {t.bg_primary};"
            )
        # Re-render messages and conversation list
        self._rebuild_conv_list()
        if self.current_messages:
            self._render_messages(scroll_to_bottom=False)

    # -- Privacy Overlay (lock screen) ----------------------------------------

    _LOCK_TIMEOUT_MS = 30_000  # 30 s unfocused → lock (require password)

    def _setup_privacy_overlay(self):
        """Create overlay that hides content on focus loss; locks after timeout."""
        self._privacy_locked = False
        # Check if identity key is password-encrypted (ECP1 format)
        # If not, lock feature is disabled (no password to verify against)
        self._lock_capable = False
        try:
            from chat_core import get_key_dir
            key_path = get_key_dir(self.bridge.client.email) / "identity_private.bin"
            if key_path.exists():
                self._lock_capable = key_path.read_bytes()[:4] == b"ECP1"
        except Exception:
            pass

        t = c()
        # -- overlay widget --
        self._privacy_overlay = QWidget(self)
        self._privacy_overlay.setStyleSheet(
            f"background-color: {t.overlay};"
        )
        self._privacy_overlay.hide()

        overlay_layout = QVBoxLayout(self._privacy_overlay)
        overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

        lock_icon = QLabel("\U0001f512")
        lock_icon.setStyleSheet("font-size: 36pt; background: transparent;")
        lock_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
        overlay_layout.addWidget(lock_icon)

        self._lock_hint = QLabel("Encrypted Chat")
        self._lock_hint.setStyleSheet(
            f"font-size: 12pt; color: {t.text_muted}; background: transparent;"
        )
        self._lock_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
        overlay_layout.addWidget(self._lock_hint)

        # Password input (hidden until locked)
        self._lock_input = QLineEdit()
        self._lock_input.setPlaceholderText("Enter password to unlock")
        self._lock_input.setEchoMode(QLineEdit.EchoMode.Password)
        self._lock_input.setMaximumWidth(280)
        self._lock_input.setStyleSheet(
            f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; "
            f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; "
            f"color: {t.text_primary}; }}"
            f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
        )
        self._lock_input.returnPressed.connect(self._on_unlock_attempt)
        self._lock_input.hide()
        overlay_layout.addWidget(self._lock_input, alignment=Qt.AlignmentFlag.AlignCenter)

        self._lock_error = QLabel("")
        self._lock_error.setStyleSheet(
            f"font-size: 9pt; color: {t.error}; background: transparent;"
        )
        self._lock_error.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._lock_error.hide()
        overlay_layout.addWidget(self._lock_error)

        # Timer: after N seconds unfocused → require password
        self._lock_timer = QTimer(self)
        self._lock_timer.setSingleShot(True)
        self._lock_timer.timeout.connect(self._on_lock_timeout)

    def _toggle_privacy(self):
        """Toggle privacy overlay on/off (Ctrl+Shift+P)."""
        self._privacy_enabled = not self._privacy_enabled
        if not self._privacy_enabled:
            self._privacy_locked = False
            self._lock_timer.stop()
            self._hide_privacy_overlay()
        state = "ON" if self._privacy_enabled else "OFF"
        base_title = f"Encrypted Chat - {self.bridge.client.username}"
        self.setWindowTitle(f"{base_title}  [Privacy: {state}]")
        QTimer.singleShot(2000, lambda: self.setWindowTitle(base_title))

    def _show_privacy_overlay(self):
        if not self._privacy_enabled:
            return
        if not self._privacy_overlay.isVisible():
            self._privacy_overlay.setGeometry(self.rect())
            self._privacy_overlay.raise_()
            self._privacy_overlay.show()
            # Start lock countdown
            self._lock_timer.start(self._LOCK_TIMEOUT_MS)

    def _hide_privacy_overlay(self):
        self._lock_timer.stop()
        self._lock_input.hide()
        self._lock_input.clear()
        self._lock_error.hide()
        self._lock_hint.setText("Encrypted Chat")
        if self._privacy_overlay.isVisible():
            self._privacy_overlay.hide()

    def _on_lock_timeout(self):
        """Window unfocused too long — require password."""
        if self._privacy_overlay.isVisible() and self._lock_capable:
            self._privacy_locked = True
            self._lock_hint.setText("Session locked")
            self._lock_input.show()
            self._lock_input.setFocus()

    def _on_unlock_attempt(self):
        """Verify password by decrypting identity key from disk."""
        from chat_core import get_key_dir, _check_lockout, _record_failed_attempt, _clear_lockout
        from crypto_utils import _decrypt_private_key
        pwd = self._lock_input.text()
        if not pwd:
            return
        email = self.bridge.client.email
        remaining = _check_lockout(email)
        if remaining > 0:
            self._lock_error.setText(f"Too many attempts. Wait {remaining:.0f}s.")
            self._lock_error.show()
            self._lock_input.clear()
            self._lock_input.setFocus()
            return
        try:
            key_path = get_key_dir(email) / "identity_private.bin"
            data = key_path.read_bytes()
            _decrypt_private_key(data, pwd.encode("utf-8"))
            # Success — unlock
            _clear_lockout(email)
            self._privacy_locked = False
            self._hide_privacy_overlay()
        except Exception:
            _record_failed_attempt(email)
            remaining = _check_lockout(email)
            if remaining > 0:
                self._lock_error.setText(f"Wrong password. Wait {remaining:.0f}s.")
            else:
                self._lock_error.setText("Wrong password")
            self._lock_error.show()
            self._lock_input.clear()
            self._lock_input.setFocus()

    def changeEvent(self, event):
        """Handle window state changes — tray minimize + privacy overlay."""
        from PyQt6.QtCore import QEvent
        if event.type() == QEvent.Type.WindowStateChange:
            if self.isMinimized() and self._tray_icon is not None:
                event.ignore()
                self.hide()
                return
        if event.type() == QEvent.Type.ActivationChange:
            if self.isActiveWindow():
                if not self._privacy_locked:
                    self._hide_privacy_overlay()
                else:
                    # Locked — keep overlay, focus password input
                    self._lock_input.setFocus()
            else:
                self._show_privacy_overlay()
        super().changeEvent(event)

    def resizeEvent(self, event):
        """Keep privacy overlay sized to window."""
        super().resizeEvent(event)
        if hasattr(self, "_privacy_overlay"):
            self._privacy_overlay.setGeometry(self.rect())

    # -- System Tray ----------------------------------------------------------

    def _make_tray_icon(self) -> QIcon:
        """Create a simple app icon (blue chat bubble) for the system tray."""
        px = QPixmap(64, 64)
        px.fill(QColor(0, 0, 0, 0))
        p = QPainter(px)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.setBrush(QBrush(QColor(c().accent)))
        p.setPen(Qt.PenStyle.NoPen)
        p.drawRoundedRect(4, 4, 56, 48, 12, 12)
        # small triangle (tail)
        from PyQt6.QtGui import QPolygon
        from PyQt6.QtCore import QPoint
        p.drawPolygon(QPolygon([QPoint(14, 52), QPoint(24, 44), QPoint(30, 52)]))
        # lock icon (E2E indicator)
        p.setBrush(QBrush(QColor(c().accent_text)))
        p.drawRoundedRect(24, 16, 16, 14, 3, 3)
        p.setPen(QPen(QColor(c().accent_text), 3))
        p.setBrush(Qt.BrushStyle.NoBrush)
        p.drawArc(27, 10, 10, 12, 0, 180 * 16)
        p.end()
        return QIcon(px)

    def _setup_tray_icon(self):
        """Initialize the system tray icon with context menu."""
        if not QSystemTrayIcon.isSystemTrayAvailable():
            self._tray_icon = None
            return
        self._tray_icon = QSystemTrayIcon(self._make_tray_icon(), self)
        self._tray_icon.setToolTip(f"Encrypted Chat - {self.bridge.client.username}")
        self._tray_icon.activated.connect(self._on_tray_activated)

        tray_menu = QMenu()
        show_action = tray_menu.addAction("Show")
        show_action.triggered.connect(self._restore_from_tray)
        tray_menu.addSeparator()
        quit_action = tray_menu.addAction("Quit")
        quit_action.triggered.connect(self._quit_from_tray)
        self._tray_icon.setContextMenu(tray_menu)
        self._tray_icon.show()

    def _on_tray_activated(self, reason):
        """Handle tray icon click — restore window on double-click or single click."""
        if reason in (QSystemTrayIcon.ActivationReason.Trigger,
                      QSystemTrayIcon.ActivationReason.DoubleClick):
            self._restore_from_tray()

    def _restore_from_tray(self):
        """Restore window from system tray."""
        self.showNormal()
        self.activateWindow()
        self.raise_()

    def _quit_from_tray(self):
        """Quit the application from tray menu."""
        if self._tray_icon:
            self._tray_icon.hide()
        self._is_logout = False
        self.close()

    def _show_tray_notification(self, title: str, text: str):
        """Show a system tray toast notification if the window is not in the foreground."""
        if not self._tray_icon:
            logger.debug("Tray notification skipped: no tray icon")
            return
        if self.isVisible() and self.isActiveWindow() and not self._privacy_locked:
            return  # user is looking at the app (and it's not locked)
        if len(text) > 120:
            text = text[:117] + "..."
        logger.info("Tray notification: %s — %s", title, text[:50])
        self._tray_icon.showMessage(
            title, text,
            QSystemTrayIcon.MessageIcon.Information, 4000,
        )

    # -- End Tray ---------------------------------------------------------------

    def _bold_font(self) -> QFont:
        """Return a bold font with a valid size (avoids QFont pointSize=-1 warnings)."""
        f = QFont(self.conv_list.font())
        f.setBold(True)
        # Stylesheet sets font-size in px so pointSize is -1; fix by using pixelSize
        if f.pointSize() <= 0:
            px = f.pixelSize()
            if px > 0:
                f.setPixelSize(px)
            else:
                f.setPointSize(10)
        return f

    def _make_circular_avatar(self, pixmap: QPixmap, size: int = 32) -> QPixmap:
        """Crop a pixmap into a circle."""
        scaled = pixmap.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
                               Qt.TransformationMode.SmoothTransformation)
        result = QPixmap(size, size)
        result.fill(QColor(0, 0, 0, 0))
        painter = QPainter(result)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setBrush(QBrush(scaled))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(0, 0, size, size)
        painter.end()
        return result

    def _make_default_avatar(self, username: str, size: int = 32) -> QPixmap:
        """Generate a colored circle with the first letter of the username."""
        # Deterministic color from username — higher saturation in light mode
        hue = (hash(username) % 360)
        sat = 160 if not tm().is_dark else 120
        val = 180 if not tm().is_dark else 200
        color = QColor.fromHsv(hue, sat, val)
        result = QPixmap(size, size)
        result.fill(QColor(0, 0, 0, 0))
        painter = QPainter(result)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setBrush(QBrush(color))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(0, 0, size, size)
        # Draw letter
        painter.setPen(QColor(255, 255, 255))
        font = QFont("Segoe UI Variable", int(size * 0.4))
        if not font.exactMatch():
            font = QFont("Segoe UI", int(size * 0.4))
        font.setBold(True)
        painter.setFont(font)
        letter = username[0].upper() if username else "?"
        painter.drawText(0, 0, size, size, Qt.AlignmentFlag.AlignCenter, letter)
        painter.end()
        return result

    def _add_online_dot(self, avatar: QPixmap) -> QPixmap:
        """Overlay a green dot on the bottom-right of an avatar pixmap."""
        result = QPixmap(avatar)
        painter = QPainter(result)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        dot_size = max(8, avatar.width() // 4)
        x = avatar.width() - dot_size
        y = avatar.height() - dot_size
        # Border ring (matches sidebar background)
        painter.setBrush(QBrush(QColor(c().online_dot_border)))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(x - 1, y - 1, dot_size + 2, dot_size + 2)
        # Green dot
        painter.setBrush(QBrush(QColor(c().online_dot)))
        painter.drawEllipse(x, y, dot_size, dot_size)
        painter.end()
        return result

    def _get_conv_avatar(self, conv: dict) -> QIcon:
        """Get avatar icon for a conversation list item."""
        is_dm = len(conv["members"]) == 2 and not conv.get("name")
        if is_dm:
            other = None
            for m in conv["members"]:
                if m.get("email") != self.bridge.client.email:
                    other = m
                    break
            if other:
                uid = other.get("user_id") or other.get("id") or ""
                uname = other.get("username") or other.get("email") or "?"
                if uid in self._avatar_cache:
                    avatar = self._make_circular_avatar(self._avatar_cache[uid])
                else:
                    avatar = self._make_default_avatar(uname)
                    # Request avatar download if not yet requested
                    if uid and uid not in self._avatar_requested:
                        self._avatar_requested.add(uid)
                        self.bridge.get_avatar(uid)
                if uid in self._online_users:
                    avatar = self._add_online_dot(avatar)
                return QIcon(avatar)
        # Group: use group avatar if available
        conv_id = conv.get("conversation_id") or ""
        if conv_id in self._group_avatar_cache:
            return QIcon(self._make_circular_avatar(self._group_avatar_cache[conv_id]))
        gname = conv.get("name") or "G"
        # Request group avatar download if has avatar_file
        if conv.get("avatar_file") and conv_id and conv_id not in self._group_avatar_requested:
            self._group_avatar_requested.add(conv_id)
            self.bridge.get_group_avatar(conv_id)
        return QIcon(self._make_default_avatar(gname))

    def _get_conv_avatar_pixmap(self, conv: dict, size: int = 44) -> QPixmap:
        """Get avatar QPixmap for delegate painting."""
        is_dm = len(conv["members"]) == 2 and not conv.get("name")
        if is_dm:
            other = None
            for m in conv["members"]:
                if m.get("email") != self.bridge.client.email:
                    other = m
                    break
            if other:
                uid = other.get("user_id") or other.get("id") or ""
                uname = other.get("username") or other.get("email") or "?"
                if uid in self._avatar_cache:
                    avatar = self._make_circular_avatar(self._avatar_cache[uid], size)
                else:
                    avatar = self._make_default_avatar(uname, size)
                    if uid and uid not in self._avatar_requested:
                        self._avatar_requested.add(uid)
                        self.bridge.get_avatar(uid)
                if uid in self._online_users:
                    avatar = self._add_online_dot(avatar)
                return avatar
        conv_id = conv.get("conversation_id") or ""
        if conv_id in self._group_avatar_cache:
            return self._make_circular_avatar(self._group_avatar_cache[conv_id], size)
        gname = conv.get("name") or "G"
        if conv.get("avatar_file") and conv_id and conv_id not in self._group_avatar_requested:
            self._group_avatar_requested.add(conv_id)
            self.bridge.get_group_avatar(conv_id)
        return self._make_default_avatar(gname, size)

    def _build_ui(self):
        main_layout = QHBoxLayout(self)
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)

        splitter = QSplitter(Qt.Orientation.Horizontal)

        # Left panel - conversations
        left = QWidget()
        left_layout = QVBoxLayout(left)
        left_layout.setContentsMargins(8, 8, 4, 8)

        t = c()
        left.setObjectName("sidebarPanel")
        left.setStyleSheet(f"#sidebarPanel {{ background-color: {t.bg_tertiary}; }}")
        self._sidebar_panel = left

        header_row = QHBoxLayout()
        self._conv_label = QLabel("Conversations")
        self._conv_label.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent}; background: transparent;")
        header_row.addWidget(self._conv_label)
        header_row.addStretch()

        new_chat_btn = QPushButton("")
        new_chat_btn.setFixedSize(32, 32)
        new_chat_btn.setObjectName("toolBtn")
        new_chat_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder))
        new_chat_btn.setToolTip("New Chat")
        new_chat_btn.clicked.connect(self._on_new_chat)
        header_row.addWidget(new_chat_btn)

        group_btn = QPushButton("")
        group_btn.setFixedSize(32, 32)
        group_btn.setObjectName("toolBtn")
        group_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon))
        group_btn.setToolTip("New Group")
        group_btn.clicked.connect(self._on_new_group)
        header_row.addWidget(group_btn)

        profile_btn = QPushButton("")
        profile_btn.setFixedSize(32, 32)
        profile_btn.setObjectName("toolBtn")
        profile_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogInfoView))
        profile_btn.setToolTip("My Profile")
        profile_btn.clicked.connect(self._on_my_profile)
        header_row.addWidget(profile_btn)

        left_layout.addLayout(header_row)

        # Invitation section (hidden when empty)
        self.inv_label = QLabel("Pending Invitations")
        self.inv_label.setStyleSheet(f"font-weight: bold; font-size: 9pt; color: {t.warning}; margin-top: 4px;")
        self.inv_label.setVisible(False)
        left_layout.addWidget(self.inv_label)

        self.inv_list = QListWidget()
        self.inv_list.setMaximumHeight(120)
        self.inv_list.setVisible(False)
        self.inv_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.inv_list.customContextMenuRequested.connect(self._on_inv_context_menu)
        self.inv_list.setStyleSheet(
            f"QListWidget {{ background-color: {t.bg_primary}; border: 1px solid {t.warning}; border-radius: 6px; padding: 2px; }}"
            f"QListWidget::item {{ padding: 6px; color: {t.text_primary}; }}"
            f"QListWidget::item:hover {{ background-color: {t.bg_hover}; color: {t.text_primary}; }}"
        )
        left_layout.addWidget(self.inv_list)

        self.conv_list = QListWidget()
        self.conv_list.setIconSize(QSize(44, 44))
        # Override global QSS item styles — delegate handles all painting
        self.conv_list.setStyleSheet(
            f"QListWidget {{ background-color: {t.bg_tertiary}; border: none; padding: 0px; }}"
            f"QListWidget::item {{ padding: 0px; border: none; }}"
            f"QListWidget::item:selected {{ background: transparent; border: none; }}"
            f"QListWidget::item:hover {{ background: transparent; }}"
        )
        self._conv_delegate = ConversationDelegate(self.conv_list)
        self.conv_list.setItemDelegate(self._conv_delegate)
        self.conv_list.setMouseTracking(True)  # for hover painting
        self.conv_list.currentRowChanged.connect(self._on_conv_selected)
        self.conv_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.conv_list.customContextMenuRequested.connect(self._on_conv_list_context_menu)
        left_layout.addWidget(self.conv_list)

        # Bottom toolbar row: settings + logout
        bottom_row = QHBoxLayout()
        bottom_row.setContentsMargins(0, 4, 0, 0)

        settings_btn = QPushButton("\u2699 Settings")
        settings_btn.setObjectName("sidebarBtn")
        settings_btn.setToolTip("Open settings")
        settings_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; color: {t.text_secondary}; border: none; border-radius: 6px; padding: 8px 16px; }}"
            f"QPushButton:hover {{ background-color: {t.bg_hover}; }}"
        )
        settings_btn.clicked.connect(self._on_open_settings)
        self._settings_btn = settings_btn
        bottom_row.addWidget(settings_btn)

        logout_btn = QPushButton("\u2715")
        logout_btn.setFixedSize(32, 32)
        logout_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; color: {t.error}; border: none; "
            f"border-radius: 16px; font-size: 13pt; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.error}; color: {t.accent_text}; }}"
        )
        logout_btn.setToolTip("Logout")
        logout_btn.clicked.connect(self._on_logout)
        self._logout_btn = logout_btn
        bottom_row.addWidget(logout_btn)

        left_layout.addLayout(bottom_row)

        # Right panel - messages
        right = QWidget()
        right_layout = QVBoxLayout(right)
        right_layout.setContentsMargins(4, 8, 8, 8)

        # Chat header bar (56px height)
        chat_header_widget = QWidget()
        chat_header_widget.setFixedHeight(56)
        self._chat_header_widget = chat_header_widget
        chat_header_widget.setObjectName("chatHeader")
        chat_header_widget.setStyleSheet(
            f"#chatHeader {{ border-bottom: 1px solid {t.separator}; }}"
            f"#chatHeader QLabel, #chatHeader QPushButton {{ border: none; }}"
        )
        chat_header_row = QHBoxLayout(chat_header_widget)
        chat_header_row.setContentsMargins(12, 4, 8, 4)

        self.chat_header_avatar = QLabel()
        self.chat_header_avatar.setFixedSize(40, 40)
        self.chat_header_avatar.setStyleSheet("background: transparent;")
        self.chat_header_avatar.setVisible(False)
        chat_header_row.addWidget(self.chat_header_avatar)

        # Name + status text vertical stack
        name_status_layout = QVBoxLayout()
        name_status_layout.setSpacing(0)
        name_status_layout.setContentsMargins(6, 0, 0, 0)
        self.chat_header = QLabel("Select a conversation")
        self.chat_header.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent};")
        name_status_layout.addWidget(self.chat_header)

        self._chat_header_status = QLabel("")
        self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;")
        self._chat_header_status.setVisible(False)
        name_status_layout.addWidget(self._chat_header_status)
        chat_header_row.addLayout(name_status_layout)

        # E2E lock indicator
        self._e2e_label = QLabel("\U0001f512 End-to-end encrypted")
        self._e2e_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._e2e_label.setToolTip("End-to-end encrypted")
        self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;")
        self._e2e_label.setCursor(Qt.CursorShape.PointingHandCursor)
        self._e2e_label.mousePressEvent = self._on_e2e_label_clicked
        self._e2e_label.setVisible(False)
        chat_header_row.addWidget(self._e2e_label)

        self.connection_dot = QLabel("\u25cf")
        self.connection_dot.setFixedSize(16, 16)
        self.connection_dot.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.connection_dot.setStyleSheet(f"color: {t.success}; font-size: 11pt;")
        self.connection_dot.setToolTip("Connected")
        chat_header_row.addWidget(self.connection_dot)
        chat_header_row.addStretch()

        self.group_info_btn = QPushButton("")
        self.group_info_btn.setFixedSize(32, 32)
        self.group_info_btn.setObjectName("toolBtn")
        self.group_info_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation))
        self.group_info_btn.setToolTip("Group Info")
        self.group_info_btn.clicked.connect(self._on_group_info)
        self.group_info_btn.setVisible(False)
        chat_header_row.addWidget(self.group_info_btn)

        self.user_info_btn = QPushButton("")
        self.user_info_btn.setFixedSize(32, 32)
        self.user_info_btn.setObjectName("toolBtn")
        self.user_info_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogInfoView))
        self.user_info_btn.setToolTip("User Info")
        self.user_info_btn.clicked.connect(self._on_dm_user_info)
        self.user_info_btn.setVisible(False)
        chat_header_row.addWidget(self.user_info_btn)

        self.delete_conv_btn = QPushButton("")
        self.delete_conv_btn.setFixedSize(32, 32)
        self.delete_conv_btn.setStyleSheet(
            f"QPushButton {{ background: transparent; border: none; border-radius: 4px; padding: 4px; }}"
            f"QPushButton:hover {{ background-color: {t.error}; }}"
        )
        self.delete_conv_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
        self.delete_conv_btn.setToolTip("Delete conversation")
        self.delete_conv_btn.clicked.connect(self._on_delete_conv_btn)
        self.delete_conv_btn.setVisible(False)
        chat_header_row.addWidget(self.delete_conv_btn)

        self.add_member_btn = QPushButton("")
        self.add_member_btn.setFixedSize(32, 32)
        self.add_member_btn.setObjectName("toolBtn")
        self.add_member_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder))
        self.add_member_btn.setToolTip("Add Member")
        self.add_member_btn.clicked.connect(self._on_add_member)
        self.add_member_btn.setVisible(False)
        chat_header_row.addWidget(self.add_member_btn)

        self.pin_list_btn = QPushButton("\U0001f4cc")
        self.pin_list_btn.setFixedSize(32, 32)
        self.pin_list_btn.setObjectName("toolBtn")
        self.pin_list_btn.setToolTip("Pinned messages")
        self.pin_list_btn.clicked.connect(self._show_pinned_messages)
        self.pin_list_btn.setVisible(False)
        chat_header_row.addWidget(self.pin_list_btn)

        self.search_btn = QPushButton("")
        self.search_btn.setFixedSize(32, 32)
        self.search_btn.setObjectName("toolBtn")
        self.search_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView))
        self.search_btn.setToolTip("Search messages (Ctrl+F)")
        self.search_btn.clicked.connect(self._toggle_search)
        self.search_btn.setVisible(False)
        chat_header_row.addWidget(self.search_btn)

        right_layout.addWidget(chat_header_widget)

        # Search bar (hidden by default)
        self.search_widget = QWidget()
        search_row = QHBoxLayout(self.search_widget)
        search_row.setContentsMargins(0, 2, 0, 2)
        self.search_input = QLineEdit()
        self.search_input.setPlaceholderText("Search messages...")
        self.search_input.setStyleSheet(
            f"QLineEdit {{ background-color: {t.bg_secondary}; color: {t.text_primary}; "
            f"border: 1px solid {t.border}; border-radius: 4px; padding: 4px 8px; font-size: 10pt; }}"
        )
        self.search_input.textChanged.connect(self._on_search_text_changed)
        self.search_input.returnPressed.connect(self._on_search_next)
        # Escape in search input closes search
        QShortcut(QKeySequence("Escape"), self.search_input).activated.connect(self._close_search)
        search_row.addWidget(self.search_input, stretch=1)
        self.search_prev_btn = QPushButton("\u25b2")
        self.search_prev_btn.setFixedSize(28, 28)
        self.search_prev_btn.setObjectName("toolBtn")
        self.search_prev_btn.setToolTip("Previous match")
        self.search_prev_btn.clicked.connect(self._on_search_prev)
        search_row.addWidget(self.search_prev_btn)
        self.search_next_btn = QPushButton("\u25bc")
        self.search_next_btn.setFixedSize(28, 28)
        self.search_next_btn.setObjectName("toolBtn")
        self.search_next_btn.setToolTip("Next match")
        self.search_next_btn.clicked.connect(self._on_search_next)
        search_row.addWidget(self.search_next_btn)
        self.search_count_label = QLabel("0/0")
        self.search_count_label.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; min-width: 40px;")
        self.search_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        search_row.addWidget(self.search_count_label)
        self.search_close_btn = QPushButton("\u2715")
        self.search_close_btn.setFixedSize(28, 28)
        self.search_close_btn.setObjectName("toolBtn")
        self.search_close_btn.setToolTip("Close search")
        self.search_close_btn.clicked.connect(self._close_search)
        search_row.addWidget(self.search_close_btn)
        self.search_widget.setVisible(False)
        right_layout.addWidget(self.search_widget)

        # --- Pinned message banner ---
        self._pin_banner = QWidget()
        self._pin_banner.setStyleSheet(
            f"background-color:{t.border}; border-bottom:2px solid {t.pin_color};"
        )
        pin_banner_layout = QHBoxLayout(self._pin_banner)
        pin_banner_layout.setContentsMargins(10, 6, 10, 6)
        pin_icon = QLabel("\U0001f4cc")
        pin_icon.setStyleSheet("font-size:12pt; background:transparent; border:none;")
        pin_banner_layout.addWidget(pin_icon)
        self._pin_banner_label = QLabel("")
        self._pin_banner_label.setStyleSheet(
            f"color:{t.text_primary}; font-size:9pt; background:transparent; border:none;"
        )
        self._pin_banner_label.setCursor(Qt.CursorShape.PointingHandCursor)
        self._pin_banner_label.setWordWrap(False)
        pin_banner_layout.addWidget(self._pin_banner_label, stretch=1)
        pin_banner_close = QPushButton("\u2715")
        pin_banner_close.setFixedSize(20, 20)
        pin_banner_close.setStyleSheet(
            f"QPushButton {{ background:transparent; color:{t.text_muted}; border:none; font-size:10pt; }}"
            f"QPushButton:hover {{ color:{t.text_primary}; }}"
        )
        pin_banner_close.clicked.connect(lambda: self._pin_banner.setVisible(False))
        pin_banner_layout.addWidget(pin_banner_close)
        self._pin_banner.setVisible(False)
        self._pin_banner.setCursor(Qt.CursorShape.PointingHandCursor)
        self._pin_banner.mousePressEvent = self._on_pin_banner_clicked
        self._pin_banner_msg_id = None
        right_layout.addWidget(self._pin_banner)

        self.load_more_btn = QPushButton("Load older messages")
        self.load_more_btn.setObjectName("secondaryBtn")
        self.load_more_btn.clicked.connect(self._on_load_more)
        self.load_more_btn.setVisible(False)
        right_layout.addWidget(self.load_more_btn)

        # Message display area — QScrollArea with widget-based bubbles
        self._msg_scroll_area = QScrollArea()
        self._msg_scroll_area.setWidgetResizable(True)
        self._msg_scroll_area.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAlwaysOff
        )
        self._msg_scroll_area.setStyleSheet(
            f"QScrollArea {{ background-color: {t.bg_primary}; border: none; }}"
        )
        self._msg_scroll_area.setAcceptDrops(True)
        self._msg_scroll_area.installEventFilter(self)
        self._msg_scroll_area.viewport().setContextMenuPolicy(
            Qt.ContextMenuPolicy.NoContextMenu
        )
        self._msg_container = QWidget()
        self._msg_container.setStyleSheet(f"background-color: {t.bg_primary};")
        self._msg_layout = QVBoxLayout(self._msg_container)
        self._msg_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
        self._msg_layout.setContentsMargins(0, 8, 0, 8)
        self._msg_layout.setSpacing(2)
        self._msg_scroll_area.setWidget(self._msg_container)
        self._msg_widgets = []
        self.message_area = self._msg_scroll_area  # alias for scroll/jump/drop
        right_layout.addWidget(self._msg_scroll_area, stretch=1)

        # Smart scroll: track if user is near bottom
        self._is_near_bottom = True
        self._msg_scroll_area.verticalScrollBar().valueChanged.connect(
            self._on_scroll_changed
        )

        # Scroll-to-bottom floating button (hidden by default)
        self.jump_btn = QPushButton("\u2193")
        self.jump_btn.setParent(self.message_area)
        self.jump_btn.setVisible(False)
        self.jump_btn.setFixedSize(36, 36)
        self.jump_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; border-radius: 18px; "
            f"font-size: 14pt; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.accent_hover}; }}"
        )
        self.jump_btn.clicked.connect(self._scroll_to_bottom)

        # Reply preview (above input, blue left bar)
        self._reply_widget = QWidget()
        self._reply_widget.setVisible(False)
        reply_row = QHBoxLayout(self._reply_widget)
        reply_row.setContentsMargins(8, 4, 8, 0)
        reply_row.setSpacing(4)
        reply_bar = QFrame()
        reply_bar.setFixedWidth(3)
        reply_bar.setStyleSheet(f"background-color: {t.accent}; border-radius: 1px;")
        reply_row.addWidget(reply_bar)
        self.reply_label = QLabel("")
        self.reply_label.setStyleSheet(
            f"color: {t.accent}; font-style: italic; font-size: 9pt; "
            f"padding: 2px 4px; background: transparent;"
        )
        self.reply_label.setWordWrap(True)
        reply_row.addWidget(self.reply_label, stretch=1)
        reply_dismiss = QPushButton("\u2715")
        reply_dismiss.setFixedSize(20, 20)
        reply_dismiss.setStyleSheet(
            f"QPushButton {{ background:transparent; color:{t.text_muted}; border:none; font-size:10pt; }}"
            f"QPushButton:hover {{ color:{t.text_primary}; }}"
        )
        reply_dismiss.clicked.connect(self._cancel_reply)
        reply_row.addWidget(reply_dismiss)
        right_layout.addWidget(self._reply_widget)

        # Input row: [attach] [input] [send]
        input_row = QHBoxLayout()
        input_row.setSpacing(8)
        input_row.setContentsMargins(8, 4, 8, 4)

        self._attach_btn = QPushButton("\U0001f4ce")
        attach_btn = self._attach_btn
        attach_btn.setFixedSize(40, 40)
        attach_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        attach_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.bg_secondary}; border: none; "
            f"border-radius: 20px; font-size: 14pt; }}"
            f"QPushButton:hover {{ background-color: {t.bg_hover}; }}"
        )
        self._attach_menu = QMenu(attach_btn)
        self._attach_menu.addAction("\U0001f5bc Image", self._on_attach_image)
        self._attach_menu.addAction("\U0001f4c4 File", self._on_attach_file)
        attach_btn.clicked.connect(lambda: self._attach_menu.exec(
            attach_btn.mapToGlobal(attach_btn.rect().topLeft() - QPoint(0, self._attach_menu.sizeHint().height()))
        ))
        input_row.addWidget(attach_btn)

        self.msg_input = MessageInput()
        self.msg_input.send_requested.connect(self._on_send)
        self.msg_input.textChanged.connect(self._on_input_changed)
        self.msg_input.file_dropped.connect(self._on_file_dropped)
        input_row.addWidget(self.msg_input, stretch=1)

        self._send_btn = QPushButton("\u27a4")
        send_btn = self._send_btn
        send_btn.setFixedSize(40, 40)
        send_btn.setCursor(Qt.CursorShape.PointingHandCursor)
        send_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; "
            f"border: none; border-radius: 20px; font-size: 14pt; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.accent_hover}; }}"
        )
        send_btn.clicked.connect(self._on_send)
        input_row.addWidget(send_btn)
        right_layout.addLayout(input_row)

        self.char_counter = QLabel(f"0 / {MAX_INPUT_CHARS}")
        self.char_counter.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt; padding: 0 4px;")
        self.char_counter.setAlignment(Qt.AlignmentFlag.AlignRight)
        right_layout.addWidget(self.char_counter)

        self.reencrypt_label = QLabel("")
        self.reencrypt_label.setStyleSheet(
            f"background-color: {t.bg_secondary}; border-radius: 6px; "
            f"padding: 8px 12px; color: {t.success}; font-weight: bold;"
        )
        self.reencrypt_label.setVisible(False)
        right_layout.addWidget(self.reencrypt_label)

        splitter.addWidget(left)
        splitter.addWidget(right)
        splitter.setStretchFactor(0, 1)
        splitter.setStretchFactor(1, 3)

        # Wrap splitter + status bar in vertical layout for full-width status bar
        wrapper = QVBoxLayout()
        wrapper.setContentsMargins(0, 0, 0, 0)
        wrapper.setSpacing(0)
        wrapper.addWidget(splitter)

        # Status bar (permanent, fixed height, full width — no layout jumping)
        self.status_bar = QLabel("")
        self.status_bar.setFixedHeight(24)
        self.status_bar.setStyleSheet(
            f"background-color: {t.bg_tertiary}; border-radius: 0px; "
            f"padding: 0 8px; color: {t.success}; font-size: 8pt;"
        )
        self.status_bar.setCursor(Qt.CursorShape.PointingHandCursor)
        self.status_bar.mousePressEvent = self._on_status_bar_click
        self._status_bar_conv_id = None
        wrapper.addWidget(self.status_bar)

        main_layout.addLayout(wrapper)

    def _connect_signals(self):
        self.bridge.conversations_loaded.connect(self._on_conversations_loaded)
        self.bridge.messages_loaded.connect(self._on_messages_loaded)
        self.bridge.older_messages_loaded.connect(self._on_older_messages_loaded)
        self.bridge.message_sent.connect(self._on_message_sent)
        self.bridge.message_sent_payload.connect(self._on_message_sent_payload)
        self.bridge.new_notification.connect(self._on_notification)
        self.bridge.add_member_result.connect(self._on_add_member_result)
        self.bridge.authorize_result.connect(self._on_authorize_result)
        self.bridge.rotate_result.connect(self._on_rotate_result)
        self.bridge.password_changed.connect(self._on_password_changed)
        self.bridge.username_changed.connect(self._on_username_changed)
        self.bridge.reencrypt_status.connect(self._on_reencrypt_status)
        self.bridge.messages_read_notification.connect(self._on_messages_read)
        self.bridge.message_delivered_notification.connect(self._on_message_delivered)
        self.bridge.remove_member_result.connect(self._on_remove_member_result)
        self.bridge.message_deleted_notification.connect(self._on_message_deleted)
        self.bridge.delete_message_result.connect(self._on_delete_message_result)
        self.bridge.image_sent.connect(self._on_image_sent)
        self.bridge.image_downloaded.connect(self._on_image_downloaded)
        self.bridge.file_sent.connect(self._on_file_sent)
        self.bridge.file_downloaded.connect(self._on_file_downloaded)
        self.bridge.conversation_updated.connect(self._on_conversation_updated)
        self.bridge.connection_state_changed.connect(self._on_connection_state_changed)
        self.bridge.group_left.connect(self._on_group_left)
        self.bridge.group_renamed.connect(self._on_group_renamed)
        self.bridge.conversation_deleted.connect(self._on_conversation_deleted)
        self.bridge.avatar_loaded.connect(self._on_avatar_for_conv_list)
        self.bridge.invitations_loaded.connect(self._on_invitations_loaded)
        self.bridge.invitation_result.connect(self._on_invitation_result)
        self.bridge.invitation_received.connect(self._on_invitation_received)
        self.bridge.online_status_changed.connect(self._on_online_status_changed)
        self.bridge.online_users_loaded.connect(self._on_online_users_loaded)
        self.bridge.group_avatar_loaded.connect(self._on_group_avatar_for_conv_list)
        self.bridge.group_avatar_updated.connect(self._on_group_avatar_updated)
        self.bridge.session_reset_notification.connect(self._on_session_reset)
        self.bridge.reaction_notification.connect(self._on_reaction_notification)
        self.bridge.pin_notification.connect(self._on_pin_notification)
        self.bridge.unpin_notification.connect(self._on_unpin_notification)
        self.bridge.pinned_messages_loaded.connect(self._on_pinned_messages_loaded)
        self.bridge.forward_result.connect(self._on_forward_result)
        self.bridge.key_change_warning.connect(self._on_key_change_warning)
        self._show_verification_dialog_signal.connect(self._show_verification_dialog)

    # ------------------------------------------------------------------
    # Favorites
    # ------------------------------------------------------------------

    def _favorites_path(self):
        from chat_core import get_key_dir
        return get_key_dir(self.bridge.client.email) / "favorites.json"

    def _load_favorites(self) -> set[str]:
        try:
            p = self._favorites_path()
            if p.exists():
                return set(json.loads(p.read_text()))
        except Exception:
            pass
        return set()

    def _save_favorites(self):
        try:
            self._favorites_path().write_text(json.dumps(list(self._favorites)))
        except Exception:
            pass

    def _on_conv_list_context_menu(self, pos):
        item = self.conv_list.itemAt(pos)
        if not item:
            return
        conv_id = item.data(Qt.ItemDataRole.UserRole)
        if not conv_id:
            return
        from PyQt6.QtWidgets import QMenu
        menu = QMenu(self)
        is_fav = conv_id in self._favorites
        action = menu.addAction("Odebrat z oblibených" if is_fav else "Přidat do oblíbených")
        result = menu.exec(self.conv_list.mapToGlobal(pos))
        if result == action:
            if is_fav:
                self._favorites.discard(conv_id)
            else:
                self._favorites.add(conv_id)
            self._save_favorites()
            self._rebuild_conv_list()

    # ------------------------------------------------------------------
    # Conversation list helpers
    # ------------------------------------------------------------------

    def _get_conv_display_name(self, conv: dict) -> str:
        """Get display name for a conversation (used for sorting and labels)."""
        others = [m.get("username") or m.get("email") or "?" for m in conv["members"]
                  if m.get("email") != self.bridge.client.email]
        return conv.get("name") or (", ".join(others) if others else self.bridge.client.username)

    def _get_conv_other_user_id(self, conv: dict) -> str:
        """Get the other user's ID in a DM conversation (empty string for groups)."""
        is_dm = len(conv["members"]) == 2 and not conv.get("name")
        if not is_dm:
            return ""
        for m in conv["members"]:
            if m.get("email") != self.bridge.client.email:
                return m.get("user_id") or m.get("id") or ""
        return ""

    def _get_conv_sort_key(self, conv: dict) -> tuple:
        """Sort key: favorites first, then online DMs, then rest — alphabetically within each."""
        conv_id = conv.get("conversation_id", "")
        is_fav = 0 if conv_id in self._favorites else 1
        other_uid = self._get_conv_other_user_id(conv)
        is_online = 0 if other_uid and other_uid in self._online_users else 1
        name = self._get_conv_display_name(conv).lower()
        return (is_fav, is_online, name)

    def _on_conversations_loaded(self, convs):
        self.conversations = convs
        # Populate unread counts from server (covers messages received while offline)
        for cv in convs:
            cid = cv["conversation_id"]
            server_unread = cv.get("unread_count", 0)
            # Use the higher of server vs local (local may have newer real-time notifications)
            if server_unread > self._unread_counts.get(cid, 0):
                self._unread_counts[cid] = server_unread
        self._rebuild_conv_list()

    def _rebuild_conv_list(self):
        """Sort and rebuild the conversation list widget."""
        if not self.conversations:
            return
        # Sort: favorites first, then online DMs, then rest — alphabetically within each group
        self.conversations.sort(key=self._get_conv_sort_key)
        prev_id = self.current_conv_id
        self.conv_list.blockSignals(True)
        self.conv_list.clear()
        select_row = -1
        for i, cv in enumerate(self.conversations):
            conv_id = cv["conversation_id"]
            name = self._get_conv_display_name(cv)
            count = self._unread_counts.get(conv_id, 0)
            is_fav = conv_id in self._favorites
            # Preview + timestamp from last-message cache
            preview_text, preview_ts, receipt_st = self._last_message_cache.get(conv_id, ("", "", ""))
            rel_ts = _relative_time(preview_ts)
            # Avatar pixmap
            avatar_pix = self._get_conv_avatar_pixmap(cv)

            # Verification status (DMs only)
            verified_status = ""
            is_dm = len(cv["members"]) == 2 and not cv.get("name")
            if is_dm:
                peer_uid = self._get_conv_other_user_id(cv)
                if peer_uid:
                    verified_status = self.bridge.client.get_verification_status(peer_uid)

            item = QListWidgetItem()
            item.setSizeHint(QSize(0, ConversationDelegate.ITEM_HEIGHT))
            item.setData(ROLE_CONV_ID, conv_id)
            item.setData(ROLE_DISPLAY_NAME, name)
            item.setData(ROLE_PREVIEW, preview_text)
            item.setData(ROLE_TIMESTAMP, rel_ts)
            item.setData(ROLE_UNREAD, count)
            item.setData(ROLE_IS_FAV, is_fav)
            item.setData(ROLE_AVATAR, avatar_pix)
            item.setData(ROLE_VERIFIED, verified_status)
            item.setData(ROLE_RECEIPT, receipt_st)
            self.conv_list.addItem(item)
            if conv_id == prev_id:
                select_row = i
        self.conv_list.blockSignals(False)
        if select_row >= 0:
            self.conv_list.setCurrentRow(select_row)

    def _on_conversation_updated(self):
        """Refresh conversation list when a conversation is created/member added/removed."""
        self.bridge.load_conversations()

    def _on_periodic_refresh(self):
        """Periodic refresh: reload invitations and refresh avatars in small batches."""
        # Keep this first so invitations are not queued behind a large avatar burst.
        self.bridge.list_invitations()

        uids = list(self._avatar_requested)
        if uids:
            n = len(uids)
            batch = min(self._AVATAR_REFRESH_BATCH, n)
            start = self._avatar_refresh_cursor % n
            for i in range(batch):
                uid = uids[(start + i) % n]
                self.bridge.get_avatar(uid)
            self._avatar_refresh_cursor = (start + batch) % n

        conv_ids = list(self._group_avatar_requested)
        if conv_ids:
            n = len(conv_ids)
            batch = min(self._GROUP_AVATAR_REFRESH_BATCH, n)
            start = self._group_avatar_refresh_cursor % n
            for i in range(batch):
                conv_id = conv_ids[(start + i) % n]
                self.bridge.get_group_avatar(conv_id)
            self._group_avatar_refresh_cursor = (start + batch) % n

    def _on_online_users_loaded(self, user_ids):
        self._online_users = set(user_ids)
        self._rebuild_conv_list()

    def _on_online_status_changed(self, user_id, is_online):
        if is_online:
            self._online_users.add(user_id)
        else:
            self._online_users.discard(user_id)
        self._rebuild_conv_list()

    def _on_avatar_for_conv_list(self, user_id, data):
        """Cache downloaded avatar and refresh conversation list icons + chat header."""
        qimg = _safe_load_image(data)
        if qimg is not None:
            self._avatar_cache[user_id] = QPixmap.fromImage(qimg)
            self._update_conv_list_styles()
            # Refresh chat header avatar if current conv uses this user's avatar
            self._refresh_chat_header_avatar()

    def _on_group_avatar_for_conv_list(self, conv_id, data):
        """Cache downloaded group avatar and refresh conversation list icons + chat header."""
        qimg = _safe_load_image(data)
        if qimg is not None:
            self._group_avatar_cache[conv_id] = QPixmap.fromImage(qimg)
            self._update_conv_list_styles()
            # Refresh chat header avatar if current conv is this group
            self._refresh_chat_header_avatar()

    def _on_group_avatar_updated(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "Group Avatar", msg)

    def _on_invitations_loaded(self, invitations):
        self._pending_invitations = invitations
        self.inv_list.clear()
        if not invitations:
            self.inv_label.setVisible(False)
            self.inv_list.setVisible(False)
            return
        self.inv_label.setVisible(True)
        self.inv_list.setVisible(True)
        for inv in invitations:
            conv_name = inv.get("conversation_name") or "Unnamed group"
            inviter = inv.get("invited_by_username", "someone")
            label = f"{conv_name} (from {inviter})"
            item = QListWidgetItem(label)
            item.setData(Qt.ItemDataRole.UserRole, inv["conversation_id"])
            self.inv_list.addItem(item)

    def _on_invitation_result(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "Invitation", msg)

    def _on_invitation_received(self, data):
        """New invitation received via push notification."""
        self.bridge.list_invitations()
        conv_name = data.get("conversation_name") or "a group"
        inviter = data.get("invited_by_username", "Someone")
        t = c()
        self.status_bar.setText(f"{inviter} invited you to {conv_name}")
        self.status_bar.setStyleSheet(
            f"background-color: {t.bg_tertiary}; border-radius: 0px; "
            f"padding: 0 8px; color: {t.warning}; font-size: 8pt; font-weight: bold;"
        )
        self._status_bar_conv_id = None
        QTimer.singleShot(5000, self._clear_status_bar)

    def _on_inv_context_menu(self, pos):
        item = self.inv_list.itemAt(pos)
        if not item:
            return
        conv_id = item.data(Qt.ItemDataRole.UserRole)
        if not conv_id:
            return
        menu = QMenu(self)
        accept_action = menu.addAction("Accept")
        decline_action = menu.addAction("Decline")
        chosen = menu.exec(self.inv_list.mapToGlobal(pos))
        if chosen == accept_action:
            self.bridge.accept_invitation(conv_id)
        elif chosen == decline_action:
            self.bridge.decline_invitation(conv_id)

    def _on_connection_state_changed(self, state):
        t = c()
        if state == "connected":
            self.connection_dot.setStyleSheet(f"color: {t.success}; font-size: 11pt;")
            self.connection_dot.setToolTip("Connected")
            self.status_bar.setText("Connected")
            self.status_bar.setStyleSheet(
                f"background-color: {t.bg_tertiary}; border-radius: 0px; "
                f"padding: 0 8px; color: {t.success}; font-size: 8pt;"
            )
            QTimer.singleShot(3000, self._clear_status_bar)
        elif state == "disconnected":
            self.connection_dot.setStyleSheet(f"color: {t.error}; font-size: 11pt;")
            self.connection_dot.setToolTip("Disconnected")
            self.status_bar.setText("Disconnected from server")
            self.status_bar.setStyleSheet(
                f"background-color: {t.bg_tertiary}; border-radius: 0px; "
                f"padding: 0 8px; color: {t.error}; font-size: 8pt; font-weight: bold;"
            )
            self._status_bar_conv_id = None
        elif state == "reconnecting":
            self.connection_dot.setStyleSheet(f"color: {t.warning}; font-size: 11pt;")
            self.connection_dot.setToolTip("Reconnecting...")
            self.status_bar.setText("Reconnecting...")
            self.status_bar.setStyleSheet(
                f"background-color: {t.bg_tertiary}; border-radius: 0px; "
                f"padding: 0 8px; color: {t.warning}; font-size: 8pt;"
            )
            self._status_bar_conv_id = None
        elif state == "revoked":
            self.connection_dot.setStyleSheet(f"color: {t.error}; font-size: 11pt;")
            self.connection_dot.setToolTip("Access revoked")
            # Clear conversation list
            self.conv_list.clear()
            self.conversations = []
            self._unread_counts.clear()
            # Clear open conversation
            self.current_conv_id = None
            self.msg_input.drop_enabled = False
            self.chat_header.setText("Select a conversation")
            self.chat_header_avatar.setVisible(False)
            self._clear_message_area()
            self.msg_input.setEnabled(False)
            self.group_info_btn.setVisible(False)
            self.user_info_btn.setVisible(False)
            self.add_member_btn.setVisible(False)
            self.delete_conv_btn.setVisible(False)
            QMessageBox.warning(self, "Access Revoked",
                                "Your keys were rotated on another device. "
                                "This session is no longer valid.")

    def _on_scroll_changed(self, value):
        sb = self.message_area.verticalScrollBar()
        self._is_near_bottom = (sb.maximum() - value) < 60
        if self._is_near_bottom:
            self.jump_btn.setVisible(False)
        elif sb.maximum() > 0:
            self.jump_btn.setText("\u2193")
            self.jump_btn.setVisible(True)
            self._position_jump_btn()

    def _scroll_to_bottom(self):
        sb = self.message_area.verticalScrollBar()
        sb.setValue(sb.maximum())
        self.jump_btn.setVisible(False)

    def _position_jump_btn(self):
        w = self.message_area.width()
        self.jump_btn.move((w - 36) // 2, self.message_area.height() - 48)

    def _clear_status_bar(self):
        self.status_bar.setText("")
        t = c()
        self.status_bar.setStyleSheet(
            f"background-color: {t.bg_tertiary}; border-radius: 0px; "
            f"padding: 0 8px; color: {t.success}; font-size: 8pt;"
        )
        self._status_bar_conv_id = None

    def _on_status_bar_click(self, event):
        conv_id = self._status_bar_conv_id
        if conv_id:
            for i, c in enumerate(self.conversations):
                if c["conversation_id"] == conv_id:
                    self.conv_list.setCurrentRow(i)
                    self._clear_status_bar()
                    break

    def _update_chat_header_avatar(self, conv):
        """Set the circular avatar next to the conversation name in the chat header."""
        is_dm = len(conv["members"]) == 2 and not conv.get("name")
        size = 40
        t = c()
        if is_dm:
            other = None
            for m in conv["members"]:
                if m.get("email") != self.bridge.client.email:
                    other = m
                    break
            if other:
                uid = other.get("user_id") or other.get("id") or ""
                uname = other.get("username") or other.get("email") or "?"
                if uid in self._avatar_cache:
                    avatar = self._make_circular_avatar(self._avatar_cache[uid], size)
                else:
                    avatar = self._make_default_avatar(uname, size)
                self.chat_header_avatar.setPixmap(avatar)
                self.chat_header_avatar.setVisible(True)
                # Online status text
                if uid in self._online_users:
                    self._chat_header_status.setText("Online")
                    self._chat_header_status.setStyleSheet(f"color: {t.success}; font-size: 8pt;")
                else:
                    self._chat_header_status.setText("Offline")
                    self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;")
                self._chat_header_status.setVisible(True)
            else:
                self.chat_header_avatar.setVisible(False)
                self._chat_header_status.setVisible(False)
        else:
            conv_id = conv.get("conversation_id") or ""
            gname = conv.get("name") or "G"
            if conv_id in self._group_avatar_cache:
                avatar = self._make_circular_avatar(self._group_avatar_cache[conv_id], size)
            else:
                avatar = self._make_default_avatar(gname, size)
            self.chat_header_avatar.setPixmap(avatar)
            self.chat_header_avatar.setVisible(True)
            # Member count for groups
            member_count = len(conv.get("members", []))
            self._chat_header_status.setText(f"{member_count} members")
            self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;")
            self._chat_header_status.setVisible(True)
        # Show E2E indicator with verification status
        if is_dm:
            peer_uid = ""
            for m in conv["members"]:
                if m.get("email") != self.bridge.client.email:
                    peer_uid = m.get("user_id") or m.get("id") or ""
                    break
            v_status = self.bridge.client.get_verification_status(peer_uid) if peer_uid else ""
            if v_status == "verified":
                self._e2e_label.setText("\u2705 Verified")
                self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.success}; background: transparent;")
                self._e2e_label.setToolTip("Identity verified — click to view safety number")
            elif v_status == "trusted":
                self._e2e_label.setText("\U0001f512 Encrypted")
                self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;")
                self._e2e_label.setToolTip("End-to-end encrypted (not verified) — click to verify")
            else:
                self._e2e_label.setText("\U0001f512 Encrypted")
                self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;")
                self._e2e_label.setToolTip("End-to-end encrypted — click to verify")
        else:
            self._e2e_label.setText("\U0001f512 End-to-end encrypted")
            self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;")
            self._e2e_label.setToolTip("End-to-end encrypted")
        self._e2e_label.setVisible(True)

    def _refresh_chat_header_avatar(self):
        """Re-render chat header avatar for the currently selected conversation."""
        if not self.current_conv_id:
            return
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                self._update_chat_header_avatar(cv)
                return

    def _update_conv_list_styles(self):
        """Update delegate data roles for all items (after avatar/unread changes)."""
        for i in range(self.conv_list.count()):
            item = self.conv_list.item(i)
            conv_id = item.data(ROLE_CONV_ID)
            count = self._unread_counts.get(conv_id, 0)
            conv = None
            for cv in self.conversations:
                if cv["conversation_id"] == conv_id:
                    conv = cv
                    break
            if conv:
                item.setData(ROLE_DISPLAY_NAME, self._get_conv_display_name(conv))
                item.setData(ROLE_AVATAR, self._get_conv_avatar_pixmap(conv))
                item.setData(ROLE_IS_FAV, conv_id in self._favorites)
            item.setData(ROLE_UNREAD, count)
            # Update preview/timestamp from cache
            preview_text, preview_ts, receipt_st = self._last_message_cache.get(conv_id, ("", "", ""))
            item.setData(ROLE_PREVIEW, preview_text)
            item.setData(ROLE_TIMESTAMP, _relative_time(preview_ts))
            item.setData(ROLE_RECEIPT, receipt_st)

    def _on_conv_selected(self, row):
        if row < 0 or row >= len(self.conversations):
            return
        conv = self.conversations[row]
        self.current_conv_id = conv["conversation_id"]
        self.msg_input.drop_enabled = True
        others = [m.get("username") or m.get("email") or "?" for m in conv["members"]
                  if m.get("email") != self.bridge.client.email]
        header = conv.get("name") or (", ".join(others) if others else self.bridge.client.username)
        self.chat_header.setText(header)
        # Set avatar in chat header
        self._update_chat_header_avatar(conv)
        is_group = len(conv["members"]) > 2 or conv.get("name")
        self._is_dm = not is_group
        self.add_member_btn.setVisible(bool(is_group))
        self.group_info_btn.setVisible(bool(is_group))
        self.user_info_btn.setVisible(self._is_dm)
        # DMs: always show delete. Groups: only show for creator.
        if self._is_dm:
            self.delete_conv_btn.setVisible(True)
        else:
            my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
            self.delete_conv_btn.setVisible(conv.get("created_by") == my_user_id)
        self.reply_to_id = None
        self._reply_widget.setVisible(False)
        self._has_more_messages = True
        self.load_more_btn.setVisible(False)
        self._unread_counts.pop(self.current_conv_id, None)
        self._update_conv_list_styles()
        self.search_btn.setVisible(True)
        self.pin_list_btn.setVisible(True)
        self._pin_banner.setVisible(False)
        self._close_search()
        self.bridge.load_messages(self.current_conv_id)

    def _on_e2e_label_clicked(self, event):
        """Open VerificationDialog when E2E label is clicked (DMs only)."""
        if not self.current_conv_id or not getattr(self, "_is_dm", False):
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        peer_uid = self._get_conv_other_user_id(conv)
        if not peer_uid:
            return
        peer_name = ""
        for m in conv["members"]:
            if m.get("email") != self.bridge.client.email:
                peer_name = m.get("username") or m.get("email") or "?"
                break
        # Ensure identity key is in cache
        self.bridge.schedule(self._ensure_and_show_verification(peer_uid, peer_name))

    async def _ensure_and_show_verification(self, peer_uid: str, peer_name: str):
        """Ensure we have the peer's identity key, then show verification dialog."""
        await self.bridge.client._get_user_info(user_id=peer_uid)
        # Emit signal back to Qt thread
        self._show_verification_dialog_signal.emit(peer_uid, peer_name)

    def _show_verification_dialog(self, peer_uid: str, peer_name: str):
        dlg = VerificationDialog(self.bridge, peer_uid, peer_name, parent=self)
        dlg.exec()
        # Refresh conv list and header to reflect any verification changes
        self._rebuild_conv_list()
        if self.current_conv_id:
            for cv in self.conversations:
                if cv["conversation_id"] == self.current_conv_id:
                    self._update_chat_header_avatar(cv)
                    break

    def _on_key_change_warning(self, user_id: str, username: str, old_key_hex: str, was_verified: bool, new_key_bytes: bytes = b""):
        """Show warning dialog when a contact's identity key has changed."""
        t = c()
        severity = "CRITICAL" if was_verified else "WARNING"
        name = username or user_id[:8]
        msg = (
            f"The identity key for <b>{name}</b> has changed!\n\n"
            f"This could mean:\n"
            f"- They re-installed the app or got a new device\n"
            f"- Someone may be intercepting your messages\n\n"
        )
        if was_verified:
            msg += "<b>This contact was previously verified.</b> You should re-verify."

        dlg = QDialog(self)
        dlg.setMinimumWidth(400)
        lay = _make_frameless(dlg, f"Identity Key Changed ({severity})")

        warning_label = QLabel(msg)
        warning_label.setWordWrap(True)
        warning_label.setStyleSheet(f"color: {t.text_primary};")
        lay.addWidget(warning_label)

        btn_row = QHBoxLayout()
        accept_btn = QPushButton("Accept New Key")
        accept_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.warning}; color: {t.bg_primary}; "
            f"font-weight: bold; padding: 8px 16px; border-radius: 6px; }}"
        )
        accept_btn.clicked.connect(lambda: self._accept_key_change(user_id, new_key_bytes, dlg))
        btn_row.addWidget(accept_btn)

        close_btn = QPushButton("Dismiss")
        close_btn.setObjectName("secondaryBtn")
        close_btn.clicked.connect(dlg.accept)
        btn_row.addWidget(close_btn)

        lay.addLayout(btn_row)
        dlg.exec()

    def _accept_key_change(self, user_id: str, new_key_bytes: bytes, dlg: QDialog):
        if new_key_bytes:
            self.bridge.client.accept_key_change(user_id, new_key_bytes)
        dlg.accept()
        self._rebuild_conv_list()

    def _on_messages_loaded(self, conv_id, messages):
        if conv_id != self.current_conv_id:
            return
        self.current_messages = messages
        # Update last-message cache for conversation list preview
        if messages:
            last = messages[-1]
            preview = last.get("text", "")
            if last.get("deleted"):
                preview = "Message deleted"
            elif last.get("image") and not preview:
                preview = "Sent an image"
            elif last.get("file") and not preview:
                preview = "Sent a file"
            receipt = self._compute_receipt_status(last)
            self._last_message_cache[conv_id] = (preview[:60], last.get("created_at", ""), receipt)
            self._update_conv_list_styles()
        # Show "Load older" if we got a full batch (there may be more)
        self._has_more_messages = len(messages) >= 50
        self.load_more_btn.setVisible(self._has_more_messages)
        self._render_messages()
        self._update_pin_banner()

    def _compute_receipt_status(self, m) -> str:
        """Return receipt status for a message: 'read', 'delivered', 'sent', or ''."""
        my_uid = (self.bridge.client.session.get("user_id", "")
                  if self.bridge and self.bridge.client and self.bridge.client.session else "")
        if not my_uid or m.get("sender_id") != my_uid:
            return ""
        read_by = m.get("read_by", [])
        if any(r.get("user_id") != my_uid for r in read_by):
            return "read"
        delivered_to = m.get("delivered_to", [])
        if any(d.get("user_id") != my_uid for d in delivered_to):
            return "delivered"
        return "sent"

    def _render_messages(self, scroll_to_bottom=True):
        """Clear and rebuild all message bubble widgets."""
        self._msg_widgets = []
        while self._msg_layout.count() > 0:
            item = self._msg_layout.takeAt(0)
            w = item.widget()
            if w:
                w.deleteLater()
        for i, m in enumerate(self.current_messages):
            w = self._create_message_widget(m, i)
            self._msg_layout.addWidget(w)
            self._msg_widgets.append(w)
        if scroll_to_bottom:
            QTimer.singleShot(10, self._scroll_to_bottom)

    def _clear_message_area(self):
        """Remove all message widgets from the scroll area."""
        self._msg_widgets = []
        self.current_messages = []
        while self._msg_layout.count() > 0:
            item = self._msg_layout.takeAt(0)
            w = item.widget()
            if w:
                w.deleteLater()

    def _decode_thumbnail(self, image_info):
        """Decode base64 thumbnail to QPixmap."""
        thumbnail_b64 = image_info.get("thumbnail", "")
        if not thumbnail_b64:
            return None
        from protocol import decode_binary
        try:
            thumb_bytes = decode_binary(thumbnail_b64)
            qimg = _safe_load_image(thumb_bytes)
            if qimg is not None:
                return QPixmap.fromImage(qimg)
        except Exception:
            pass
        return None

    def _create_message_widget(self, m, index):
        """Create a widget tree for a single message bubble."""
        t = c()
        my_uid = (self.bridge.client.session.get("user_id", "")
                  if self.bridge.client.session else "")
        is_me = (m.get("sender_id") == my_uid if my_uid
                 else m.get("sender") == self.bridge.client.username)

        # -- Wrapper for left/right alignment --
        wrapper = QWidget()
        wrapper.setStyleSheet("background: transparent;")
        wrapper._msg_index = index
        wlay = QHBoxLayout(wrapper)
        wlay.setContentsMargins(12, 2, 12, 2)
        wlay.setSpacing(0)

        # -- Deleted message --
        if m.get("deleted"):
            ts = m.get("created_at", "")
            time_str = _format_msg_time(ts)
            del_bubble = MessageBubble(t.bg_secondary)
            del_bubble._msg_index = index
            dlay = QVBoxLayout(del_bubble)
            dlay.setContentsMargins(14, 8, 14, 8)
            dl = QLabel(f"\u00b7 {time_str}  Message deleted")
            dl.setStyleSheet(
                f"color: {t.text_muted}; font-style: italic; "
                f"font-size: 10pt; background: transparent;"
            )
            dlay.addWidget(dl)
            if is_me:
                wlay.addStretch(1)
            wlay.addWidget(del_bubble)
            if not is_me:
                wlay.addStretch(1)
            return wrapper

        sender = m.get("sender", "???")
        text = m.get("text", "")

        # Determine colours
        if is_me:
            bubble_bg = t.bubble_sent_bg
            text_color = t.bubble_sent_text
            meta_color = t.bubble_sent_meta
            sender_color = t.accent
        else:
            bubble_bg = t.bubble_recv_bg
            text_color = t.bubble_recv_text
            meta_color = t.bubble_recv_meta
            sender_color = t.sender_name_other

        if is_me:
            wlay.addStretch(1)

        # -- Bubble --
        bubble = MessageBubble(bubble_bg)
        bubble._msg_index = index
        bubble.setSizePolicy(
            QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum
        )
        bubble.setMaximumWidth(600)
        bubble.setMinimumWidth(80)

        blay = QVBoxLayout(bubble)
        blay.setContentsMargins(14, 8, 14, 8)
        blay.setSpacing(3)

        # -- Forwarded header --
        fwd = m.get("forwarded_from")
        if fwd:
            fwd_sender = fwd.get("sender", "???")
            fwd_esc = (fwd_sender.replace("&", "&amp;")
                       .replace("<", "&lt;").replace(">", "&gt;"))
            fwd_label = QLabel(
                f'<span style="color:{meta_color}; font-size:9pt;">'
                f'Forwarded from <b style="color:{t.info};">{fwd_esc}</b>'
                f'</span>'
            )
            fwd_label.setTextFormat(Qt.TextFormat.RichText)
            fwd_label.setStyleSheet(
                f"background: transparent; border-left: 2px solid {t.info}; "
                f"padding-left: 6px; margin-bottom: 2px;"
            )
            blay.addWidget(fwd_label)

        # -- Reply context --
        if m.get("reply_to"):
            for orig in self.current_messages:
                if orig.get("message_id") == m.get("reply_to"):
                    orig_sender = orig.get("sender", "???")
                    orig_esc = (orig_sender.replace("&", "&amp;")
                                .replace("<", "&lt;").replace(">", "&gt;"))
                    orig_text = orig.get("text", "")[:50]
                    orig_text_esc = (orig_text.replace("&", "&amp;")
                                     .replace("<", "&lt;").replace(">", "&gt;"))
                    reply_lbl = QLabel(
                        f'<b style="color:{t.text_secondary};">{orig_esc}</b><br/>'
                        f'<span style="color:{meta_color}; font-size:9pt;">'
                        f'{orig_text_esc}</span>'
                    )
                    reply_lbl.setTextFormat(Qt.TextFormat.RichText)
                    reply_lbl.setWordWrap(True)
                    reply_lbl.setStyleSheet(
                        f"background: transparent; "
                        f"border-left: 2px solid {t.scrollbar}; "
                        f"padding-left: 6px; margin-bottom: 2px;"
                    )
                    blay.addWidget(reply_lbl)
                    break

        # -- Header (sender name + pin — groups only) --
        timestamp = m.get("created_at", "")
        sender_esc = (sender.replace("&", "&amp;")
                      .replace("<", "&lt;").replace(">", "&gt;"))
        pin_html = ""
        if m.get("pinned_at"):
            pin_html = f' <span style="color:{t.pin_color};">\U0001f4cc</span>'

        is_dm = self._is_dm
        if not is_dm:
            header_html = (
                f'<b style="color:{sender_color};">{sender_esc}</b>'
                f'{pin_html}'
            )
            header_label = QLabel(header_html)
            header_label.setTextFormat(Qt.TextFormat.RichText)
            header_label.setStyleSheet("background: transparent;")
            blay.addWidget(header_label)
        elif pin_html:
            pin_label = QLabel(pin_html)
            pin_label.setTextFormat(Qt.TextFormat.RichText)
            pin_label.setStyleSheet("background: transparent;")
            blay.addWidget(pin_label)

        # -- Suppress image placeholder text --
        image_info = m.get("image")
        if image_info:
            fname_raw = image_info.get("filename", "image")
            if text == f"[Image: {fname_raw}]":
                text = ""

        # -- Message text --
        if text:
            text_html = _linkify_urls(text)
            text_html = text_html.replace("\n", "<br/>")
            # Search highlighting
            if (self._search_active and self._search_query
                    and index in self._search_results):
                is_current = (
                    0 <= self._search_current < len(self._search_results)
                    and self._search_results[self._search_current] == index
                )
                bg = t.search_current if is_current else t.search_highlight
                text_html = self._highlight_search_text(
                    text_html, self._search_query, bg
                )
            # @Mentions highlighting
            import re as _re
            mention_c = t.mention
            text_html = _re.sub(
                r'@(\w+)',
                lambda mt: (
                    f'<span style="color:{mention_c}; font-weight:bold;">'
                    f'@{mt.group(1)}</span>'
                ),
                text_html,
            )

            text_label = QLabel(text_html)
            text_label.setTextFormat(Qt.TextFormat.RichText)
            text_label.setWordWrap(True)
            text_label.setStyleSheet(
                f"color: {text_color}; background: transparent; "
                f"font-size: 11pt;"
            )
            text_label.setTextInteractionFlags(
                Qt.TextInteractionFlag.LinksAccessibleByMouse
            )
            text_label.linkActivated.connect(self._on_link_clicked)
            blay.addWidget(text_label)

        # -- Image thumbnail --
        if image_info:
            thumb_pixmap = self._decode_thumbnail(image_info)
            file_id = image_info.get("file_id", "")
            filename = image_info.get("filename", "image")
            size_bytes = image_info.get("size", 0)
            size_str = self._human_file_size(size_bytes)
            if thumb_pixmap:
                img_label = QLabel()
                scaled = thumb_pixmap.scaledToWidth(
                    min(200, thumb_pixmap.width()),
                    Qt.TransformationMode.SmoothTransformation,
                )
                img_label.setPixmap(scaled)
                img_label.setStyleSheet("background: transparent;")
                img_label.setCursor(Qt.CursorShape.PointingHandCursor)
                img_label.mousePressEvent = (
                    lambda e, fid=file_id: self._on_image_click(fid)
                )
                blay.addWidget(img_label)

            link_color = text_color if is_me else t.accent
            fname_esc = (filename.replace("&", "&amp;")
                         .replace("<", "&lt;").replace(">", "&gt;"))
            info_label = QLabel(
                f'<a href="image://{file_id}" '
                f'style="color:{link_color}; text-decoration:none;">'
                f'{fname_esc} ({size_str}) \u2014 Click to view</a>'
            )
            info_label.setTextFormat(Qt.TextFormat.RichText)
            info_label.setStyleSheet(
                f"font-size: 9pt; background: transparent;"
            )
            info_label.setTextInteractionFlags(
                Qt.TextInteractionFlag.LinksAccessibleByMouse
            )
            info_label.linkActivated.connect(self._on_link_clicked)
            blay.addWidget(info_label)

        # -- File card --
        file_info = m.get("file")
        if file_info:
            fname = file_info.get("filename", "file")
            fname_esc = (fname.replace("&", "&amp;")
                         .replace("<", "&lt;").replace(">", "&gt;"))
            fsize = file_info.get("size", 0)
            size_str = self._human_file_size(fsize)
            f_id = file_info.get("file_id", "")
            icon = self._file_icon(fname)
            file_link_color = text_color if is_me else t.accent

            file_frame = QFrame()
            file_frame.setStyleSheet(
                f"QFrame {{ background: transparent; "
                f"border: 1px solid {meta_color}; "
                f"border-radius: 6px; padding: 8px; }}"
            )
            flay = QHBoxLayout(file_frame)
            flay.setContentsMargins(0, 0, 0, 0)
            file_label = QLabel(
                f'{icon} <a href="file://{f_id}" '
                f'style="color:{file_link_color};">{fname_esc}</a>'
                f' <span style="color:{meta_color};">({size_str})</span>'
            )
            file_label.setTextFormat(Qt.TextFormat.RichText)
            file_label.setStyleSheet("background: transparent; border: none;")
            file_label.setTextInteractionFlags(
                Qt.TextInteractionFlag.LinksAccessibleByMouse
            )
            file_label.linkActivated.connect(self._on_link_clicked)
            flay.addWidget(file_label)
            blay.addWidget(file_frame)

        # -- Reaction badges --
        reactions = m.get("reactions", [])
        if reactions:
            _REACTION_EMOJI = {
                "thumbsup": "\U0001f44d", "heart": "\u2764\ufe0f",
                "laugh": "\U0001f602", "surprised": "\U0001f62e",
                "sad": "\U0001f622", "thumbsdown": "\U0001f44e",
            }
            grouped = {}
            for r in reactions:
                grouped.setdefault(r["reaction"], []).append(r["user_id"])
            my_id = (self.bridge.client.session.get("user_id", "")
                     if self.bridge.client.session else "")
            react_widget = QWidget()
            react_widget.setStyleSheet("background: transparent;")
            react_lay = QHBoxLayout(react_widget)
            react_lay.setContentsMargins(0, 2, 0, 0)
            react_lay.setSpacing(4)
            for rkey, uids in grouped.items():
                emoji = _REACTION_EMOJI.get(rkey, rkey)
                count = len(uids)
                is_mine = my_id in uids
                bg = t.reaction_bg_own if is_mine else t.reaction_bg
                bdr = t.reaction_border_own if is_mine else t.reaction_border
                badge = QLabel(f"{emoji} {count}")
                badge.setStyleSheet(
                    f"background-color: {bg}; color: {t.text_primary}; "
                    f"border: 1px solid {bdr}; "
                    f"border-radius: 10px; padding: 2px 6px; font-size: 10pt;"
                )
                react_lay.addWidget(badge)
            react_lay.addStretch()
            blay.addWidget(react_widget)

        # -- Footer (timestamp + receipt, right-aligned, below content) --
        time_str = _format_msg_time(timestamp)
        receipt_status = ""
        if is_me:
            read_by = m.get("read_by", [])
            others_read = [r for r in read_by if r.get("user_id") != my_uid]
            delivered_to = m.get("delivered_to", [])
            others_delivered = [d for d in delivered_to if d.get("user_id") != my_uid]
            if others_read:
                receipt_status = "read"
            elif others_delivered:
                receipt_status = "delivered"
            else:
                receipt_status = "sent"
        footer_w = _ReceiptFooter(time_str, receipt_status, meta_color,
                                   t.bubble_sent_text, t.success)
        footer_w.setStyleSheet("background: transparent;")
        blay.addWidget(footer_w, alignment=Qt.AlignmentFlag.AlignRight)

        wlay.addWidget(bubble)
        if not is_me:
            wlay.addStretch(1)

        # Install event filter on all child widgets for context menu propagation
        for child in bubble.findChildren(QWidget):
            child.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
            child.installEventFilter(self)
        bubble.installEventFilter(self)
        wrapper.installEventFilter(self)

        return wrapper

    def _on_image_click(self, file_id):
        """Handle click on an image thumbnail in a message bubble."""
        for msg in self.current_messages:
            image_info = msg.get("image")
            if image_info and image_info.get("file_id") == file_id:
                self._view_image(msg)
                return

    def _find_msg_index_at_widget(self, widget):
        """Walk up the widget tree to find the _msg_index attribute."""
        while widget:
            if hasattr(widget, '_msg_index'):
                return widget._msg_index
            widget = widget.parentWidget()
        return None

    def _on_older_messages_loaded(self, conv_id, messages):
        if conv_id != self.current_conv_id:
            return
        if not messages:
            self._has_more_messages = False
            self.load_more_btn.setVisible(False)
            return
        self._has_more_messages = len(messages) >= 50
        self.load_more_btn.setVisible(self._has_more_messages)
        # Prepend older messages and re-render
        self.current_messages = messages + self.current_messages
        self._render_messages(scroll_to_bottom=False)

    def _on_load_more(self):
        if not self.current_conv_id or not self._has_more_messages:
            return
        offset = len(self.current_messages)
        self.bridge.load_older_messages(self.current_conv_id, offset)

    def _on_message_context_menu(self, pos):
        if not self.current_messages:
            return
        global_pos = self._msg_scroll_area.viewport().mapToGlobal(pos)
        widget = QApplication.widgetAt(global_pos)
        idx = self._find_msg_index_at_widget(widget)
        if idx is not None:
            self._show_msg_context_menu(idx, global_pos)

    def _show_msg_context_menu(self, idx, global_pos):
        if idx < 0 or idx >= len(self.current_messages):
            return
        m = self.current_messages[idx]
        if m.get("deleted"):
            return

        my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
        menu = QMenu(self)

        reply_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack)
        reply_action = menu.addAction(reply_icon, "Reply")

        # Reaction submenu
        react_menu = menu.addMenu("React")
        _REACTION_LABELS = {
            "thumbsup": "\U0001f44d +1",
            "heart": "\u2764\ufe0f Heart",
            "laugh": "\U0001f602 Haha",
            "surprised": "\U0001f62e Wow",
            "sad": "\U0001f622 Sad",
            "thumbsdown": "\U0001f44e -1",
        }
        react_actions = {}
        for rkey, rlabel in _REACTION_LABELS.items():
            act = react_menu.addAction(rlabel)
            react_actions[act] = rkey

        # Forward
        fwd_action = menu.addAction("Forward")

        # Pin / Unpin
        pin_action = None
        if m.get("pinned_at"):
            pin_action = menu.addAction("\U0001f4cc Unpin")
        else:
            pin_action = menu.addAction("\U0001f4cc Pin")

        menu.addSeparator()

        # Delete option for own messages
        del_action = None
        if m.get("sender_id") == my_user_id:
            del_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)
            del_action = menu.addAction(del_icon, "Delete")

        # View image option
        img_action = None
        if m.get("image"):
            img_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView)
            img_action = menu.addAction(img_icon, "View image")

        # Download file option
        file_action = None
        if m.get("file"):
            file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
            file_action = menu.addAction(file_icon, "Download file")

        # Reset session option for undecryptable messages
        reset_action = None
        if m.get("text", "").startswith("[Decryption failed"):
            reset_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)
            reset_action = menu.addAction(reset_icon, "Reset session with sender")

        chosen = menu.exec(global_pos)
        if not chosen:
            return
        if chosen == reply_action:
            self.reply_to_id = m["message_id"]
            sender = m.get("sender", "???")
            preview = m.get("text", "")[:40]
            self.reply_label.setText(f"Replying to {sender}: {preview}")
            self._reply_widget.setVisible(True)
            self.msg_input.setFocus()
        elif chosen in react_actions:
            rkey = react_actions[chosen]
            existing = m.get("reactions", [])
            has_it = any(r["user_id"] == my_user_id and r["reaction"] == rkey for r in existing)
            if has_it:
                m["reactions"] = [r for r in existing if r["user_id"] != my_user_id]
                self.bridge.react_message(m["message_id"], rkey, "remove")
            else:
                new_reactions = [r for r in existing if r["user_id"] != my_user_id]
                new_reactions.append({"user_id": my_user_id, "reaction": rkey, "created_at": ""})
                m["reactions"] = new_reactions
                self.bridge.react_message(m["message_id"], rkey, "add")
            # Persist to local cache so reactions survive conversation switch
            self.bridge.client.update_message_in_cache(
                self.current_conv_id, m["message_id"],
                {"reactions": m["reactions"]})
            self._render_messages(scroll_to_bottom=self._is_near_bottom)
        elif chosen == fwd_action:
            self._show_forward_dialog(m)
        elif chosen == pin_action:
            if m.get("pinned_at"):
                m.pop("pinned_at", None)
                m.pop("pinned_by", None)
                self.bridge.pin_message(m["message_id"], self.current_conv_id, "unpin")
                self.bridge.client.update_message_in_cache(
                    self.current_conv_id, m["message_id"],
                    {"pinned_at": None, "pinned_by": None})
            else:
                my_user_id_pin = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
                m["pinned_at"] = "now"
                m["pinned_by"] = my_user_id_pin
                self.bridge.pin_message(m["message_id"], self.current_conv_id, "pin")
                self.bridge.client.update_message_in_cache(
                    self.current_conv_id, m["message_id"],
                    {"pinned_at": "now", "pinned_by": my_user_id_pin})
            self._render_messages(scroll_to_bottom=self._is_near_bottom)
            self._update_pin_banner()
        elif chosen == del_action:
            if self._confirm_dialog("Delete Message",
                                    "Delete this message? This cannot be undone."):
                # Apply locally immediately (server notification only goes to others)
                m["deleted"] = True
                m["text"] = ""
                m["image"] = None
                m["file"] = None
                self.bridge.client.update_message_in_cache(
                    self.current_conv_id, m["message_id"], {"deleted": True})
                self._render_messages(scroll_to_bottom=self._is_near_bottom)
                self.bridge.delete_message(m["message_id"])
        elif chosen == img_action:
            self._view_image(m)
        elif chosen == file_action:
            file_info = m.get("file")
            if file_info:
                self.bridge.download_file(file_info["file_id"], file_info)
        elif chosen == reset_action:
            sender_id = m.get("sender_id", "")
            if sender_id:
                if self._confirm_dialog("Reset Session",
                                        "Reset encryption session with this sender? "
                                        "A new session will be created on the next message."):
                    self.bridge.reset_session(sender_id)

    # ------------------------------------------------------------------
    # Search
    # ------------------------------------------------------------------

    def _toggle_search(self):
        if self._search_active:
            self._close_search()
        else:
            if not self.current_conv_id:
                return
            self._search_active = True
            self.search_widget.setVisible(True)
            self.search_input.setFocus()
            self.search_input.selectAll()

    def _close_search(self):
        self._search_active = False
        self._search_query = ""
        self._search_results = []
        self._search_current = -1
        self.search_widget.setVisible(False)
        self.search_input.clear()
        self.search_count_label.setText("0/0")
        # Re-render to remove highlights
        if self.current_messages:
            self._render_messages(scroll_to_bottom=False)

    def _on_search_text_changed(self, text):
        self._search_query = text.strip()
        if not self._search_query:
            self._search_results = []
            self._search_current = -1
            self.search_count_label.setText("0/0")
            self._render_messages(scroll_to_bottom=False)
            return
        query_lower = self._search_query.lower()
        self._search_results = []
        for i, m in enumerate(self.current_messages):
            if m.get("deleted"):
                continue
            msg_text = m.get("text", "")
            if query_lower in msg_text.lower():
                self._search_results.append(i)
        if self._search_results:
            self._search_current = 0
            self.search_count_label.setText(f"1/{len(self._search_results)}")
        else:
            self._search_current = -1
            self.search_count_label.setText("0/0")
        self._render_messages(scroll_to_bottom=False)
        if self._search_results:
            self._scroll_to_message(self._search_results[self._search_current])

    def _on_search_next(self):
        if not self._search_results:
            return
        self._search_current = (self._search_current + 1) % len(self._search_results)
        self.search_count_label.setText(f"{self._search_current + 1}/{len(self._search_results)}")
        self._render_messages(scroll_to_bottom=False)
        self._scroll_to_message(self._search_results[self._search_current])

    def _on_search_prev(self):
        if not self._search_results:
            return
        self._search_current = (self._search_current - 1) % len(self._search_results)
        self.search_count_label.setText(f"{self._search_current + 1}/{len(self._search_results)}")
        self._render_messages(scroll_to_bottom=False)
        self._scroll_to_message(self._search_results[self._search_current])

    def _scroll_to_message(self, index):
        if 0 <= index < len(self._msg_widgets):
            self._msg_scroll_area.ensureWidgetVisible(
                self._msg_widgets[index], 0, 50
            )

    @staticmethod
    def _highlight_search_text(html_text: str, query: str, bg_color: str) -> str:
        """Highlight matching text in HTML, skipping content inside tags."""
        query_esc = query.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        if not query_esc:
            return html_text
        result = []
        i = 0
        q_lower = query_esc.lower()
        q_len = len(query_esc)
        while i < len(html_text):
            if html_text[i] == '<':
                # Skip HTML tags
                end = html_text.find('>', i)
                if end == -1:
                    result.append(html_text[i:])
                    break
                result.append(html_text[i:end + 1])
                i = end + 1
            else:
                # Look for match in text content
                chunk_end = html_text.find('<', i)
                if chunk_end == -1:
                    chunk_end = len(html_text)
                chunk = html_text[i:chunk_end]
                # Case-insensitive replace within this chunk
                chunk_lower = chunk.lower()
                out = []
                j = 0
                while j < len(chunk):
                    pos = chunk_lower.find(q_lower, j)
                    if pos == -1:
                        out.append(chunk[j:])
                        break
                    out.append(chunk[j:pos])
                    matched = chunk[pos:pos + q_len]
                    out.append(f'<span style="background-color:{bg_color}; color:{c().bg_primary};">{matched}</span>')
                    j = pos + q_len
                result.append("".join(out))
                i = chunk_end
        return "".join(result)

    # ------------------------------------------------------------------
    # Session reset
    # ------------------------------------------------------------------

    def _on_session_reset(self, from_user_id, from_device_id):
        # Find username for the user
        username = from_user_id[:8]
        for cv in self.conversations:
            for m in cv.get("members", []):
                uid = m.get("user_id") or m.get("id")
                if uid == from_user_id:
                    username = m.get("username") or m.get("email") or username
                    break
        self.status_bar.setText(f"Session with {username} was reset. New session will be created on next message.")
        t = c()
        self.status_bar.setStyleSheet(
            f"background-color: {t.bg_tertiary}; border-radius: 0px; "
            f"padding: 0 8px; color: {t.warning}; font-size: 8pt; font-weight: bold;"
        )
        QTimer.singleShot(8000, self._clear_status_bar)

    # ------------------------------------------------------------------
    # Reactions / Pins / Forward notification handlers
    # ------------------------------------------------------------------

    def _on_reaction_notification(self, data):
        """Handle message_reacted push notification."""
        conv_id = data.get("conversation_id", "")
        msg_id = data.get("message_id", "")
        user_id = data.get("user_id", "")
        reaction = data.get("reaction", "")
        action = data.get("action", "add")

        if conv_id != self.current_conv_id:
            return
        for msg in self.current_messages:
            if msg.get("message_id") == msg_id:
                reactions = msg.get("reactions", [])
                if action == "add":
                    reactions = [r for r in reactions if r["user_id"] != user_id]
                    reactions.append({"user_id": user_id, "reaction": reaction, "created_at": ""})
                else:
                    reactions = [r for r in reactions if r["user_id"] != user_id]
                msg["reactions"] = reactions
                self.bridge.client.update_message_in_cache(
                    conv_id, msg_id, {"reactions": reactions})
                break
        self._render_messages(scroll_to_bottom=self._is_near_bottom)

    def _on_pin_notification(self, data):
        """Handle message_pinned push notification."""
        conv_id = data.get("conversation_id", "")
        msg_id = data.get("message_id", "")
        user_id = data.get("user_id", "")
        if conv_id != self.current_conv_id:
            return
        for msg in self.current_messages:
            if msg.get("message_id") == msg_id:
                msg["pinned_at"] = "now"
                msg["pinned_by"] = user_id
                self.bridge.client.update_message_in_cache(
                    conv_id, msg_id, {"pinned_at": "now", "pinned_by": user_id})
                break
        self._render_messages(scroll_to_bottom=self._is_near_bottom)
        self._update_pin_banner()
        username = data.get("username", user_id[:8] if user_id else "?")
        self.status_bar.setText(f"{username} pinned a message")
        QTimer.singleShot(3000, self._clear_status_bar)

    def _on_unpin_notification(self, data):
        """Handle message_unpinned push notification."""
        conv_id = data.get("conversation_id", "")
        msg_id = data.get("message_id", "")
        if conv_id != self.current_conv_id:
            return
        for msg in self.current_messages:
            if msg.get("message_id") == msg_id:
                msg.pop("pinned_at", None)
                msg.pop("pinned_by", None)
                self.bridge.client.update_message_in_cache(
                    conv_id, msg_id, {"pinned_at": None, "pinned_by": None})
                break
        self._render_messages(scroll_to_bottom=self._is_near_bottom)
        self._update_pin_banner()

    def _on_pinned_messages_loaded(self, conv_id, pinned):
        """Show pinned messages dialog."""
        if conv_id != self.current_conv_id:
            return
        if not pinned:
            dlg = QDialog(self)
            dlg.setMinimumWidth(300)
            lay = _make_frameless(dlg, "Pinned Messages")
            t = c()
            lbl = QLabel("No pinned messages in this conversation.")
            lbl.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;")
            lbl.setWordWrap(True)
            lay.addWidget(lbl)
            ok_btn = QPushButton("OK")
            ok_btn.clicked.connect(dlg.accept)
            lay.addWidget(ok_btn)
            dlg.exec()
            return
        dlg = QDialog(self)
        dlg.setMinimumSize(360, 300)
        t = c()
        layout = _make_frameless(dlg, "Pinned Messages")
        lw = QListWidget()
        lw.setStyleSheet(
            f"QListWidget {{ background-color:{t.bg_secondary}; border:1px solid {t.border}; border-radius:6px; }}"
            f"QListWidget::item {{ padding:8px; color:{t.text_primary}; border-bottom:1px solid {t.border}; }}"
            f"QListWidget::item:selected {{ background-color:{t.bg_hover}; }}"
        )
        # Build a sender map from current messages
        sender_map = {}
        for m in self.current_messages:
            sid = m.get("sender_id", "")
            if sid and sid not in sender_map:
                sender_map[sid] = m.get("sender", sid[:8])
        for p in pinned:
            sender = sender_map.get(p.get("sender_id", ""), p.get("sender_id", "?")[:8])
            # Find message text from current_messages
            text_preview = ""
            for m in self.current_messages:
                if m.get("message_id") == p.get("message_id"):
                    text_preview = m.get("text", "")[:60]
                    if not sender_map.get(p.get("sender_id", "")):
                        sender = m.get("sender", sender)
                    break
            ts = p.get("pinned_at", "")[:16] if p.get("pinned_at") else ""
            item = QListWidgetItem(f"\U0001f4cc {sender}: {text_preview}\n    Pinned: {ts}")
            item.setData(Qt.ItemDataRole.UserRole, p.get("message_id"))
            lw.addItem(item)
        layout.addWidget(lw)
        close_btn = QPushButton("Close")
        close_btn.setObjectName("secondaryBtn")
        close_btn.clicked.connect(dlg.accept)
        layout.addWidget(close_btn)

        def _on_item_clicked(item):
            target_id = item.data(Qt.ItemDataRole.UserRole)
            if target_id:
                for i, msg in enumerate(self.current_messages):
                    if msg.get("message_id") == target_id:
                        self._scroll_to_message(i)
                        break
            dlg.accept()
        lw.itemDoubleClicked.connect(_on_item_clicked)
        dlg.exec()

    def _update_pin_banner(self):
        """Update the pinned message banner from current_messages."""
        pinned = [m for m in self.current_messages if m.get("pinned_at")]
        if not pinned:
            self._pin_banner.setVisible(False)
            self._pin_banner_msg_id = None
            return
        # Show the most recently pinned message
        latest = pinned[-1]
        sender = latest.get("sender", "?")
        text = latest.get("text", "")
        if len(text) > 80:
            text = text[:80] + "..."
        text = text.replace("\n", " ")
        # HTML-escape user-controlled data to prevent injection
        sender_esc = sender.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        text_esc = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        count_str = f" ({len(pinned)} pinned)" if len(pinned) > 1 else ""
        self._pin_banner_label.setText(f"<b>{sender_esc}:</b> {text_esc}{count_str}")
        self._pin_banner_msg_id = latest.get("message_id")
        self._pin_banner.setVisible(True)

    def _on_pin_banner_clicked(self, event):
        """Scroll to the pinned message when banner is clicked."""
        if self._pin_banner_msg_id:
            for i, msg in enumerate(self.current_messages):
                if msg.get("message_id") == self._pin_banner_msg_id:
                    self._scroll_to_message(i)
                    break

    def _show_pinned_messages(self):
        """Fetch and show pinned messages for current conversation."""
        if self.current_conv_id:
            self.bridge.get_pinned_messages(self.current_conv_id)

    def _on_forward_result(self, ok, msg):
        if ok:
            self.status_bar.setText("Message forwarded")
            QTimer.singleShot(3000, self._clear_status_bar)
        else:
            self.status_bar.setText(f"Forward failed: {msg}")
            QTimer.singleShot(5000, self._clear_status_bar)

    def _confirm_dialog(self, title: str, text: str) -> bool:
        """Show a frameless confirmation dialog. Returns True if user confirmed."""
        dlg = QDialog(self)
        dlg.setMinimumWidth(340)
        layout = _make_frameless(dlg, title)
        t = c()
        label = QLabel(text)
        label.setWordWrap(True)
        label.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;")
        layout.addWidget(label)
        btn_lay = QHBoxLayout()
        btn_lay.setSpacing(8)
        cancel_btn = QPushButton("Cancel")
        cancel_btn.setObjectName("secondaryBtn")
        confirm_btn = QPushButton("Delete")
        confirm_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.error}; color: {t.accent_text}; "
            f"border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; }}"
            f"QPushButton:hover {{ background-color: {t.warning}; }}"
        )
        btn_lay.addStretch()
        btn_lay.addWidget(cancel_btn)
        btn_lay.addWidget(confirm_btn)
        layout.addLayout(btn_lay)
        cancel_btn.clicked.connect(dlg.reject)
        confirm_btn.clicked.connect(dlg.accept)
        return dlg.exec() == QDialog.DialogCode.Accepted

    def _show_forward_dialog(self, msg):
        """Show dialog to pick a conversation to forward a message to."""
        dlg = QDialog(self)
        dlg.setMinimumSize(320, 360)
        t = c()
        layout = _make_frameless(dlg, "Forward message")
        label = QLabel("Select conversation:")
        label.setStyleSheet(f"color:{t.text_primary}; font-size:10pt;")
        layout.addWidget(label)
        conv_list = QListWidget()
        conv_list.setStyleSheet(
            f"QListWidget {{ background-color:{t.bg_secondary}; border:1px solid {t.border}; border-radius:6px; }}"
            f"QListWidget::item {{ padding:8px; color:{t.text_primary}; }}"
            f"QListWidget::item:selected {{ background-color:{t.bg_hover}; }}"
        )
        for cv in self.conversations:
            if cv["conversation_id"] != self.current_conv_id:
                name = cv.get("name")
                if not name:
                    others = [m.get("username") or m.get("email") or "?" for m in cv["members"]
                              if m.get("email") != self.bridge.client.email]
                    name = ", ".join(others) if others else "?"
                item = QListWidgetItem(name)
                item.setData(Qt.ItemDataRole.UserRole, cv)
                conv_list.addItem(item)
        layout.addWidget(conv_list)
        fwd_btn = QPushButton("Forward")
        fwd_btn.setObjectName("secondaryBtn")
        layout.addWidget(fwd_btn)

        def _do_forward():
            sel = conv_list.currentItem()
            if not sel:
                return
            target_conv = sel.data(Qt.ItemDataRole.UserRole)
            if target_conv:
                fwd_msg = dict(msg)
                fwd_msg["conversation_id"] = self.current_conv_id
                self.bridge.forward_message(
                    target_conv["conversation_id"], fwd_msg, target_conv["members"]
                )
            dlg.accept()

        fwd_btn.clicked.connect(_do_forward)
        conv_list.itemDoubleClicked.connect(lambda _: _do_forward())
        dlg.exec()

    # ------------------------------------------------------------------
    # @Mentions autocomplete
    # ------------------------------------------------------------------

    def _setup_mention_completer(self):
        """Set up the mention autocomplete popup for msg_input."""
        self._mention_popup = QListWidget(self)
        self._mention_popup.setWindowFlags(Qt.WindowType.Popup)
        t = c()
        self._mention_popup.setStyleSheet(
            f"QListWidget {{ background:{t.bg_secondary}; color:{t.text_primary}; border:1px solid {t.border}; "
            f"border-radius:4px; font-size:10pt; }}"
            f"QListWidget::item {{ padding:4px 8px; }}"
            f"QListWidget::item:selected {{ background:{t.bg_hover}; }}"
        )
        self._mention_popup.setMaximumHeight(150)
        self._mention_popup.itemClicked.connect(self._on_mention_selected)
        self._mention_popup.hide()
        self._mention_active = False

    def _check_mention_trigger(self):
        """Check if user is typing @mention and show autocomplete."""
        if not hasattr(self, '_mention_popup'):
            self._setup_mention_completer()
        text = self.msg_input.toPlainText()
        cursor_pos = self.msg_input.textCursor().position()
        # Find @word at cursor position
        before = text[:cursor_pos]
        import re as _re
        match = _re.search(r'@(\w*)$', before)
        if not match:
            self._mention_popup.hide()
            self._mention_active = False
            return
        prefix = match.group(1).lower()
        self._mention_start = match.start()

        # Get members of current conversation
        members = []
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                members = cv.get("members", [])
                break
        my_email = self.bridge.client.email if self.bridge.client else ""
        candidates = []
        for m in members:
            uname = m.get("username") or m.get("email") or ""
            if m.get("email") == my_email:
                continue
            if prefix == "" or uname.lower().startswith(prefix):
                candidates.append(uname)

        if not candidates:
            self._mention_popup.hide()
            self._mention_active = False
            return

        self._mention_popup.clear()
        for cand in candidates[:6]:
            self._mention_popup.addItem(cand)
        # Position popup above the input
        cursor_rect = self.msg_input.cursorRect()
        global_pos = self.msg_input.mapToGlobal(cursor_rect.bottomLeft())
        self._mention_popup.move(global_pos.x(), global_pos.y() - self._mention_popup.sizeHint().height() - 5)
        self._mention_popup.setFixedWidth(max(200, self.msg_input.width() // 2))
        self._mention_popup.show()
        self._mention_active = True

    def _on_mention_selected(self, item):
        """Insert the selected @mention into msg_input."""
        username = item.text()
        text = self.msg_input.toPlainText()
        cursor_pos = self.msg_input.textCursor().position()
        # Replace @prefix with @username
        new_text = text[:self._mention_start] + f"@{username} " + text[cursor_pos:]
        self.msg_input.setPlainText(new_text)
        cursor = self.msg_input.textCursor()
        cursor.setPosition(self._mention_start + len(username) + 2)
        self.msg_input.setTextCursor(cursor)
        self._mention_popup.hide()
        self._mention_active = False
        self.msg_input.setFocus()

    def _on_input_changed(self):
        text = self.msg_input.toPlainText()
        count = len(text)
        if count > MAX_INPUT_CHARS:
            cursor = self.msg_input.textCursor()
            self.msg_input.setPlainText(text[:MAX_INPUT_CHARS])
            cursor.movePosition(cursor.MoveOperation.End)
            self.msg_input.setTextCursor(cursor)
            count = MAX_INPUT_CHARS
        color = c().error if count > MAX_INPUT_CHARS * 0.9 else c().text_muted
        self.char_counter.setStyleSheet(f"color: {color}; font-size: 8pt; padding: 0 4px;")
        self.char_counter.setText(f"{count} / {MAX_INPUT_CHARS}")
        # @Mention autocomplete check
        if self.current_conv_id:
            self._check_mention_trigger()

    def _cancel_reply(self):
        """Dismiss the reply preview."""
        self.reply_to_id = None
        self.reply_label.setText("")
        self._reply_widget.setVisible(False)

    def _on_send(self):
        text = self.msg_input.toPlainText().strip()
        if not text or not self.current_conv_id:
            return
        if len(text) > MAX_INPUT_CHARS:
            QMessageBox.warning(self, "Message Too Long",
                                f"Message too long (max {MAX_INPUT_CHARS} characters).")
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        self.msg_input.clear()
        self.bridge.send_message(
            self.current_conv_id, text, conv["members"],
            reply_to=self.reply_to_id,
        )
        self.reply_to_id = None
        self._reply_widget.setVisible(False)

    def _on_message_sent(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "Error", msg)

    def _on_message_sent_payload(self, conv_id, payload):
        """Append the just-sent message locally without re-fetching from server."""
        # Update last-message cache
        preview = payload.get("text", "")
        if payload.get("image") and not preview:
            preview = "Sent an image"
        elif payload.get("file") and not preview:
            preview = "Sent a file"
        self._last_message_cache[conv_id] = (preview[:60], payload.get("created_at", ""), "sent")
        self._update_conv_list_styles()

        if conv_id != self.current_conv_id:
            return
        # Avoid duplicate if notification arrived first (race)
        msg_id = payload.get("message_id", "")
        if msg_id:
            for m in self.current_messages:
                if m.get("message_id") == msg_id:
                    return
        self.current_messages.append(payload)
        idx = len(self.current_messages) - 1
        w = self._create_message_widget(payload, idx)
        self._msg_layout.addWidget(w)
        self._msg_widgets.append(w)
        if self._is_near_bottom:
            QTimer.singleShot(10, self._scroll_to_bottom)

    def _on_new_chat(self):
        dlg = QDialog(self)
        dlg.setMinimumWidth(400)
        t = c()
        lay = _make_frameless(dlg, "New Chat")
        lay.setSpacing(12)

        email_label = QLabel("Recipient email")
        email_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(email_label)
        email_input = QLineEdit()
        email_input.setPlaceholderText("user@example.com")
        email_input.setMinimumHeight(36)
        lay.addWidget(email_input)

        msg_label = QLabel("First message")
        msg_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(msg_label)
        msg_input = QLineEdit()
        msg_input.setPlaceholderText("Type a message...")
        msg_input.setMinimumHeight(36)
        lay.addWidget(msg_input)

        btn_row = QHBoxLayout()
        btn_row.addStretch()
        cancel_btn = QPushButton("Cancel")
        cancel_btn.setObjectName("secondaryBtn")
        cancel_btn.clicked.connect(dlg.reject)
        btn_row.addWidget(cancel_btn)
        send_btn = QPushButton("Send")
        send_btn.clicked.connect(dlg.accept)
        btn_row.addWidget(send_btn)
        lay.addLayout(btn_row)

        msg_input.returnPressed.connect(dlg.accept)
        email_input.returnPressed.connect(lambda: msg_input.setFocus())

        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        email = email_input.text().strip()
        text = msg_input.text().strip()
        if not email or not text:
            return
        if len(text) > MAX_INPUT_CHARS:
            QMessageBox.warning(self, "Message Too Long",
                                f"Message too long (max {MAX_INPUT_CHARS} characters).")
            return
        self.bridge.send_new_chat(email, text)

    def _on_new_group(self):
        dlg = QDialog(self)
        dlg.setMinimumWidth(400)
        t = c()
        lay = _make_frameless(dlg, "New Group")
        lay.setSpacing(12)

        name_label = QLabel("Group name")
        name_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(name_label)
        name_input = QLineEdit()
        name_input.setPlaceholderText("My Group")
        name_input.setMinimumHeight(36)
        lay.addWidget(name_input)

        members_label = QLabel("Member emails (comma-separated)")
        members_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(members_label)
        members_input = QLineEdit()
        members_input.setPlaceholderText("alice@example.com, bob@example.com")
        members_input.setMinimumHeight(36)
        lay.addWidget(members_input)

        btn_row = QHBoxLayout()
        btn_row.addStretch()
        cancel_btn = QPushButton("Cancel")
        cancel_btn.setObjectName("secondaryBtn")
        cancel_btn.clicked.connect(dlg.reject)
        btn_row.addWidget(cancel_btn)
        create_btn = QPushButton("Create")
        create_btn.clicked.connect(dlg.accept)
        btn_row.addWidget(create_btn)
        lay.addLayout(btn_row)

        members_input.returnPressed.connect(dlg.accept)
        name_input.returnPressed.connect(lambda: members_input.setFocus())

        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        members = members_input.text().strip()
        if not members:
            return
        member_list = [m.strip() for m in members.split(",") if m.strip()]
        if member_list:
            self.bridge.create_group(member_list, name=name_input.text().strip() or None)

    def _on_add_member(self):
        if not self.current_conv_id:
            return
        dlg = QDialog(self)
        dlg.setMinimumWidth(360)
        t = c()
        lay = _make_frameless(dlg, "Add Member")
        lay.setSpacing(12)
        lbl = QLabel("Email to invite")
        lbl.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(lbl)
        email_input = QLineEdit()
        email_input.setPlaceholderText("user@example.com")
        email_input.setMinimumHeight(36)
        email_input.returnPressed.connect(dlg.accept)
        lay.addWidget(email_input)
        btn_row = QHBoxLayout()
        btn_row.addStretch()
        cancel_btn = QPushButton("Cancel")
        cancel_btn.setObjectName("secondaryBtn")
        cancel_btn.clicked.connect(dlg.reject)
        btn_row.addWidget(cancel_btn)
        add_btn = QPushButton("Add")
        add_btn.clicked.connect(dlg.accept)
        btn_row.addWidget(add_btn)
        lay.addLayout(btn_row)
        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        email = email_input.text().strip()
        if not email:
            return
        self.bridge.add_member(self.current_conv_id, email)

    def _on_add_member_result(self, ok, msg):
        if ok:
            QMessageBox.information(self, "Add Member", "Invitation sent.")
        else:
            QMessageBox.warning(self, "Add Member", msg)

    def _on_group_info(self):
        if not self.current_conv_id:
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
        is_creator = conv.get("created_by") == my_user_id
        group_name = conv.get("name") or "Group"
        members = conv["members"]

        dlg = QDialog(self)
        dlg.setMinimumWidth(380)
        t = c()
        dlg_layout = _make_frameless(dlg, "Group Info")

        # Group avatar
        avatar_row = QHBoxLayout()
        avatar_label = QLabel()
        avatar_label.setFixedSize(64, 64)
        avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        conv_id = conv["conversation_id"]
        if conv_id in self._group_avatar_cache:
            avatar_pix = self._make_circular_avatar(self._group_avatar_cache[conv_id], size=64)
        else:
            avatar_pix = self._make_default_avatar(group_name, size=64)
        avatar_label.setPixmap(avatar_pix)
        avatar_row.addWidget(avatar_label)

        group_name_esc = group_name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        title = QLabel(f"<b style='font-size:12pt; color:{t.accent};'>{group_name_esc}</b>")
        avatar_row.addWidget(title, stretch=1)

        if is_creator:
            change_avatar_btn = QPushButton("Change Avatar")
            change_avatar_btn.setObjectName("secondaryBtn")
            change_avatar_btn.clicked.connect(lambda: self._do_change_group_avatar(conv_id, dlg))
            avatar_row.addWidget(change_avatar_btn)

            rename_btn = QPushButton("Rename")
            rename_btn.setObjectName("secondaryBtn")
            rename_btn.clicked.connect(lambda: self._do_rename_group(conv_id, group_name, dlg))
            avatar_row.addWidget(rename_btn)

        dlg_layout.addLayout(avatar_row)

        count_label = QLabel(f"<b>Members ({len(members)}):</b>")
        count_label.setStyleSheet("margin-top: 8px;")
        dlg_layout.addWidget(count_label)

        for mem in members:
            uname = mem.get("username") or mem.get("email") or "?"
            email = mem.get("email", "")
            uid = mem.get("user_id") or mem.get("id") or ""
            is_mem_creator = uid == conv.get("created_by")

            row = QHBoxLayout()
            uname_esc = uname.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            email_esc = email.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            is_online = uid in self._online_users
            online_dot = "\U0001f7e2 " if is_online else ""
            verified_dot = ""
            my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
            if uid and uid != my_uid and self.bridge.client.get_verification_status(uid) == "verified":
                verified_dot = " \u2705"
            name_text = f"{online_dot}<b>{uname_esc}</b>{verified_dot}"
            if email:
                name_text += f"  <span style='color:{t.text_muted};'>{email_esc}</span>"
            if is_mem_creator:
                name_text += f"  <span style='color:{t.success};'>creator</span>"
            name_label = QLabel(name_text)
            name_label.setWordWrap(True)
            row.addWidget(name_label, stretch=1)

            info_btn = QPushButton("")
            info_btn.setFixedSize(28, 28)
            info_btn.setObjectName("secondaryBtn")
            info_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation))
            info_btn.setToolTip(f"View profile of {uname}")
            info_btn.clicked.connect(lambda checked, u=uid, d=dlg: (d.accept(), self._show_user_profile(u)))
            row.addWidget(info_btn)

            # Remove button (only for creator, not on self)
            if is_creator and uid != my_user_id:
                remove_btn = QPushButton("")
                remove_btn.setFixedSize(28, 28)
                remove_btn.setObjectName("secondaryBtn")
                remove_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton))
                remove_btn.setToolTip(f"Remove {uname}")
                remove_btn.clicked.connect(lambda checked, u=uid, n=uname, d=dlg: self._do_remove_member_action(u, n, d))
                row.addWidget(remove_btn)

            dlg_layout.addLayout(row)

        dlg_layout.addSpacing(12)

        # Leave Group button
        leave_btn = QPushButton("Leave Group")
        leave_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.error}; color: {t.accent_text}; font-weight: bold; }}"
            f"QPushButton:hover {{ opacity: 0.8; }}"
        )
        leave_btn.clicked.connect(lambda: self._do_leave_group_action(dlg))
        dlg_layout.addWidget(leave_btn)

        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dlg.accept)
        dlg_layout.addWidget(close_btn)
        dlg.exec()

    def _do_remove_member_action(self, user_id, username, dialog):
        if not self.current_conv_id:
            return
        confirm = QMessageBox.question(
            self, "Remove Member",
            f"Remove <b>{username}</b> from the group?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if confirm == QMessageBox.StandardButton.Yes:
            dialog.accept()
            self.bridge.remove_member(self.current_conv_id, user_id)

    def _do_change_group_avatar(self, conv_id, dialog):
        path, _ = QFileDialog.getOpenFileName(
            dialog, "Select Group Avatar", "",
            "Images (*.png *.jpg *.jpeg);;All Files (*)",
        )
        if not path:
            return
        try:
            with open(path, "rb") as f:
                image_data = f.read()
            if len(image_data) > 2 * 1024 * 1024:
                QMessageBox.warning(dialog, "Too Large", "Avatar must be under 2 MB.")
                return
            dialog.accept()
            self.bridge.update_group_avatar(conv_id, image_data)
        except Exception as e:
            QMessageBox.warning(dialog, "Error", f"Failed to read image: {e}")

    def _do_rename_group(self, conv_id, current_name, dialog):
        from PyQt6.QtWidgets import QInputDialog
        new_name, ok = QInputDialog.getText(
            dialog, "Rename Group", "New group name:",
            text=current_name,
        )
        if ok and new_name.strip():
            new_name = new_name.strip()
            if new_name != current_name:
                dialog.accept()
                self.bridge.rename_conversation(conv_id, new_name)

    def _on_group_renamed(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "Rename Group", msg)

    def _do_leave_group_action(self, dialog):
        if not self.current_conv_id:
            return
        confirm = QMessageBox.question(
            self, "Leave Group",
            "Leave this group? You will no longer receive messages.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if confirm == QMessageBox.StandardButton.Yes:
            dialog.accept()
            self.bridge.leave_group(self.current_conv_id)

    def _on_group_left(self, ok, msg):
        if ok:
            self.current_conv_id = None
            self.msg_input.drop_enabled = False
            self.chat_header.setText("Select a conversation")
            self.chat_header_avatar.setVisible(False)
            self._clear_message_area()
            self.group_info_btn.setVisible(False)
            self.user_info_btn.setVisible(False)
            self.add_member_btn.setVisible(False)
            self.delete_conv_btn.setVisible(False)
        else:
            QMessageBox.warning(self, "Leave Group", msg)

    def _on_delete_conv_btn(self):
        if not self.current_conv_id:
            return
        confirm = QMessageBox.question(
            self, "Delete Conversation",
            "Delete this conversation? This cannot be undone.",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if confirm == QMessageBox.StandardButton.Yes:
            self.bridge.delete_conversation(self.current_conv_id)

    def _on_conversation_deleted(self, ok, msg):
        if ok:
            self.current_conv_id = None
            self.msg_input.drop_enabled = False
            self.chat_header.setText("Select a conversation")
            self.chat_header_avatar.setVisible(False)
            self._clear_message_area()
            self.group_info_btn.setVisible(False)
            self.user_info_btn.setVisible(False)
            self.add_member_btn.setVisible(False)
            self.delete_conv_btn.setVisible(False)
        else:
            QMessageBox.warning(self, "Delete Conversation", msg)

    def _on_remove_member_result(self, ok, msg):
        if ok:
            QMessageBox.information(self, "Remove Member", "Member removed.")
            if self.current_conv_id:
                self.bridge.load_messages(self.current_conv_id)
        else:
            QMessageBox.warning(self, "Remove Member", msg)

    def _on_my_profile(self):
        my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
        if not my_user_id:
            return
        dlg = UserProfileDialog(self.bridge, my_user_id, editable=True, parent=self)
        dlg.exec()

    def _on_dm_user_info(self):
        """Show profile of the other user in a DM conversation."""
        if not self.current_conv_id:
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        my_email = self.bridge.client.email
        for m in conv["members"]:
            if m.get("email") != my_email:
                uid = m.get("user_id") or m.get("id")
                if uid:
                    self._show_user_profile(uid)
                return

    def _show_user_profile(self, user_id):
        dlg = UserProfileDialog(self.bridge, user_id, editable=False, parent=self)
        dlg.exec()

    def _on_open_settings(self):
        t = c()
        dlg = QDialog(self)
        dlg.setMinimumWidth(360)
        lay = _make_frameless(dlg, "Settings")
        lay.setSpacing(16)

        # -- Appearance section --
        sec_appearance = QLabel("Appearance")
        sec_appearance.setStyleSheet(
            f"font-size: 10pt; font-weight: bold; color: {t.text_secondary}; "
            f"margin-top: 4px;"
        )
        lay.addWidget(sec_appearance)

        theme_row = QHBoxLayout()
        theme_label = QLabel("Theme")
        theme_label.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};")
        theme_row.addWidget(theme_label)
        theme_row.addStretch()
        theme_btn = QPushButton(
            "\u2600 Light mode" if tm().is_dark else "\U0001f319 Dark mode"
        )
        theme_btn.setObjectName("secondaryBtn")
        theme_btn.setFixedWidth(140)
        theme_row.addWidget(theme_btn)
        lay.addLayout(theme_row)

        # Separator
        sep1 = QFrame()
        sep1.setFrameShape(QFrame.Shape.HLine)
        sep1.setStyleSheet(f"background-color: {t.separator}; max-height: 1px;")
        lay.addWidget(sep1)

        # -- Security section --
        sec_security = QLabel("Security")
        sec_security.setStyleSheet(
            f"font-size: 10pt; font-weight: bold; color: {t.text_secondary};"
        )
        lay.addWidget(sec_security)

        # Rotate Keys
        rotate_row = QHBoxLayout()
        rotate_info = QVBoxLayout()
        rotate_title = QLabel("Rotate Keys")
        rotate_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};")
        rotate_info.addWidget(rotate_title)
        rotate_desc = QLabel("Generate new RSA keys. Revokes other devices.")
        rotate_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};")
        rotate_desc.setWordWrap(True)
        rotate_info.addWidget(rotate_desc)
        rotate_row.addLayout(rotate_info, stretch=1)
        rotate_btn = QPushButton("Rotate")
        rotate_btn.setObjectName("secondaryBtn")
        rotate_btn.setFixedWidth(100)
        rotate_btn.clicked.connect(lambda: (dlg.close(), self._on_rotate_keys()))
        rotate_row.addWidget(rotate_btn)
        lay.addLayout(rotate_row)

        # Change Username
        chun_row = QHBoxLayout()
        chun_info = QVBoxLayout()
        chun_title = QLabel("Change Username")
        chun_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};")
        chun_info.addWidget(chun_title)
        chun_desc = QLabel("Change your display name.")
        chun_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};")
        chun_desc.setWordWrap(True)
        chun_info.addWidget(chun_desc)
        chun_row.addLayout(chun_info, stretch=1)
        chun_btn = QPushButton("Change")
        chun_btn.setObjectName("secondaryBtn")
        chun_btn.setFixedWidth(100)
        chun_btn.clicked.connect(lambda: (dlg.close(), self._on_change_username()))
        chun_row.addWidget(chun_btn)
        lay.addLayout(chun_row)

        # Change Password
        chpw_row = QHBoxLayout()
        chpw_info = QVBoxLayout()
        chpw_title = QLabel("Change Password")
        chpw_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};")
        chpw_info.addWidget(chpw_title)
        chpw_desc = QLabel("Change password for local key encryption.")
        chpw_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};")
        chpw_desc.setWordWrap(True)
        chpw_info.addWidget(chpw_desc)
        chpw_row.addLayout(chpw_info, stretch=1)
        chpw_btn = QPushButton("Change")
        chpw_btn.setObjectName("secondaryBtn")
        chpw_btn.setFixedWidth(100)
        chpw_btn.clicked.connect(lambda: (dlg.close(), self._on_change_password()))
        chpw_row.addWidget(chpw_btn)
        lay.addLayout(chpw_row)

        # Separator
        sep2 = QFrame()
        sep2.setFrameShape(QFrame.Shape.HLine)
        sep2.setStyleSheet(f"background-color: {t.separator}; max-height: 1px;")
        lay.addWidget(sep2)

        # -- Devices section --
        sec_devices = QLabel("Devices")
        sec_devices.setStyleSheet(
            f"font-size: 10pt; font-weight: bold; color: {t.text_secondary};"
        )
        lay.addWidget(sec_devices)

        # Link Device (new)
        link_row = QHBoxLayout()
        link_info = QVBoxLayout()
        link_title = QLabel("Link New Device")
        link_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};")
        link_info.addWidget(link_title)
        link_desc = QLabel("Authorize another device to access your account.")
        link_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};")
        link_desc.setWordWrap(True)
        link_info.addWidget(link_desc)
        link_row.addLayout(link_info, stretch=1)
        link_btn = QPushButton("Link")
        link_btn.setObjectName("secondaryBtn")
        link_btn.setFixedWidth(100)
        link_btn.clicked.connect(lambda: (dlg.close(), self._on_authorize_device()))
        link_row.addWidget(link_btn)
        lay.addLayout(link_row)

        # Wire up theme toggle now that all widgets exist
        _s_labels = [sec_appearance, sec_security, sec_devices]
        _t_labels = [theme_label, rotate_title, chpw_title, link_title]
        _d_labels = [rotate_desc, chpw_desc, link_desc]
        _seps = [sep1, sep2]

        def _toggle_and_update():
            tm().toggle()
            theme_btn.setText(
                "\u2600 Light mode" if tm().is_dark else "\U0001f319 Dark mode"
            )
            t2 = c()
            dlg._frameless_container.setStyleSheet(
                f"#_framelessContainer {{ background-color: {t2.bg_primary}; border-radius: 12px; }}"
            )
            dlg._frameless_title_bar.setStyleSheet(
                f"background-color: {t2.bg_secondary}; "
                f"border-top-left-radius: 12px; border-top-right-radius: 12px;"
            )
            dlg._frameless_title_label.setStyleSheet(
                f"font-weight: bold; font-size: 10pt; color: {t2.text_primary}; background: transparent;"
            )
            for lbl in _s_labels:
                lbl.setStyleSheet(f"font-size: 10pt; font-weight: bold; color: {t2.text_secondary};")
            for lbl in _t_labels:
                lbl.setStyleSheet(f"font-size: 11pt; color: {t2.text_primary};")
            for lbl in _d_labels:
                lbl.setStyleSheet(f"font-size: 8pt; color: {t2.text_muted};")
            for s in _seps:
                s.setStyleSheet(f"background-color: {t2.separator}; max-height: 1px;")
        theme_btn.clicked.connect(_toggle_and_update)

        lay.addStretch()

        # Close button
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(dlg.close)
        lay.addWidget(close_btn)

        dlg.exec()

    def _on_authorize_device(self):
        code, ok = QInputDialog.getText(self, "Authorize Device", "Pairing code:")
        if not ok or not code.strip():
            return
        self.bridge.authorize_device(code.strip())

    def _on_rotate_keys(self):
        confirm = QMessageBox.question(
            self,
            "Rotate Keys",
            "This will revoke other devices. Continue?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if confirm != QMessageBox.StandardButton.Yes:
            return
        password, ok = QInputDialog.getText(self, "Rotate Keys", "Password:", QLineEdit.EchoMode.Password)
        if not ok or not password:
            return
        self.bridge.rotate_keys(self.bridge.client.username, password)

    def _on_change_password(self):
        t = c()
        dlg = QDialog(self)
        dlg.setMinimumWidth(360)
        lay = _make_frameless(dlg, "Change Password")

        old_label = QLabel("Current Password")
        old_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(old_label)
        old_input = QLineEdit()
        old_input.setEchoMode(QLineEdit.EchoMode.Password)
        old_input.setPlaceholderText("Enter current password")
        old_input.setStyleSheet(
            f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; "
            f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; "
            f"color: {t.text_primary}; }}"
            f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
        )
        lay.addWidget(old_input)

        new_label = QLabel("New Password")
        new_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(new_label)
        new_input = QLineEdit()
        new_input.setEchoMode(QLineEdit.EchoMode.Password)
        new_input.setPlaceholderText("Enter new password")
        new_input.setStyleSheet(old_input.styleSheet())
        lay.addWidget(new_input)

        confirm_label = QLabel("Confirm New Password")
        confirm_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(confirm_label)
        confirm_input = QLineEdit()
        confirm_input.setEchoMode(QLineEdit.EchoMode.Password)
        confirm_input.setPlaceholderText("Re-enter new password")
        confirm_input.setStyleSheet(old_input.styleSheet())
        lay.addWidget(confirm_input)

        error_label = QLabel("")
        error_label.setStyleSheet(f"color: {t.error}; font-size: 9pt;")
        error_label.hide()
        lay.addWidget(error_label)

        btn_lay = QHBoxLayout()
        btn_lay.addStretch()
        change_btn = QPushButton("Change Password")
        change_btn.setStyleSheet(
            f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; "
            f"border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; }}"
            f"QPushButton:hover {{ opacity: 0.9; }}"
        )
        btn_lay.addWidget(change_btn)
        lay.addLayout(btn_lay)

        def _do_change():
            old_pw = old_input.text()
            new_pw = new_input.text()
            conf_pw = confirm_input.text()
            if not old_pw:
                error_label.setText("Current password is required.")
                error_label.show()
                return
            if not new_pw:
                error_label.setText("New password cannot be empty.")
                error_label.show()
                return
            if new_pw != conf_pw:
                error_label.setText("New passwords do not match.")
                error_label.show()
                return
            dlg.accept()
            self.bridge.change_password(old_pw, new_pw)

        change_btn.clicked.connect(_do_change)
        confirm_input.returnPressed.connect(_do_change)
        dlg.exec()

    def _on_logout(self):
        confirm = QMessageBox.question(
            self,
            "Logout",
            "Log out and return to the login screen?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if confirm != QMessageBox.StandardButton.Yes:
            return
        self._is_logout = True
        self.bridge.logout()
        self.close()
        if self._on_logout_cb:
            self._on_logout_cb()

    def _on_notification(self, payload):
        sender = payload.get("sender", "???")
        conv_id = payload.get("conversation_id", "")

        # Update last-message cache for conversation list preview
        if conv_id:
            preview = payload.get("text", "")
            if payload.get("image") and not preview:
                preview = "Sent an image"
            elif payload.get("file") and not preview:
                preview = "Sent a file"
            if preview:
                self._last_message_cache[conv_id] = (
                    f"{sender}: {preview}"[:60],
                    payload.get("created_at", ""),
                    "",  # incoming message — no receipt status for others' msgs
                )

        # Resolve conversation name for notifications
        conv_name = sender
        is_notif_dm = False
        if conv_id:
            for cv in self.conversations:
                if cv["conversation_id"] == conv_id:
                    is_notif_dm = len(cv["members"]) == 2 and not cv.get("name")
                    if not is_notif_dm:
                        conv_name = cv.get("name") or sender
                    break

        # System tray toast when window is not visible or not focused
        if conv_id:
            if is_notif_dm:
                notif_title = sender
                notif_text = payload.get("text", "New message")
            else:
                notif_title = conv_name
                notif_text = f"{sender}: {payload.get('text', 'New message')}"
            if payload.get("image"):
                notif_text = notif_text or "Sent an image"
            elif payload.get("file"):
                notif_text = notif_text or "Sent a file"
            self._show_tray_notification(notif_title, notif_text)

        # Show notification in status bar (for non-current conversations)
        if conv_id and conv_id != self.current_conv_id:
            if is_notif_dm:
                self.status_bar.setText(f"New message from {sender}")
            else:
                self.status_bar.setText(f"New message from {sender} in {conv_name}")
            t = c()
            self.status_bar.setStyleSheet(
                f"background-color: {t.bg_tertiary}; border-radius: 0px; "
                f"padding: 0 8px; color: {t.success}; font-size: 8pt; font-weight: bold;"
            )
            self._status_bar_conv_id = conv_id
            QTimer.singleShot(5000, self._clear_status_bar)

        # Confirm delivery for all incoming messages (always, regardless of current view)
        msg_id = payload.get("message_id", "")
        if conv_id and msg_id:
            self.bridge.schedule(
                self.bridge.client.confirm_delivery(conv_id, [msg_id])
            )

        # Increment unread count if not currently viewing this conversation
        # (or if privacy overlay is locked — user can't see messages)
        viewing = conv_id == self.current_conv_id and not self._privacy_locked
        if conv_id and not viewing:
            self._unread_counts[conv_id] = self._unread_counts.get(conv_id, 0) + 1
            self._update_conv_list_styles()

        # Append directly to current conversation instead of re-fetching
        if conv_id == self.current_conv_id:
            # Avoid duplicate if local send already appended this message
            if msg_id:
                for m in self.current_messages:
                    if m.get("message_id") == msg_id:
                        return
            self.current_messages.append(payload)
            idx = len(self.current_messages) - 1
            w = self._create_message_widget(payload, idx)
            self._msg_layout.addWidget(w)
            self._msg_widgets.append(w)
            if self._is_near_bottom:
                QTimer.singleShot(10, self._scroll_to_bottom)
            else:
                self.jump_btn.setText("\u2193 New")
                self.jump_btn.setVisible(True)
                self._position_jump_btn()
            # Mark as read (only if not locked)
            if msg_id and not self._privacy_locked:
                self.bridge.schedule(
                    self.bridge.client.mark_read(conv_id, [msg_id])
                )

    def _on_messages_read(self, data):
        conv_id = data.get("conversation_id", "")
        user_id = data.get("user_id", "")
        message_ids = set(data.get("message_ids", []))
        my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""

        # Persist to cache for ALL conversations (not just current)
        if conv_id == self.current_conv_id:
            for msg in self.current_messages:
                if message_ids and msg.get("message_id") not in message_ids:
                    continue
                if not message_ids and msg.get("sender_id") != my_uid:
                    continue
                read_by = msg.get("read_by", [])
                if not any(r.get("user_id") == user_id for r in read_by):
                    read_by.append({"user_id": user_id})
                    msg["read_by"] = read_by
                    self.bridge.client.update_message_in_cache(
                        conv_id, msg.get("message_id"), {"read_by": read_by})
            self._render_messages(scroll_to_bottom=self._is_near_bottom)
        else:
            # Non-current conversation: update cache only (no in-memory messages to update)
            cached = self.bridge.client.load_message_cache(conv_id)
            if cached:
                for msg_id_key, entry in cached.items():
                    if message_ids and msg_id_key not in message_ids:
                        continue
                    if not message_ids and entry.get("sender_id") != my_uid:
                        continue
                    read_by = entry.get("read_by", [])
                    if not any(r.get("user_id") == user_id for r in read_by):
                        read_by.append({"user_id": user_id})
                        self.bridge.client.update_message_in_cache(
                            conv_id, msg_id_key, {"read_by": read_by})
        # Update conv list receipt status to "read"
        if conv_id in self._last_message_cache:
            prev_text, prev_ts, prev_receipt = self._last_message_cache[conv_id]
            if prev_receipt in ("sent", "delivered"):
                self._last_message_cache[conv_id] = (prev_text, prev_ts, "read")
                self._update_conv_list_styles()

    def _on_message_delivered(self, data):
        conv_id = data.get("conversation_id", "")
        user_id = data.get("user_id", "")
        message_ids = set(data.get("message_ids", []))

        if conv_id == self.current_conv_id:
            for msg in self.current_messages:
                if msg.get("message_id") in message_ids:
                    delivered_to = msg.get("delivered_to", [])
                    if not any(d.get("user_id") == user_id for d in delivered_to):
                        delivered_to.append({"user_id": user_id})
                        msg["delivered_to"] = delivered_to
                        self.bridge.client.update_message_in_cache(
                            conv_id, msg.get("message_id"), {"delivered_to": delivered_to})
            self._render_messages(scroll_to_bottom=self._is_near_bottom)
        else:
            # Non-current conversation: update cache only
            cached = self.bridge.client.load_message_cache(conv_id)
            if cached:
                for msg_id_key, entry in cached.items():
                    if msg_id_key in message_ids:
                        delivered_to = entry.get("delivered_to", [])
                        if not any(d.get("user_id") == user_id for d in delivered_to):
                            delivered_to.append({"user_id": user_id})
                            self.bridge.client.update_message_in_cache(
                                conv_id, msg_id_key, {"delivered_to": delivered_to})
        # Update conv list receipt status to "delivered"
        if conv_id in self._last_message_cache:
            prev_text, prev_ts, prev_receipt = self._last_message_cache[conv_id]
            if prev_receipt == "sent":
                self._last_message_cache[conv_id] = (prev_text, prev_ts, "delivered")
                self._update_conv_list_styles()

    def _on_link_clicked(self, url_str):
        """Handle link clicks from message bubble labels."""
        if url_str.startswith("image://"):
            file_id = url_str[len("image://"):]
            for msg in self.current_messages:
                image_info = msg.get("image")
                if image_info and image_info.get("file_id") == file_id:
                    self._view_image(msg)
                    return
        elif url_str.startswith("file://"):
            file_id = url_str[len("file://"):]
            for msg in self.current_messages:
                file_info = msg.get("file")
                if file_info and file_info.get("file_id") == file_id:
                    self.bridge.download_file(file_id, file_info)
                    return
        elif url_str.startswith("https://"):
            QDesktopServices.openUrl(QUrl(url_str))
        elif url_str.startswith("http://"):
            reply = QMessageBox.warning(
                self,
                "Insecure link",
                f"This link uses unencrypted HTTP.\n\n{url_str}\n\nContinue anyway?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No,
            )
            if reply == QMessageBox.StandardButton.Yes:
                QDesktopServices.openUrl(QUrl(url_str))

    # -- Drag & drop --------------------------------------------------------

    _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
    def _msg_area_normal_style(self):
        return f"QScrollArea {{ background-color: {c().bg_primary}; border: none; }}"

    def eventFilter(self, obj, event):
        """Handle drag-and-drop on message scroll area + context menu on messages."""
        from PyQt6.QtCore import QEvent
        # Context menu from any child of message container
        if event.type() == QEvent.Type.ContextMenu and obj is not self._msg_scroll_area:
            widget = obj
            idx = self._find_msg_index_at_widget(widget)
            if idx is not None:
                self._show_msg_context_menu(idx, event.globalPos())
                return True
        if obj is self._msg_scroll_area:
            if event.type() == QEvent.Type.DragEnter:
                if not self.current_conv_id:
                    event.ignore()
                    return True
                if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()):
                    event.acceptProposedAction()
                    self._msg_scroll_area.setStyleSheet(
                        f"QScrollArea {{ border: 2px dashed {c().accent}; }}"
                    )
                    return True
            elif event.type() == QEvent.Type.DragMove:
                if event.mimeData().hasUrls():
                    event.acceptProposedAction()
                    return True
            elif event.type() == QEvent.Type.DragLeave:
                self._msg_scroll_area.setStyleSheet(self._msg_area_normal_style())
                return True
            elif event.type() == QEvent.Type.Drop:
                self._msg_scroll_area.setStyleSheet(self._msg_area_normal_style())
                if event.mimeData().hasUrls():
                    for url in event.mimeData().urls():
                        if url.isLocalFile():
                            self._on_file_dropped(url.toLocalFile())
                    event.acceptProposedAction()
                    return True
        return super().eventFilter(obj, event)

    def _on_file_dropped(self, path: str):
        """Send a dropped file as image or file attachment."""
        if not self.current_conv_id:
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        import os
        ext = os.path.splitext(path)[1].lower()
        if ext in self._IMAGE_EXTENSIONS:
            self.bridge.send_image(
                self.current_conv_id, path, conv["members"],
                reply_to=self.reply_to_id,
            )
        else:
            self.bridge.send_file(
                self.current_conv_id, path, conv["members"],
                reply_to=self.reply_to_id,
            )
        self.reply_to_id = None
        self._reply_widget.setVisible(False)

    # -- Attach menu -------------------------------------------------------

    def _on_attach_image(self):
        if not self.current_conv_id:
            return
        path, _ = QFileDialog.getOpenFileName(
            self, "Select Image", "",
            "Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All Files (*)",
        )
        if not path:
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        self.bridge.send_image(
            self.current_conv_id, path, conv["members"],
            reply_to=self.reply_to_id,
        )
        self.reply_to_id = None
        self._reply_widget.setVisible(False)

    @staticmethod
    def _human_file_size(size_bytes):
        if size_bytes >= 1024 * 1024:
            return f"{size_bytes / (1024 * 1024):.1f} MB"
        elif size_bytes >= 1024:
            return f"{size_bytes / 1024:.0f} KB"
        return f"{size_bytes} B"

    @staticmethod
    def _file_icon(filename: str) -> str:
        """Return an emoji icon based on file extension."""
        ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
        _icons = {
            "pdf": "\U0001f4d5",        # red book
            "doc": "\U0001f4d8",         # blue book
            "docx": "\U0001f4d8",
            "odt": "\U0001f4d8",
            "xls": "\U0001f4ca",         # bar chart
            "xlsx": "\U0001f4ca",
            "ods": "\U0001f4ca",
            "csv": "\U0001f4ca",
            "ppt": "\U0001f4d9",         # orange book
            "pptx": "\U0001f4d9",
            "odp": "\U0001f4d9",
            "zip": "\U0001f4e6",         # package
            "rar": "\U0001f4e6",
            "7z": "\U0001f4e6",
            "tar": "\U0001f4e6",
            "gz": "\U0001f4e6",
            "mp3": "\U0001f3b5",         # music note
            "wav": "\U0001f3b5",
            "flac": "\U0001f3b5",
            "ogg": "\U0001f3b5",
            "m4a": "\U0001f3b5",
            "mp4": "\U0001f3ac",         # clapper board
            "mkv": "\U0001f3ac",
            "avi": "\U0001f3ac",
            "mov": "\U0001f3ac",
            "webm": "\U0001f3ac",
            "py": "\U0001f40d",          # snake
            "js": "\U0001f4dc",          # scroll
            "ts": "\U0001f4dc",
            "html": "\U0001f310",        # globe
            "css": "\U0001f3a8",         # palette
            "json": "\U0001f4cb",        # clipboard
            "xml": "\U0001f4cb",
            "yaml": "\U0001f4cb",
            "yml": "\U0001f4cb",
            "txt": "\U0001f4c4",         # page facing up
            "log": "\U0001f4c4",
            "md": "\U0001f4c4",
        }
        return _icons.get(ext, "\U0001f4ce")  # default: paperclip

    def _on_attach_file(self):
        if not self.current_conv_id:
            return
        path, _ = QFileDialog.getOpenFileName(
            self, "Select File", "",
            "All Files (*)",
        )
        if not path:
            return
        conv = None
        for cv in self.conversations:
            if cv["conversation_id"] == self.current_conv_id:
                conv = cv
                break
        if not conv:
            return
        self.bridge.send_file(
            self.current_conv_id, path, conv["members"],
            reply_to=self.reply_to_id,
        )
        self.reply_to_id = None
        self._reply_widget.setVisible(False)

    def _on_file_sent(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "File Error", msg)

    def _on_file_downloaded(self, data, file_info):
        filename = _safe_filename(file_info.get("filename", "file"), "file")
        path, _ = QFileDialog.getSaveFileName(self, "Save File", filename)
        if path:
            try:
                with open(path, "wb") as f:
                    f.write(data)
                QMessageBox.information(self, "Saved", f"File saved to {path}")
            except Exception as e:
                QMessageBox.warning(self, "Error", f"Failed to save: {e}")

    def _on_image_sent(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "Image Error", msg)

    def _view_image(self, msg):
        image_info = msg.get("image")
        if not image_info:
            return
        file_id = image_info.get("file_id", "")
        self._pending_image_download = {"file_id": file_id, "image_info": image_info}
        self.bridge.download_image(file_id, image_info)

    def _on_image_downloaded(self, file_id, data):
        if not self._pending_image_download or self._pending_image_download["file_id"] != file_id:
            return
        image_info = self._pending_image_download["image_info"]
        self._pending_image_download = None
        self._show_image_dialog(data, image_info)

    def _show_image_dialog(self, image_data, image_info):
        dlg = QDialog(self)
        dlg.setMinimumSize(400, 300)
        img_title = _safe_filename(image_info.get("filename", "Image"), "Image")
        layout = _make_frameless(dlg, img_title)

        qimg = _safe_load_image(image_data)
        if qimg is None:
            layout.addWidget(QLabel("Failed to load image."))
        else:
            pixmap = QPixmap.fromImage(qimg)
            label = QLabel()
            # Scale down if larger than screen
            screen_size = self.screen().availableSize()
            max_w = int(screen_size.width() * 0.8)
            max_h = int(screen_size.height() * 0.8)
            if pixmap.width() > max_w or pixmap.height() > max_h:
                pixmap = pixmap.scaled(max_w, max_h, Qt.AspectRatioMode.KeepAspectRatio,
                                       Qt.TransformationMode.SmoothTransformation)
            label.setPixmap(pixmap)
            label.setAlignment(Qt.AlignmentFlag.AlignCenter)

            scroll = QScrollArea()
            scroll.setWidget(label)
            scroll.setWidgetResizable(True)
            layout.addWidget(scroll)

        btn_row = QHBoxLayout()
        save_btn = QPushButton("Save")
        save_btn.clicked.connect(lambda: self._save_image(image_data, image_info, dlg))
        btn_row.addWidget(save_btn)
        close_btn = QPushButton("Close")
        close_btn.setObjectName("secondaryBtn")
        close_btn.clicked.connect(dlg.accept)
        btn_row.addWidget(close_btn)
        layout.addLayout(btn_row)

        if qimg is not None and not qimg.isNull():
            dlg.resize(min(pixmap.width() + 40, max_w),
                       min(pixmap.height() + 80, max_h))
        dlg.exec()

    def _save_image(self, image_data, image_info, dialog):
        filename = _safe_filename(image_info.get("filename", "image.jpg"), "image.jpg")
        path, _ = QFileDialog.getSaveFileName(dialog, "Save Image", filename)
        if path:
            try:
                with open(path, "wb") as f:
                    f.write(image_data)
                QMessageBox.information(dialog, "Saved", f"Image saved to {path}")
            except Exception as e:
                QMessageBox.warning(dialog, "Error", f"Failed to save: {e}")

    def _on_message_deleted(self, data):
        message_id = data.get("message_id", "")
        conv_id = data.get("conversation_id", "")
        if conv_id == self.current_conv_id:
            for msg in self.current_messages:
                if msg.get("message_id") == message_id:
                    msg["deleted"] = True
                    msg["text"] = ""
                    msg["image"] = None
                    break
            self._render_messages()

    def _on_delete_message_result(self, ok, msg):
        if not ok:
            QMessageBox.warning(self, "Delete Error", msg)
            return
        # No need to reload — _on_message_deleted() already updates in-place via notification

    def _on_authorize_result(self, ok, msg):
        if ok:
            QMessageBox.information(self, "Authorize Device", msg)
        else:
            QMessageBox.warning(self, "Authorize Device", msg)

    def _on_rotate_result(self, ok, msg):
        if ok:
            QMessageBox.information(self, "Rotate Keys", msg)
        else:
            QMessageBox.warning(self, "Rotate Keys", msg)

    def _on_password_changed(self, ok, msg):
        if ok:
            QMessageBox.information(self, "Change Password", msg)
        else:
            QMessageBox.warning(self, "Change Password", msg)

    def _on_change_username(self):
        t = c()
        current = ""
        if self.bridge and self.bridge.client:
            current = getattr(self.bridge.client, "username", "") or ""
        dlg = QDialog(self)
        dlg.setMinimumWidth(360)
        lay = _make_frameless(dlg, "Change Username")

        label = QLabel("New Username")
        label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;")
        lay.addWidget(label)
        name_input = QLineEdit()
        name_input.setText(current)
        name_input.setPlaceholderText("Enter new username")
        name_input.setMaxLength(100)
        name_input.setStyleSheet(
            f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; "
            f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; "
            f"color: {t.text_primary}; }}"
            f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}"
        )
        lay.addWidget(name_input)

        btn_row = QHBoxLayout()
        cancel_btn = QPushButton("Cancel")
        cancel_btn.setObjectName("secondaryBtn")
        cancel_btn.clicked.connect(dlg.reject)
        btn_row.addWidget(cancel_btn)
        save_btn = QPushButton("Save")
        save_btn.setObjectName("primaryBtn")
        save_btn.clicked.connect(dlg.accept)
        btn_row.addWidget(save_btn)
        lay.addLayout(btn_row)

        name_input.setFocus()
        if dlg.exec() == QDialog.DialogCode.Accepted:
            new_name = name_input.text().strip()
            if new_name and new_name != current:
                self.bridge.change_username(new_name)

    def _on_username_changed(self, ok, msg):
        if ok:
            QMessageBox.information(self, "Change Username", msg)
            self.bridge.schedule(self.bridge._do_load_conversations())
        else:
            QMessageBox.warning(self, "Change Username", msg)

    def _on_reencrypt_status(self, msg):
        self.reencrypt_label.setText(msg)
        self.reencrypt_label.setVisible(True)
        if msg.lower().startswith("re-encryption complete"):
            QTimer.singleShot(4000, lambda: self.reencrypt_label.setVisible(False))

    def closeEvent(self, event):
        if self._tray_icon:
            self._tray_icon.hide()
        if not self._is_logout:
            self.bridge.stop()
            self.bridge.wait(2000)
        event.accept()


def main():
    setup_logging()

    # Suppress Qt screen enumeration warnings on Windows (monitor sleep/wake)
    os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.screen=false")

    # Windows 10+ requires AppUserModelID for system tray notifications to work
    if sys.platform == "win32":
        try:
            import ctypes
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("com.encrypted-chat.client")
        except Exception:
            pass

    app = QApplication(sys.argv)
    app.setStyleSheet(qss())

    bridge = AsyncBridge()

    login_win = LoginWindow(bridge)
    main_win = [None]  # mutable ref

    def on_connected():
        login_win.reset()
        login_win.show()

    def on_conn_error(msg):
        QMessageBox.critical(None, "Connection Error", f"Cannot connect to server:\n{msg}")
        sys.exit(1)

    def on_register_result(ok, msg):
        if ok:
            # Show verification code page inline
            hint = ""
            if msg and len(msg) <= 6 and msg.isdigit():
                hint = f"Code: {msg}"
            elif msg:
                hint = msg
            login_win.show_verification_page(hint)

            def do_confirm(code):
                async def _confirm():
                    okc, msgc = await bridge.client.confirm_registration(
                        login_win.email_input.text().strip(),
                        login_win.username_input.text().strip(),
                        code.strip(),
                    )
                    if okc:
                        login_win.show_success(msgc)
                        bridge.do_login(login_win.email_input.text().strip(), login_win.password_input.text())
                    else:
                        login_win.show_error(msgc)
                bridge.schedule(_confirm())

            login_win._confirm_callback = do_confirm
        else:
            login_win.show_error(msg)

    def on_pairing_code(code):
        login_win.show_success(f"Pairing code: {code}")

    def on_pairing_complete(ok, msg):
        if ok:
            login_win.show_success(msg)
            bridge.do_login(login_win._pair_email, login_win._pair_password)
        else:
            login_win.show_error(msg)

    def on_login_result(ok, msg):
        if ok:
            login_win.show_success(msg)
            login_win.hide()
            tm().set_email(bridge.client.email)
            app.setStyleSheet(qss())
            main_win[0] = MainWindow(bridge, on_logout=lambda: (login_win.reset(), login_win.show()))
            main_win[0].show()
        else:
            login_win.show_error(msg)

    bridge.connected.connect(on_connected)
    bridge.connection_error.connect(on_conn_error)
    bridge.register_result.connect(on_register_result)
    bridge.login_result.connect(on_login_result)
    bridge.pairing_code.connect(on_pairing_code)
    bridge.pairing_complete.connect(on_pairing_complete)
    bridge.reconnected.connect(lambda: (login_win.reset(), login_win.show()))

    bridge.start()

    sys.exit(app.exec())


if __name__ == "__main__":
    main()
