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

import asyncio
import json
import logging
import os

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

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

from chat_core import ChatClient

# 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


# 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) -> 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.
    """
    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:#fab387; text-decoration:underline;">'
                    f'\U0001f513 {url_esc}</a>'
                )
            else:
                result.append(
                    f'<a href="{url_esc}" style="color:#89b4fa; 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")


DARK_STYLE = """
QWidget {
    background-color: #1e1e2e;
    color: #cdd6f4;
    font-family: "Segoe UI", "DejaVu Sans", sans-serif;
    font-size: 11pt;
}
QLineEdit {
    background-color: #313244;
    border: 1px solid #45475a;
    border-radius: 6px;
    padding: 8px;
    color: #cdd6f4;
}
QLineEdit:focus {
    border: 1px solid #89b4fa;
}
QPushButton {
    background-color: #89b4fa;
    color: #1e1e2e;
    border: none;
    border-radius: 6px;
    padding: 8px 16px;
    font-weight: bold;
}
QPushButton:hover {
    background-color: #74c7ec;
}
QPushButton:pressed {
    background-color: #89dceb;
}
QPushButton#secondaryBtn {
    background-color: #45475a;
    color: #cdd6f4;
}
QPushButton#secondaryBtn:hover {
    background-color: #585b70;
}
QListWidget {
    background-color: #181825;
    border: none;
    border-radius: 6px;
    padding: 4px;
}
QListWidget::item {
    padding: 10px;
    border-radius: 4px;
}
QListWidget::item:selected {
    background-color: #313244;
    border-left: 3px solid #89b4fa;
}
QListWidget::item:hover {
    background-color: #252536;
    color: #cdd6f4;
}
QTextEdit, QTextBrowser {
    background-color: #1e1e2e;
    border: none;
    border-radius: 6px;
    padding: 8px;
    color: #cdd6f4;
}
QScrollBar:vertical {
    background: #1e1e2e;
    width: 10px;
    border-radius: 5px;
}
QScrollBar::handle:vertical {
    background: #45475a;
    border-radius: 5px;
    min-height: 30px;
}
QScrollBar::handle:vertical:hover {
    background: #585b70;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
    height: 0;
}
QLabel#title {
    font-size: 15pt;
    font-weight: bold;
    color: #89b4fa;
}
QSplitter::handle {
    background-color: #45475a;
    width: 1px;
}
"""

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


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

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAcceptRichText(False)
        self.setPlaceholderText("Type a message...")
        self.setFixedHeight(72)
        self.setStyleSheet(
            "QTextEdit { background-color: #313244; border: 1px solid #45475a; "
            "border-radius: 6px; padding: 8px; color: #cdd6f4; }"
            "QTextEdit:focus { border: 1px solid #89b4fa; }"
        )

    def keyPressEvent(self, event):
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
                super().keyPressEvent(event)
            else:
                self.send_requested.emit()
                return
        super().keyPressEvent(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)
    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_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

    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._ready: asyncio.Event | None = None

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

    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:
            pass
        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_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 == "new_message":
                    payload = self.client.decrypt_notification(data)
                    if payload:
                        self.new_notification.emit(payload)
                    # None = control message (e.g. sender key distribution), skip silently
            except asyncio.TimeoutError:
                continue
            except Exception:
                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, msg = await self.client.send_message(conv_id, text, members, reply_to=reply_to)
        except Exception as e:
            logger.error("send_message exception: %s", e, exc_info=True)
            self.message_sent.emit(False, str(e))
            return
        self.message_sent.emit(ok, msg)
        if ok:
            # Reload messages to get the server-assigned message_id and timestamp
            msgs = await self.client.get_messages(conv_id, limit=50)
            self.messages_loaded.emit(conv_id, msgs)

    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 c in convs:
                if c["conversation_id"] == conv_id:
                    members = c["members"]
                    break
            ok, msg = await self.client.send_message(conv_id, text, members)
            self.message_sent.emit(ok, msg)
            if ok:
                msgs = await self.client.get_messages(conv_id)
                self.messages_loaded.emit(conv_id, msgs)
        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_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()
        ok, msg = await self.client.send_image(conv_id, image_path, members, reply_to=reply_to)
        self.image_sent.emit(ok, msg)
        if ok:
            msgs = await self.client.get_messages(conv_id, limit=50)
            self.messages_loaded.emit(conv_id, msgs)

    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()
        ok, msg = await self.client.send_file(conv_id, file_path, members, reply_to=reply_to)
        self.file_sent.emit(ok, msg)
        if ok:
            msgs = await self.client.get_messages(conv_id, limit=50)
            self.messages_loaded.emit(conv_id, msgs)

    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()
        data = await self.client.get_avatar(user_id)
        if data:
            self.avatar_loaded.emit(user_id, data)

    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()
        invitations = await self.client.list_invitations()
        self.invitations_loaded.emit(invitations)

    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()
        data = await self.client.get_group_avatar(conv_id)
        if data:
            self.group_avatar_loaded.emit(conv_id, data)

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

    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)


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.setWindowTitle("User Profile" if not editable else "Edit Profile")
        self.setMinimumWidth(400)
        self._build_ui()
        self._connect_signals()
        self.bridge.get_profile(user_id)

    def _build_ui(self):
        self.layout_main = QVBoxLayout(self)
        self.layout_main.setSpacing(12)
        self.layout_main.setContentsMargins(24, 20, 24, 20)

        # Avatar
        self.avatar_label = QLabel()
        self.avatar_label.setFixedSize(80, 80)
        self.avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.avatar_label.setStyleSheet(
            "background-color: #313244; border-radius: 40px; "
            "font-size: 21pt; color: #89b4fa;"
        )
        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("font-size: 14pt; font-weight: bold; color: #89b4fa;")
        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("font-weight: bold; color: #89b4fa;")
            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("color: #cdd6f4;")
            self.layout_main.addWidget(self.email_visible_cb)

            self.phone_visible_cb = QCheckBox("Phone visible to others")
            self.phone_visible_cb.setStyleSheet("color: #cdd6f4;")
            self.layout_main.addWidget(self.phone_visible_cb)

            self.location_visible_cb = QCheckBox("Location visible to others")
            self.location_visible_cb.setStyleSheet("color: #cdd6f4;")
            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)

        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("color: #6c7086;")
            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)

    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 LoginWindow(QWidget):
    def __init__(self, bridge: AsyncBridge):
        super().__init__()
        self.bridge = bridge
        self.setWindowTitle("Encrypted Chat - Login")
        self.setFixedSize(500, 480)
        self._pair_email = ""
        self._pair_password = ""
        self._build_ui()

    def _build_ui(self):
        outer = QVBoxLayout(self)
        outer.setContentsMargins(0, 0, 0, 0)

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

        # --- Page 0: Login / Register form ---
        page0 = QWidget()
        layout = QVBoxLayout(page0)
        layout.setSpacing(14)
        layout.setContentsMargins(50, 40, 50, 40)

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

        subtitle = QLabel("End-to-end encrypted messaging")
        subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
        subtitle.setStyleSheet("color: #6c7086; font-size: 9pt; margin-bottom: 8px;")
        layout.addWidget(subtitle)

        layout.addSpacing(6)

        self.username_input = QLineEdit()
        self.username_input.setPlaceholderText("Username (display)")
        self.username_input.returnPressed.connect(self._on_login)
        layout.addWidget(self.username_input)

        self.email_input = QLineEdit()
        self.email_input.setPlaceholderText("Email")
        layout.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)
        layout.addWidget(self.password_input)

        btn_row = QHBoxLayout()
        self.register_btn = QPushButton("Register")
        self.register_btn.setObjectName("secondaryBtn")
        self.register_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton))
        self.register_btn.clicked.connect(self._on_register)
        btn_row.addWidget(self.register_btn)

        self.login_btn = QPushButton("Login")
        self.login_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton))
        self.login_btn.clicked.connect(self._on_login)
        btn_row.addWidget(self.login_btn)
        layout.addLayout(btn_row)

        self.link_btn = QPushButton("Link Device")
        self.link_btn.setObjectName("secondaryBtn")
        self.link_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton))
        self.link_btn.clicked.connect(self._on_link_device)
        layout.addWidget(self.link_btn)

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

        layout.addStretch()
        self.stack.addWidget(page0)

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

        step_label = QLabel("Step 2 of 2")
        step_label.setStyleSheet("color: #89b4fa; font-weight: bold; font-size: 10pt;")
        step_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        vl.addWidget(step_label)

        info_label = QLabel("Enter the 6-digit verification code sent to your email")
        info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #cdd6f4; font-size: 10pt;")
        vl.addWidget(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(
            "QLineEdit { font-size: 16pt; letter-spacing: 8px; text-align: center; "
            "background-color: #313244; border: 1px solid #45475a; border-radius: 6px; padding: 12px; }"
            "QLineEdit:focus { border: 1px solid #89b4fa; }"
        )
        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("color: #a6e3a1;")
        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("color: #f38ba8;")
            return
        self.code_status_label.setText("Confirming...")
        self.code_status_label.setStyleSheet("color: #a6e3a1;")
        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.password_input.text()
        email = self.email_input.text().strip()
        if not username:
            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.email_input.text().strip()
        password = self.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.username_input.setEnabled(enabled)
        self.email_input.setEnabled(enabled)
        self.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("color: #f38ba8;")
            self.confirm_btn.setEnabled(True)
            self.back_btn.setEnabled(True)
        else:
            self.status_label.setText(msg)
            self.status_label.setStyleSheet("color: #f38ba8;")
            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("color: #a6e3a1;")
        else:
            self.status_label.setText(msg)
            self.status_label.setStyleSheet("color: #a6e3a1;")

    def reset(self):
        self.stack.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.code_input.clear()
        self._set_enabled(True)
        self.confirm_btn.setEnabled(True)
        self.back_btn.setEnabled(True)


class MainWindow(QWidget):
    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: dict[str, QPixmap] = {}  # user_id -> pixmap
        self._group_avatar_cache: dict[str, QPixmap] = {}  # conv_id -> pixmap
        self._avatar_requested: set[str] = set()
        self._group_avatar_requested: set[str] = set()
        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._build_ui()
        self._connect_signals()

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

        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

    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
        hue = (hash(username) % 360)
        color = QColor.fromHsv(hue, 120, 200)
        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", 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
        # Dark border
        painter.setBrush(QBrush(QColor(0x1e, 0x1e, 0x2e)))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(x - 1, y - 1, dot_size + 2, dot_size + 2)
        # Green dot
        painter.setBrush(QBrush(QColor(0xa6, 0xe3, 0xa1)))
        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 _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)

        header_row = QHBoxLayout()
        conv_label = QLabel("Conversations")
        conv_label.setStyleSheet("font-weight: bold; font-size: 12pt; color: #89b4fa;")
        header_row.addWidget(conv_label)
        header_row.addStretch()

        new_chat_btn = QPushButton("")
        new_chat_btn.setFixedSize(32, 32)
        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("secondaryBtn")
        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)

        auth_btn = QPushButton("")
        auth_btn.setFixedSize(32, 32)
        auth_btn.setObjectName("secondaryBtn")
        auth_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton))
        auth_btn.setToolTip("Authorize Device")
        auth_btn.clicked.connect(self._on_authorize_device)
        header_row.addWidget(auth_btn)

        rotate_btn = QPushButton("")
        rotate_btn.setFixedSize(32, 32)
        rotate_btn.setObjectName("secondaryBtn")
        rotate_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload))
        rotate_btn.setToolTip("Rotate Keys")
        rotate_btn.clicked.connect(self._on_rotate_keys)
        header_row.addWidget(rotate_btn)

        profile_btn = QPushButton("")
        profile_btn.setFixedSize(32, 32)
        profile_btn.setObjectName("secondaryBtn")
        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)

        logout_btn = QPushButton("")
        logout_btn.setFixedSize(32, 32)
        logout_btn.setObjectName("secondaryBtn")
        logout_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton))
        logout_btn.setToolTip("Logout")
        logout_btn.clicked.connect(self._on_logout)
        header_row.addWidget(logout_btn)

        left_layout.addLayout(header_row)

        # Invitation section (hidden when empty)
        self.inv_label = QLabel("Pending Invitations")
        self.inv_label.setStyleSheet("font-weight: bold; font-size: 9pt; color: #f9e2af; 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(
            "QListWidget { background-color: #1e1e2e; border: 1px solid #f9e2af; border-radius: 6px; padding: 2px; }"
            "QListWidget::item { padding: 6px; color: #cdd6f4; }"
            "QListWidget::item:hover { background-color: #252536; color: #cdd6f4; }"
        )
        left_layout.addWidget(self.inv_list)

        self.conv_list = QListWidget()
        from PyQt6.QtCore import QSize
        self.conv_list.setIconSize(QSize(32, 32))
        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)

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

        chat_header_row = QHBoxLayout()
        self.chat_header_avatar = QLabel()
        self.chat_header_avatar.setFixedSize(28, 28)
        self.chat_header_avatar.setVisible(False)
        chat_header_row.addWidget(self.chat_header_avatar)
        self.chat_header = QLabel("Select a conversation")
        self.chat_header.setStyleSheet("font-weight: bold; font-size: 12pt; color: #89b4fa;")
        chat_header_row.addWidget(self.chat_header)

        self.connection_dot = QLabel("\u25cf")
        self.connection_dot.setFixedSize(16, 16)
        self.connection_dot.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.connection_dot.setStyleSheet("color: #a6e3a1; 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("secondaryBtn")
        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("secondaryBtn")
        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(
            "QPushButton { background-color: #45475a; color: #f38ba8; border: none; border-radius: 6px; }"
            "QPushButton:hover { background-color: #f38ba8; color: #1e1e2e; }"
        )
        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("secondaryBtn")
        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.search_btn = QPushButton("")
        self.search_btn.setFixedSize(32, 32)
        self.search_btn.setObjectName("secondaryBtn")
        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.addLayout(chat_header_row)

        # 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(
            "QLineEdit { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; "
            "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("secondaryBtn")
        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("secondaryBtn")
        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("color: #6c7086; 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("secondaryBtn")
        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)

        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)

        self.message_area = QTextBrowser()
        self.message_area.setReadOnly(True)
        self.message_area.setOpenExternalLinks(False)
        self.message_area.setOpenLinks(False)
        self.message_area.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.message_area.customContextMenuRequested.connect(self._on_message_context_menu)
        self.message_area.anchorClicked.connect(self._on_anchor_clicked)
        right_layout.addWidget(self.message_area, stretch=1)

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

        # "New messages" floating button (hidden by default)
        self.jump_btn = QPushButton("New messages \u2193")
        self.jump_btn.setParent(self.message_area)
        self.jump_btn.setVisible(False)
        self.jump_btn.setFixedHeight(28)
        self.jump_btn.setStyleSheet(
            "QPushButton { background-color: #89b4fa; color: #1e1e2e; border-radius: 14px; "
            "padding: 0 16px; font-size: 8pt; font-weight: bold; }"
            "QPushButton:hover { background-color: #74c7ec; }"
        )
        self.jump_btn.clicked.connect(self._scroll_to_bottom)

        self.reply_label = QLabel("")
        self.reply_label.setStyleSheet("color: #89b4fa; font-style: italic; padding: 2px 4px;")
        self.reply_label.setVisible(False)
        right_layout.addWidget(self.reply_label)

        # Input row
        input_row = QHBoxLayout()
        self.msg_input = MessageInput()
        self.msg_input.send_requested.connect(self._on_send)
        self.msg_input.textChanged.connect(self._on_input_changed)
        input_row.addWidget(self.msg_input)

        attach_btn = QPushButton("")
        attach_btn.setFixedSize(52, 72)
        attach_btn.setObjectName("secondaryBtn")
        attach_btn.setIconSize(QSize(24, 24))
        attach_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon))
        attach_menu = QMenu(attach_btn)
        attach_menu.addAction("Image", self._on_attach_image)
        attach_menu.addAction("File", self._on_attach_file)
        attach_btn.setMenu(attach_menu)
        input_row.addWidget(attach_btn)

        send_btn = QPushButton("Send")
        send_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight))
        send_btn.clicked.connect(self._on_send)
        send_btn.setFixedHeight(72)
        input_row.addWidget(send_btn)
        right_layout.addLayout(input_row)

        self.char_counter = QLabel(f"0 / {MAX_INPUT_CHARS}")
        self.char_counter.setStyleSheet("color: #6c7086; 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(
            "background-color: #313244; border-radius: 6px; "
            "padding: 8px 12px; color: #a6e3a1; 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(
            "background-color: #181825; border-radius: 0px; "
            "padding: 0 8px; color: #a6e3a1; 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.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.reencrypt_status.connect(self._on_reencrypt_status)
        self.bridge.messages_read_notification.connect(self._on_messages_read)
        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)

    # ------------------------------------------------------------------
    # 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 c in convs:
            cid = c["conversation_id"]
            server_unread = c.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: unread 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, c in enumerate(self.conversations):
            conv_id = c["conversation_id"]
            base_label = self._get_conv_display_name(c)
            star = "\u2605 " if conv_id in self._favorites else ""
            count = self._unread_counts.get(conv_id, 0)
            label = f"{star}({count}) {base_label}" if count > 0 else f"{star}{base_label}"
            item = QListWidgetItem(label)
            item.setData(Qt.ItemDataRole.UserRole, conv_id)
            item.setIcon(self._get_conv_avatar(c))
            if count > 0:
                item.setData(Qt.ItemDataRole.FontRole, self._bold_font())
                item.setForeground(Qt.GlobalColor.white)
            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: re-download avatars for known users and reload invitations."""
        # Re-request avatars for all cached users (server returns latest)
        for uid in list(self._avatar_requested):
            self.bridge.get_avatar(uid)
        # Re-request group avatars
        for conv_id in list(self._group_avatar_requested):
            self.bridge.get_group_avatar(conv_id)
        self.bridge.list_invitations()

    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")
        self.status_bar.setText(f"{inviter} invited you to {conv_name}")
        self.status_bar.setStyleSheet(
            "background-color: #181825; border-radius: 0px; "
            "padding: 0 8px; color: #f9e2af; 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):
        if state == "connected":
            self.connection_dot.setStyleSheet("color: #a6e3a1; font-size: 11pt;")
            self.connection_dot.setToolTip("Connected")
            self.status_bar.setText("Connected")
            self.status_bar.setStyleSheet(
                "background-color: #181825; border-radius: 0px; "
                "padding: 0 8px; color: #a6e3a1; font-size: 8pt;"
            )
            QTimer.singleShot(3000, self._clear_status_bar)
        elif state == "disconnected":
            self.connection_dot.setStyleSheet("color: #f38ba8; font-size: 11pt;")
            self.connection_dot.setToolTip("Disconnected")
            self.status_bar.setText("Disconnected from server")
            self.status_bar.setStyleSheet(
                "background-color: #181825; border-radius: 0px; "
                "padding: 0 8px; color: #f38ba8; font-size: 8pt; font-weight: bold;"
            )
            self._status_bar_conv_id = None
        elif state == "reconnecting":
            self.connection_dot.setStyleSheet("color: #fab387; font-size: 11pt;")
            self.connection_dot.setToolTip("Reconnecting...")
            self.status_bar.setText("Reconnecting...")
            self.status_bar.setStyleSheet(
                "background-color: #181825; border-radius: 0px; "
                "padding: 0 8px; color: #fab387; font-size: 8pt;"
            )
            self._status_bar_conv_id = None
        elif state == "revoked":
            self.connection_dot.setStyleSheet("color: #f38ba8; 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.chat_header.setText("Select a conversation")
            self.chat_header_avatar.setVisible(False)
            self.message_area.clear()
            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)

    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()
        btn_w = self.jump_btn.sizeHint().width()
        self.jump_btn.move((w - btn_w) // 2, self.message_area.height() - 40)

    def _clear_status_bar(self):
        self.status_bar.setText("")
        self.status_bar.setStyleSheet(
            "background-color: #181825; border-radius: 0px; "
            "padding: 0 8px; color: #a6e3a1; 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 = 28
        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)
            else:
                self.chat_header_avatar.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)

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

    def _update_conv_list_styles(self):
        for i in range(self.conv_list.count()):
            item = self.conv_list.item(i)
            conv_id = item.data(Qt.ItemDataRole.UserRole)
            count = self._unread_counts.get(conv_id, 0)
            others = []
            conv_name = None
            conv = None
            for c in self.conversations:
                if c["conversation_id"] == conv_id:
                    others = [m.get("username") or m.get("email") or "?" for m in c["members"]
                              if m.get("email") != self.bridge.client.email]
                    conv_name = c.get("name")
                    conv = c
                    break
            base_label = conv_name or (", ".join(others) if others else self.bridge.client.username)
            star = "\u2605 " if conv_id in self._favorites else ""
            item.setText(f"{star}({count}) {base_label}" if count > 0 else f"{star}{base_label}")
            if conv:
                item.setIcon(self._get_conv_avatar(conv))
            if count > 0:
                item.setData(Qt.ItemDataRole.FontRole, self._bold_font())
            else:
                item.setData(Qt.ItemDataRole.FontRole, None)

    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"]
        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_label.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._close_search()
        self.bridge.load_messages(self.current_conv_id)

    def _on_messages_loaded(self, conv_id, messages):
        if conv_id != self.current_conv_id:
            return
        self.current_messages = messages
        # 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()

    def _render_messages(self, scroll_to_bottom=True):
        html_parts = []
        for i, m in enumerate(self.current_messages):
            html_parts.append(self._render_single_message_html(m, i))

        self.message_area.setHtml("".join(html_parts))
        # Register thumbnail images as document resources
        self._register_thumbnails()
        # Re-set HTML so images resolve
        self.message_area.setHtml("".join(html_parts))
        if scroll_to_bottom:
            sb = self.message_area.verticalScrollBar()
            sb.setValue(sb.maximum())

    def _register_thumbnails(self):
        """Add thumbnail images as resources to the QTextDocument."""
        from PyQt6.QtCore import QUrl
        doc = self.message_area.document()
        for m in self.current_messages:
            image_info = m.get("image")
            if not image_info:
                continue
            thumbnail_b64 = image_info.get("thumbnail", "")
            file_id = image_info.get("file_id", "")
            if not thumbnail_b64 or not file_id:
                continue
            from protocol import decode_binary
            try:
                thumb_bytes = decode_binary(thumbnail_b64)
                qimg = _safe_load_image(thumb_bytes)
                if qimg is not None:
                    url = QUrl(f"thumb://{file_id}")
                    doc.addResource(
                        doc.ResourceType.ImageResource.value,
                        url,
                        qimg,
                    )
            except Exception:
                pass

    def _register_single_thumbnail(self, m):
        """Register a single message's thumbnail as a document resource."""
        image_info = m.get("image")
        if not image_info:
            return
        thumbnail_b64 = image_info.get("thumbnail", "")
        file_id = image_info.get("file_id", "")
        if not thumbnail_b64 or not file_id:
            return
        from PyQt6.QtCore import QUrl
        from protocol import decode_binary
        try:
            thumb_bytes = decode_binary(thumbnail_b64)
            qimg = _safe_load_image(thumb_bytes)
            if qimg is not None:
                doc = self.message_area.document()
                url = QUrl(f"thumb://{file_id}")
                doc.addResource(
                    doc.ResourceType.ImageResource.value,
                    url,
                    qimg,
                )
        except Exception:
            pass

    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 _render_single_message_html(self, m, index):
        """Render HTML for a single message."""
        is_dm = self._is_dm

        # Handle deleted messages
        if m.get("deleted"):
            timestamp = m.get("created_at", "")
            time_str = ""
            if timestamp:
                time_short = timestamp[11:16] if len(timestamp) >= 16 else timestamp
                time_str = f' <span style="font-size:10px; color:#6c7086; font-weight:normal;">\u00b7 {time_short}</span>'
            prefix = ""
            is_me = m.get("sender") == self.bridge.client.username or m.get("sender_id") == (
                self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "")
            if is_me:
                del_align = "right"
                del_border = "border-right:3px solid #6c7086; border-left:none;"
            else:
                del_align = "left"
                del_border = "border-left:3px solid #6c7086;"
            return (
                f'<div style="text-align:{del_align}; margin:6px 0;">'
                f'<a name="msg:{index}"></a>'
                f'<span style="display:inline-block; background-color:#1e1e2e; '
                f'{del_border} border-radius:4px; padding:8px 14px;">'
                f'<i style="color:#6c7086;">{prefix}{time_str} Zpr\u00e1va byla smaz\u00e1na</i>'
                f'</span></div>'
            )

        sender = m.get("sender", "???")
        text = m.get("text", "")
        timestamp = m.get("created_at", "")
        text = _linkify_urls(text)
        text = text.replace("\n", "<br/>")
        # Search highlighting
        if self._search_active and self._search_query and index in self._search_results:
            is_current_match = (self._search_current >= 0
                                and self._search_current < len(self._search_results)
                                and self._search_results[self._search_current] == index)
            bg_color = "#fab387" if is_current_match else "#f9e2af"
            text = self._highlight_search_text(text, self._search_query, bg_color)
        sender_esc = sender.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

        is_me = sender == self.bridge.client.username
        if is_me:
            color = "#89b4fa"
            bg = "#1e1e3a"
            border = "border-right:3px solid #89b4fa; border-left:none;"
            align = "right"
        else:
            color = "#f9e2af"
            bg = "#1e1e2e"
            border = "border-left:3px solid #f9e2af; border-right:none;"
            align = "left"

        reply_html = ""
        if m.get("reply_to"):
            for orig in self.current_messages:
                if orig["message_id"] == m["reply_to"]:
                    orig_sender = orig.get("sender", "???")
                    orig_sender_esc = orig_sender.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
                    orig_text = orig.get("text", "")[:50]
                    orig_text = orig_text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
                    reply_html = (
                        f'<div style="font-size:11px; color:#6c7086; background-color:#181825; '
                        f'border-left:2px solid #585b70; border-radius:3px; padding:4px 8px; '
                        f'margin-bottom:6px;">'
                        f'<b style="color:#585b70;">{orig_sender_esc}</b><br/>{orig_text}'
                        f'</div>'
                    )
                    break

        time_str = ""
        if timestamp:
            time_short = timestamp[11:16] if len(timestamp) >= 16 else timestamp
            time_str = f' <span style="font-size:10px; color:#6c7086; font-weight:normal;">\u00b7 {time_short}</span>'

        # Image rendering
        image_html = ""
        image_info = m.get("image")
        if image_info:
            thumbnail_b64 = image_info.get("thumbnail", "")
            filename = image_info.get("filename", "image")
            filename_esc = filename.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            size_bytes = image_info.get("size", 0)
            size_kb = size_bytes / 1024
            if size_kb >= 1024:
                size_str = f"{size_kb/1024:.1f} MB"
            else:
                size_str = f"{size_kb:.0f} KB"
            file_id = image_info.get("file_id", "")
            if thumbnail_b64:
                image_html = (
                    f'<div style="margin:4px 0;">'
                    f'<a href="image://{file_id}">'
                    f'<img src="thumb://{file_id}" width="200" /></a>'
                    f'</div>'
                    f'<div style="font-size:11px; color:#89b4fa; margin-top:2px;">'
                    f'<a href="image://{file_id}" style="color:#89b4fa; text-decoration:none;">'
                    f'{filename_esc} ({size_str}) \u2014 Click to view</a></div>'
                )
            # Clear text if it's just the placeholder
            if text == "[Image: " + filename_esc + "]":
                text = ""

        # File rendering
        file_html = ""
        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_html = (
                f'<div style="background:transparent; border:1px solid #45475a; border-radius:6px; padding:8px; margin:4px 0;">'
                f'{icon} <a href="file://{f_id}" style="color:#89b4fa;">{fname_esc}</a>'
                f' <span style="color:#6c7086;">({size_str})</span>'
                f'</div>'
            )

        read_html = ""
        if is_me:
            read_by = m.get("read_by", [])
            member_map = {}
            for c in self.conversations:
                if c["conversation_id"] == self.current_conv_id:
                    for mem in c["members"]:
                        uid = mem.get("user_id") or mem.get("id")
                        if uid:
                            member_map[uid] = mem.get("username") or mem.get("email") or "?"
                    break
            my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
            others_read = [r for r in read_by if r.get("user_id") != my_user_id]
            if others_read:
                names = ", ".join(member_map.get(r["user_id"], r["user_id"][:8]) for r in others_read)
                read_html = f'<div style="font-size:10px; color:#6c7086; margin-top:4px;">\u2713\u2713 Read by {names}</div>'
            else:
                read_html = '<div style="font-size:10px; color:#6c7086; margin-top:4px;">\u2713 Sent</div>'

        text_html = f'{text}' if text else ''

        # DM: no sender name, no message number — just timestamp
        if is_dm:
            header_line = f'<span style="font-size:10px; color:#6c7086;">{time_str.strip()}</span>'
        else:
            header_line = f'<b style="color:{color};">{sender_esc}{time_str}</b>'

        return (
            f'<div style="text-align:{align}; margin:8px 0;">'
            f'<a name="msg:{index}"></a>'
            f'<span style="display:inline-block; background-color:{bg}; '
            f'{border} border-radius:4px; padding:10px 16px; max-width:75%;">'
            f'{reply_html}'
            f'{header_line}<br/>'
            f'{text_html}'
            f'{image_html}'
            f'{file_html}'
            f'{read_html}'
            f'</span></div>'
        )

    def _find_message_at_pos(self, pos):
        """Find the message index at the given position in message_area."""
        cursor = self.message_area.cursorForPosition(pos)
        # Walk backwards through blocks to find the nearest msg: anchor
        block = cursor.block()
        while block.isValid():
            frag_it = block.begin()
            while frag_it != block.end():
                frag = frag_it.fragment()
                if frag.isValid():
                    fmt = frag.charFormat()
                    anchor = fmt.anchorNames()
                    if anchor:
                        for a in anchor:
                            if a.startswith("msg:"):
                                try:
                                    return int(a[4:])
                                except ValueError:
                                    pass
                frag_it += 1
            block = block.previous()
        return None

    def _on_message_context_menu(self, pos):
        if not self.current_messages:
            return
        idx = self._find_message_at_pos(pos)
        if idx is None or 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)
        menu.setStyleSheet(
            "QMenu { background-color: #313244; border: 1px solid #45475a; border-radius: 6px; padding: 4px; }"
            "QMenu::item { padding: 6px 12px; color: #cdd6f4; }"
            "QMenu::item:selected { background-color: #45475a; }"
        )

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

        # 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(self.message_area.mapToGlobal(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_label.setVisible(True)
            self.msg_input.setFocus()
        elif chosen == del_action:
            confirm = QMessageBox.question(
                self, "Delete Message",
                "Delete this message? This cannot be undone.",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            )
            if confirm == QMessageBox.StandardButton.Yes:
                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:
                confirm = QMessageBox.question(
                    self, "Reset Session",
                    "Reset encryption session with this sender? "
                    "A new session will be created on the next message.",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                )
                if confirm == QMessageBox.StandardButton.Yes:
                    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):
        self.message_area.scrollToAnchor(f"msg:{index}")

    @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:#1e1e2e;">{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 c in self.conversations:
            for m in c.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.")
        self.status_bar.setStyleSheet(
            "background-color: #181825; border-radius: 0px; "
            "padding: 0 8px; color: #f9e2af; font-size: 8pt; font-weight: bold;"
        )
        QTimer.singleShot(8000, self._clear_status_bar)

    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 = "#f38ba8" if count > MAX_INPUT_CHARS * 0.9 else "#6c7086"
        self.char_counter.setStyleSheet(f"color: {color}; font-size: 8pt; padding: 0 4px;")
        self.char_counter.setText(f"{count} / {MAX_INPUT_CHARS}")

    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 c in self.conversations:
            if c["conversation_id"] == self.current_conv_id:
                conv = c
                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_label.setVisible(False)

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

    def _on_new_chat(self):
        email, ok = QInputDialog.getText(self, "New Chat", "Email:")
        if not ok or not email.strip():
            return
        text, ok2 = QInputDialog.getText(self, "New Chat", "Message:")
        if not ok2 or not text.strip():
            return
        if len(text.strip()) > 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.strip(), text.strip())

    def _on_new_group(self):
        name, ok = QInputDialog.getText(self, "New Group", "Group name:")
        if not ok:
            return
        members, ok2 = QInputDialog.getText(self, "New Group", "Member emails (comma-separated):")
        if not ok2 or not members.strip():
            return
        member_list = [m.strip() for m in members.split(",") if m.strip()]
        if member_list:
            self.bridge.create_group(member_list, name=name.strip() or None)

    def _on_add_member(self):
        if not self.current_conv_id:
            return
        email, ok = QInputDialog.getText(self, "Add Member", "Email to add:")
        if not ok or not email.strip():
            return
        self.bridge.add_member(self.current_conv_id, email.strip())

    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 c in self.conversations:
            if c["conversation_id"] == self.current_conv_id:
                conv = c
                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.setWindowTitle("Group Info")
        dlg.setMinimumWidth(380)
        dlg_layout = QVBoxLayout(dlg)

        # 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:#89b4fa;'>{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 ""
            name_text = f"{online_dot}<b>{uname_esc}</b>"
            if email:
                name_text += f"  <span style='color:#6c7086;'>{email_esc}</span>"
            if is_mem_creator:
                name_text += "  <span style='color:#a6e3a1;'>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(
            "QPushButton { background-color: #f38ba8; color: #1e1e2e; font-weight: bold; }"
            "QPushButton:hover { background-color: #eba0ac; }"
        )
        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.chat_header.setText("Select a conversation")
            self.chat_header_avatar.setVisible(False)
            self.message_area.clear()
            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.chat_header.setText("Select a conversation")
            self.chat_header_avatar.setVisible(False)
            self.message_area.clear()
            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 c in self.conversations:
            if c["conversation_id"] == self.current_conv_id:
                conv = c
                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_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_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", "")

        # Show notification in status bar (with conv name for non-current conversations)
        if conv_id and conv_id != self.current_conv_id:
            conv_name = sender
            is_notif_dm = False
            for c in self.conversations:
                if c["conversation_id"] == conv_id:
                    is_notif_dm = len(c["members"]) == 2 and not c.get("name")
                    if not is_notif_dm:
                        conv_name = c.get("name") or sender
                    break
            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}")
            self.status_bar.setStyleSheet(
                "background-color: #181825; border-radius: 0px; "
                "padding: 0 8px; color: #a6e3a1; font-size: 8pt; font-weight: bold;"
            )
            self._status_bar_conv_id = conv_id
            QTimer.singleShot(5000, self._clear_status_bar)

        # Increment unread count if not currently viewing this conversation
        if conv_id and conv_id != self.current_conv_id:
            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:
            self.current_messages.append(payload)
            # Register thumbnail if this is an image message
            self._register_single_thumbnail(payload)
            idx = len(self.current_messages) - 1
            html = self._render_single_message_html(payload, idx)
            self.message_area.append(html)
            if self._is_near_bottom:
                sb = self.message_area.verticalScrollBar()
                sb.setValue(sb.maximum())
            else:
                self.jump_btn.setVisible(True)
                self._position_jump_btn()
            # Mark as read
            msg_id = payload.get("message_id")
            if msg_id:
                self.bridge.schedule(
                    self.bridge.client.mark_read(conv_id, [msg_id])
                )

    def _on_messages_read(self, data):
        conv_id = data.get("conversation_id", "")
        if conv_id == self.current_conv_id:
            # Update read status in memory instead of re-fetching
            user_id = data.get("user_id", "")
            message_ids = set(data.get("message_ids", []))
            for msg in self.current_messages:
                if msg.get("message_id") in message_ids:
                    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._render_messages(scroll_to_bottom=self._is_near_bottom)

    def _on_anchor_clicked(self, url):
        url_str = url.toString()
        if url_str.startswith("image://"):
            file_id = url_str[len("image://"):]
            # Find message with this 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,
                "Nezabezpečený odkaz",
                f"Tento odkaz používá nešifrované HTTP spojení.\n\n{url_str}\n\nChcete přesto pokračovat?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No,
            )
            if reply == QMessageBox.StandardButton.Yes:
                QDesktopServices.openUrl(QUrl(url_str))

    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 c in self.conversations:
            if c["conversation_id"] == self.current_conv_id:
                conv = c
                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_label.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 c in self.conversations:
            if c["conversation_id"] == self.current_conv_id:
                conv = c
                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_label.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.setWindowTitle(_safe_filename(image_info.get("filename", "Image"), "Image"))
        dlg.setMinimumSize(400, 300)
        layout = QVBoxLayout(dlg)

        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)

        dlg.resize(min(pixmap.width() + 40, max_w) if not qimg.isNull() else 400,
                   min(pixmap.height() + 80, max_h) if not qimg.isNull() else 300)
        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
        # Reload messages to reflect deletion
        if self.current_conv_id:
            self.bridge.load_messages(self.current_conv_id)

    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_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 not self._is_logout:
            self.bridge.stop()
            self.bridge.wait(2000)
        event.accept()


def main():
    setup_logging()
    app = QApplication(sys.argv)
    app.setStyleSheet(DARK_STYLE)

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