import argparse
import asyncio
import inspect
import json
import pathlib
import pyperclip
import time
import uuid
import urllib.request
import urllib.error
import ssl
import socks
import socket
from typing import Any

from emoji_width_fix import apply_fix as _apply_emoji_fix
_apply_emoji_fix()

from textual import work
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Label, Static, ListView, TabbedContent, TabPane, Tab, Input
from textual.screen import ModalScreen
from textual import events
from textual.binding import Binding

from config import APP_DIR
from crypto_utils import Identity, fmt_date, now_ts, short_snip, set_time_display_settings
from database import DB
from network import NetClient
from global_settings import load_global_config
from ui_components import (
    ChatItem,
    RequestInItem,
    RequestOutItem,
    GroupInviteInItem,
    UserItem,
    ReplyBar,
    CallTab,
    InfoTab,
    MessageWidget,
    SidebarResizeHandle,
    ConfirmDialog,
    InfoDialog,
    TextPromptDialog,
    NewChatMenu,
    ContextMenu,
    RequestDialog,
    Composer,
    SettingsScreen,
    UserProfileDialog,
    MyStatusDialog,
    FileManagerDialog,
    SaveFileDialog,
    PeerSearchPanel,
    CreateGroupDialog,
)
from ui.screens.loading import LoadingScreen
from ui.i18n import t as translate, normalize_lang
from ui.styles import CSS as APP_CSS
from ui.themes import normalize_theme, theme_list
from ui.widgets.command_palette import CommandPalette, SlashCommand, get_builtin_commands, merge_plugin_commands
from plugins import get_plugin_loader
import config, crypto_utils, database, network, ui_components, launcher, global_settings

from voice import VoiceEngine, VoiceParams
from ui.widgets.call_panel import CallParticipantItem, CallUserPopover, GroupMembersPanel

class FatalErrorScreen(ModalScreen):
    CSS = """
    FatalErrorScreen { align: center middle; background: $surface 50%; }
    #err_dialog { width: 60; height: auto; background: $panel; border: solid red; padding: 1 2; }
    .err-title { color: red; text-style: bold; text-align: center; margin-bottom: 1; }
    .err-text { margin-bottom: 1; text-align: center; }
    #btn_quit { width: 100%; }
    """

    def __init__(self, message: str):
        super().__init__()
        self.message = message

    def compose(self) -> ComposeResult:
        with Vertical(id="err_dialog"):
            yield Label("Login Failed", classes="err-title")
            yield Label(self.message, classes="err-text")
            yield Button("Quit", id="btn_quit", variant="error")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        self.app.exit()

class ChatApp(App):
    CSS = APP_CSS

    TARGET_FPS = 60

    NOTIFY_TIMEOUT_DEFAULT = 8.0
    NOTIFY_TIMEOUT_ERROR = 15.0
    NOTIFY_TIMEOUT_WARNING = 10.0

    def notify(
        self,
        message: str,
        *,
        title: str = "",
        severity: str = "information",
        timeout: float | None = None,
    ) -> None:
        if timeout is None:
            if severity == "error":
                timeout = self.NOTIFY_TIMEOUT_ERROR
            elif severity == "warning":
                timeout = self.NOTIFY_TIMEOUT_WARNING
            else:
                timeout = self.NOTIFY_TIMEOUT_DEFAULT
        super().notify(message, title=title, severity=severity, timeout=timeout)

    def t(self, key: str, **kwargs) -> str:
        return translate((getattr(self, 'lang', None) or 'en'), key, **kwargs)

    BINDINGS = [
        Binding("ctrl+left", "shrink_sidebar", show=False),
        Binding("ctrl+right", "grow_sidebar", show=False),
        Binding("escape", "cancel_reply", show=False),
        Binding("ctrl+v", "paste", show=False),
        Binding("f7", "toggle_select_mode", "Select Mode", show=False),
        Binding("ctrl+q", "quit_app", "Quit", show=False),
    ]

    sidebar_width: int = reactive(34)
    sidebar_collapsed: bool = reactive(False)
    info_expanded: bool = reactive(False)
    active_chat: str | None = reactive(None)
    reply_to: str | None = reactive(None)

    _find_mode: bool = False
    _find_matches: list = []
    _find_index: int = 0
    _find_pattern: str = ""

    _select_mode: bool = False
    _selected_messages: set = set()
    _select_drag_start: str | None = None
    _last_selected_id: str | None = None

    def _kb_matches(self, context: str, action: str, event) -> bool:
        if hasattr(self, '_keybindings_manager') and self._keybindings_manager:
            return self._keybindings_manager.matches(context, action, event)

        from ui.keybindings import DEFAULT_BINDINGS, normalize_key
        default_key = DEFAULT_BINDINGS.get(context, {}).get(action, "")
        if not default_key:
            return False
        return normalize_key(default_key) == normalize_key(event.key)

    def on_key(self, event: events.Key) -> None:

        if self._kb_matches("global", "toggle_select", event):
            self.action_toggle_select_mode()
            event.stop()
            event.prevent_default()
            return

        if self._find_mode:
            if self._kb_matches("find_mode", "find_next", event):
                self._find_next()
                event.stop()
                event.prevent_default()
                return
            elif self._kb_matches("find_mode", "find_prev", event):
                self._find_prev()
                event.stop()
                event.prevent_default()
                return
            elif self._kb_matches("find_mode", "exit", event):
                self._find_exit()
                event.stop()
                event.prevent_default()
                return

        if self._select_mode:
            if self._kb_matches("select_mode", "exit", event):
                self._exit_select_mode()
                event.stop()
                event.prevent_default()
                return
            elif self._kb_matches("select_mode", "delete", event) or event.key == "backspace":
                self.run_worker(self._delete_selected_messages())
                event.stop()
                event.prevent_default()
                return
            elif self._kb_matches("select_mode", "select_all", event):
                self._select_all_messages()
                event.stop()
                event.prevent_default()
                return
            elif self._kb_matches("select_mode", "clear_selection", event):
                self._clear_selection()
                event.stop()
                event.prevent_default()
                return

        if not hasattr(self, '_plugin_loader') or not self._plugin_loader:
            return

        modifiers = []
        if event.key.startswith("ctrl+"):
            modifiers.append("ctrl")
        if event.key.startswith("shift+") or "+shift+" in event.key:
            modifiers.append("shift")
        if event.key.startswith("alt+") or "+alt+" in event.key:
            modifiers.append("alt")

        key_name = event.key.split("+")[-1] if "+" in event.key else event.key

        from plugins.base import HookType
        results = self._plugin_loader.call_hook(HookType.ON_KEY_PRESS, key_name, modifiers)
        if any(r is True for r in results):
            event.stop()
            event.prevent_default()

    def _find_start(self, pattern: str) -> None:
        import re
        self._find_pattern = pattern
        self._find_matches = []
        self._find_index = 0

        try:
            regex = re.compile(pattern, re.IGNORECASE)
        except re.error:
            self.notify(self.t("find_invalid_regex"), severity="error")
            return

        if not self.active_chat:
            self.notify(self.t("find_no_results"), severity="warning")
            return

        try:
            from ui.widgets.messaging import MessageWidget
            msg_view = self.query_one("#messages", VerticalScroll)
            widgets = list(msg_view.query(MessageWidget))

            for w in widgets:
                msg = getattr(w, 'msg', None)
                if not msg:
                    continue
                msg_id = msg.get("msg_id") or msg.get("id")
                body = msg.get("body", "")
                if msg_id and regex.search(body):
                    self._find_matches.append(msg_id)

            self._find_matches.reverse()
        except Exception:
            pass

        if not self._find_matches:
            self.notify(self.t("find_no_results"), severity="warning")
            return

        self._find_mode = True

        try:
            find_bar = self.query_one("#find_bar")
            find_bar.remove_class("hidden")
            find_bar.refresh()
        except Exception:
            pass

        self._find_update_bar()
        self._find_jump_to_current()

    def _find_next(self) -> None:
        if not self._find_matches:
            return

        self._find_index = (self._find_index + 1) % len(self._find_matches)
        self._find_update_bar()
        self._find_jump_to_current()

    def _find_prev(self) -> None:
        if not self._find_matches:
            return

        self._find_index = (self._find_index - 1) % len(self._find_matches)
        self._find_update_bar()
        self._find_jump_to_current()

    def _find_exit(self) -> None:
        self._find_mode = False
        self._find_matches = []
        self._find_index = 0
        self._find_pattern = ""

        try:
            from ui.widgets.messaging import MessageWidget
            msg_view = self.query_one("#messages", VerticalScroll)
            for w in msg_view.query(MessageWidget):
                if w.has_class("find-highlight"):
                    w.remove_class("find-highlight")
                    w.refresh()
        except Exception:
            pass

        try:
            find_bar = self.query_one("#find_bar")
            find_bar.add_class("hidden")
            find_bar.refresh()
        except Exception:
            pass

    def action_toggle_select_mode(self) -> None:
        if self._select_mode:
            self._exit_select_mode()
        else:
            self._enter_select_mode()

    def _enter_select_mode(self) -> None:
        if not self.active_chat:
            self.notify(self.t("no_chat_selected") if hasattr(self, 't') else "No chat selected", severity="warning")
            return

        self._select_mode = True
        self._selected_messages = set()
        self._select_drag_start = None

        self._show_select_bar()

        self.notify(self.t("select_mode_help"))

    def _exit_select_mode(self) -> None:
        self._select_mode = False
        self._select_drag_start = None
        self._last_selected_id = None

        self._clear_message_selections()
        self._selected_messages = set()

        self._hide_select_bar()

        self.notify(self.t("select_mode_exited"))

    def _show_select_bar(self) -> None:
        try:
            select_bar = self.query_one("#select_bar")
            select_bar.remove_class("hidden")
            select_bar.refresh()

            hint = self.query_one("#select_bar_hint", Static)
            hint.update(self.t("select_bar_hint"))
        except Exception as e:
            print(f"[SelectMode] Error showing bar: {e}")

    def _hide_select_bar(self) -> None:
        try:
            select_bar = self.query_one("#select_bar")
            select_bar.add_class("hidden")
            select_bar.refresh()
        except Exception as e:
            print(f"[SelectMode] Error hiding bar: {e}")

    def _update_select_bar(self) -> None:
        try:
            count_label = self.query_one("#select_bar_count", Static)
            count = len(self._selected_messages)
            count_label.update(self.t("select_count", n=count))
            count_label.refresh()
        except Exception as e:
            print(f"[SelectMode] Error updating bar: {e}")

    def _toggle_message_selection(self, msg_id: str) -> None:
        if msg_id in self._selected_messages:
            self._selected_messages.discard(msg_id)
            self._set_message_selected(msg_id, False)
        else:
            self._selected_messages.add(msg_id)
            self._set_message_selected(msg_id, True)

        self._update_select_bar()

    def _set_message_selected(self, msg_id: str, selected: bool) -> None:
        try:
            if msg_id in self._msg_widgets:
                widget = self._msg_widgets[msg_id]
                if selected:
                    widget.add_class("msg-selected")
                else:
                    widget.remove_class("msg-selected")
                widget.refresh()
        except Exception:
            pass

    def _clear_message_selections(self) -> None:
        try:
            from ui.widgets.messaging import MessageWidget
            msg_view = self.query_one("#messages", VerticalScroll)
            for w in msg_view.query(MessageWidget):
                if w.has_class("msg-selected"):
                    w.remove_class("msg-selected")
                    w.refresh()
        except Exception:
            pass

    def _clear_selection(self) -> None:
        self._clear_message_selections()
        self._selected_messages = set()
        self._last_selected_id = None
        self._update_select_bar()

        self.notify(self.t("selection_cleared"))

    def _select_all_messages(self) -> None:
        if not self.active_chat:
            return

        try:
            from ui.widgets.messaging import MessageWidget
            msg_view = self.query_one("#messages", VerticalScroll)

            for w in msg_view.query(MessageWidget):
                msg = getattr(w, 'msg', None)
                if msg:
                    msg_id = msg.get("msg_id") or msg.get("id")
                    if msg_id:
                        self._selected_messages.add(msg_id)
                        w.add_class("msg-selected")
                        w.refresh()

            self._update_select_bar()
            self.notify(self.t("select_all_done", n=len(self._selected_messages)))
        except Exception as e:
            print(f"[SelectMode] Error selecting all: {e}")

    def _select_range(self, start_id: str, end_id: str) -> None:
        try:
            from ui.widgets.messaging import MessageWidget
            msg_view = self.query_one("#messages", VerticalScroll)

            in_range = False
            for w in msg_view.query(MessageWidget):
                msg = getattr(w, 'msg', None)
                if not msg:
                    continue
                msg_id = msg.get("msg_id") or msg.get("id")
                if not msg_id:
                    continue

                if msg_id == start_id or msg_id == end_id:
                    if in_range:

                        self._selected_messages.add(msg_id)
                        w.add_class("msg-selected")
                        w.refresh()
                        break
                    else:

                        in_range = True

                if in_range:
                    self._selected_messages.add(msg_id)
                    w.add_class("msg-selected")
                    w.refresh()

            self._update_select_bar()
        except Exception as e:
            print(f"[SelectMode] Error selecting range: {e}")

    async def _delete_selected_messages(self) -> None:
        if not self._selected_messages:
            self.notify(self.t("no_messages_selected"), severity="warning")
            return

        count = len(self._selected_messages)

        from ui.dialogs import ConfirmDialog
        confirmed = await self.push_screen_wait(ConfirmDialog(
            title=self.t("dlg_delete_message"),
            body=self.t("confirm_delete_messages", n=count)
        ))

        if not confirmed:
            return

        deleted_count = 0
        peer = self.active_chat

        for msg_id in list(self._selected_messages):
            try:

                self.db.delete_message(msg_id)

                await self.net.send_control_delete(peer, msg_id)
                deleted_count += 1
            except Exception as e:
                print(f"[SelectMode] Error deleting message {msg_id}: {e}")

        self._exit_select_mode()

        await self.refresh_active_chat()

        self.notify(self.t("messages_deleted", n=deleted_count))

    def _find_update_bar(self) -> None:
        try:
            pattern_label = self.query_one("#find_bar_pattern", Static)
            status = self.query_one("#find_bar_status", Static)
            hint = self.query_one("#find_bar_hint", Static)

            pat = self._find_pattern[:20] + "..." if len(self._find_pattern) > 20 else self._find_pattern
            pattern_label.update(f'"{pat}" ')

            if self._find_matches:
                status_text = f"[bold]{self._find_index + 1}/{len(self._find_matches)}[/] "
            else:
                status_text = "[dim]0/0[/] "

            status.update(status_text)

            hint.update(f"[dim]{self.t('find_bar_hint')}[/]")

            pattern_label.refresh()
            status.refresh()
            hint.refresh()
        except Exception as e:

            self.notify(f"Find bar error: {e}", severity="error")

    def _find_jump_to_current(self) -> None:
        if not self._find_matches or self._find_index >= len(self._find_matches):
            return

        target_id = self._find_matches[self._find_index]

        try:
            from ui.widgets.messaging import MessageWidget
            msg_view = self.query_one("#messages", VerticalScroll)

            for w in msg_view.query(MessageWidget):
                if w.has_class("find-highlight"):
                    w.remove_class("find-highlight")
                    w.refresh()

            for w in msg_view.query(MessageWidget):
                msg = getattr(w, 'msg', None)
                if not msg:
                    continue
                msg_id = msg.get("msg_id") or msg.get("id")
                if msg_id == target_id:
                    w.scroll_visible()
                    w.add_class("find-highlight")
                    w.refresh()
                    break
        except Exception as e:
            self.notify(f"Find jump error: {e}", severity="error")

    def notify_requests(self) -> None:
        try:
            tabs_c = self.query_one("#tabs", TabbedContent)
            try:
                tab = tabs_c.query_one("#tab-tab_requests", Tab)
                tab.add_class("attn")
                self.set_timer(1.0, lambda: tab.remove_class("attn"))
                return
            except Exception:
                pass
            try:
                tab_bar = tabs_c.query_one("TabBar")
                tabs = list(tab_bar.query(Tab))
                if len(tabs) >= 2:
                    tab = tabs[1]
                    tab.add_class("attn")
                    self.set_timer(1.0, lambda: tab.remove_class("attn"))
            except Exception:
                pass
        except Exception:
            pass

    async def _on_new_request_receive(self, peer_alias: str, peer_fp: str, payload: dict) -> None:
        try:
            from plugins.base import HookType
            import time

            request_data = {
                "peer_alias": peer_alias,
                "peer_fp": peer_fp,
                "received_at": int(time.time()),
                "payload": payload
            }

            hook_results = self._plugin_loader.call_hook(HookType.ON_REQUEST_RECEIVE, request_data)

            action = None
            for result in hook_results:
                if asyncio.iscoroutine(result):
                    result = await result
                if result in ("accept", "decline"):
                    action = result
                    break

            if action == "accept":

                requests_in = self.db.list_requests_in()
                selected = next((r for r in requests_in if r["peer_alias"] == peer_alias), None)
                if selected:
                    peer_eph = selected["payload"].get("eph_pub")
                    if peer_eph:
                        await self.net.send_hs2(peer_alias, peer_eph, peer_fp)
                    self.db.delete_request_in(selected["id"])
                    self.refresh_sidebar()
                    self.active_chat = peer_alias
                    await self.refresh_active_chat()
            elif action == "decline":

                await self.net.send_hs_reject(peer_alias)
                requests_in = self.db.list_requests_in()
                selected = next((r for r in requests_in if r["peer_alias"] == peer_alias), None)
                if selected:
                    self.db.delete_request_in(selected["id"])
                self.refresh_sidebar()
        except Exception as e:
            print(f"Error in _on_new_request_receive: {e}")

    def __init__(self, ident: Identity, db: DB, server_url: str, global_config: dict = None):
        super().__init__()
        self.ident = ident
        self.db = db
        self.server_url = server_url
        self.global_config = global_config or {}
        self.net = NetClient(
            ident,
            db,
            server_url,
            self,
            proxy_config=self.global_config.get("network"),
            file_config=self.global_config.get("files"),
            cloud_config=self.global_config.get("cloud"),
        )

        self.my_account = None
        self.theme_name = self.db.get_setting("theme") or "default"
        self.lang = self.db.get_setting("lang") or "en"

        try:
            tz = self.db.get_setting("display_timezone") or "UTC"
            fmt = self.db.get_setting("display_time_format") or "24h"
            set_time_display_settings(tz=tz, fmt=fmt)
        except Exception:
            set_time_display_settings(tz="UTC", fmt="24h")

        try:
            from ui.widgets.appearance import ColorRulesManager
            self._color_rules_manager = ColorRulesManager(self.db)
        except Exception:
            self._color_rules_manager = None

        try:
            from ui.keybindings import init_keybindings_manager
            self._keybindings_manager = init_keybindings_manager(self.db)
        except Exception:
            self._keybindings_manager = None

        self._msg_widgets = {}
        self._refresh_lock = asyncio.Lock()

        self._refresh_active_chat_task: asyncio.Task | None = None
        self._refresh_active_chat_requested: bool = False

        self._refresh_sidebar_task: asyncio.Task | None = None
        self._refresh_sidebar_requested: bool = False

        self._rendered_peer = None
        self._rendered_ids = []
        self._net_task = None
        self._fps_timer = None

        self._chat_page_size = 80
        self._chat_window_limit = 2000
        self._chat_window_size = 0
        self._chat_oldest_cursor = None
        self._chat_has_older = False
        self._chat_loading_older = False
        self._chat_rebuild_anchor_msg_id = None
        self._chat_scroll_timer = None
        self._call_peer: str | None = None
        self._call_id: str | None = None
        self._call_params: VoiceParams | None = None
        self._voice: VoiceEngine | None = None

        self._call_ringing_from: str | None = None
        self._call_ringing_id: str | None = None
        self._call_incoming_obj: dict | None = None
        self._call_cooldown_until: dict[str, float] = {}
        self._ring_task: asyncio.Task | None = None

        self._group_call_id: str | None = None
        self._group_call_group: str | None = None
        self._group_call_participants: set[str] = set()
        self._group_call_ringing: dict | None = None
        self._pending_group_call_info: dict | None = None
        self._group_call_pending_targets: dict[str, set[str]] = {}
        self._active_group_calls: dict[str, int] = {}

        self._call_chat_id: str | None = None
        self._call_participant_widgets: dict[str, CallParticipantItem] = {}
        self._call_remote_muted: set[str] = set()
        self._call_remote_gain: dict[str, float] = {}
        self._call_speaking_until: dict[str, float] = {}
        self._call_speaking_timer = None

        self.reply_bar = ReplyBar()
        self.composer = Composer(id="composer")
        self.composer.show_line_numbers = False
        self.composer.wrap = True

        self._saved_sidebar_width = self.sidebar_width

        self._plugin_loader = get_plugin_loader()
        self._plugin_loader.set_db(self.db)
        self._plugin_loader.set_app(self)
        self._plugin_loader.discover_plugins()

        self._plugin_loader.init_enabled_plugins()

    def action_quit_app(self) -> None:
        self._cleanup_before_exit()
        self.exit()

    def exit(self, result: Any = None, return_code: int = 0, message: str | None = None) -> None:
        self._cleanup_before_exit()
        super().exit(result=result, return_code=return_code, message=message)

    def _cleanup_before_exit(self) -> None:

        if getattr(self, '_cleanup_done', False):
            return
        self._cleanup_done = True

        try:
            if hasattr(self, '_plugin_loader') and self._plugin_loader:
                for plugin in self._plugin_loader._plugins.values():
                    try:
                        plugin._close_plugin_db()
                    except Exception:
                        pass
        except Exception:
            pass

        try:
            if hasattr(self, 'db') and self.db:
                self.db.close()
        except Exception:
            pass

        try:
            if hasattr(self, 'net') and self.net:
                if hasattr(self.net, 'close'):
                    self.net.close()
        except Exception:
            pass

    def request_refresh_active_chat(self, *, immediate: bool = False) -> None:
        self._refresh_active_chat_requested = True
        t = self._refresh_active_chat_task
        if t is not None and not t.done():
            return

        async def _runner() -> None:
            nonlocal immediate
            while True:
                if not immediate:
                    await asyncio.sleep(0.05)
                self._refresh_active_chat_requested = False
                try:
                    await self.refresh_active_chat()
                except Exception:
                    pass
                if not self._refresh_active_chat_requested:
                    break
                immediate = False

        self._refresh_active_chat_task = asyncio.create_task(_runner())

    def request_refresh_sidebar(self, *, immediate: bool = False) -> None:
        self._refresh_sidebar_requested = True
        t = self._refresh_sidebar_task
        if t is not None and not t.done():
            return

        async def _runner() -> None:
            nonlocal immediate
            while True:
                if not immediate:
                    await asyncio.sleep(0.05)
                self._refresh_sidebar_requested = False
                try:
                    self.refresh_sidebar()
                except Exception:
                    pass
                if not self._refresh_sidebar_requested:
                    break
                immediate = False

        self._refresh_sidebar_task = asyncio.create_task(_runner())

    def system_log(self, text: str):
        try:
            self.query_one("#system_log", Static).update(text)
        except Exception:
            pass

    def _fmt_bytes(self, n: int | None) -> str:
        try:
            v = float(int(n or 0))
        except Exception:
            return "0 B"
        units = ["B", "KB", "MB", "GB", "TB"]
        i = 0
        while v >= 1024.0 and i < len(units) - 1:
            v /= 1024.0
            i += 1
        if i == 0:
            return f"{int(v)} {units[i]}"
        return f"{v:.1f} {units[i]}"

    def _set_transfer_ui(self, text: str, *, visible: bool) -> None:
        try:
            bar = self.query_one("#transfer_bar", Horizontal)

        except Exception:
            return
        try:
            lbl = self.query_one("#transfer_status", Static)
            lbl.update(text or "")
        except Exception:
            pass
        try:
            cancel_btn = self.query_one("#transfer_cancel", Button)
            cancel_btn.disabled = not visible
        except Exception:
            pass
        try:
            ok_btn = self.query_one("#transfer_ok", Button)
            ok_btn.styles.display = "none"
            ok_btn.disabled = True
        except Exception:
            pass
        try:
            bar.display = bool(visible)
        except Exception:
            pass

    def _set_transfer_done_ui(self, text: str) -> None:
        try:
            bar = self.query_one("#transfer_bar", Horizontal)
        except Exception:
            return
        try:
            self.query_one("#transfer_status", Static).update(text or "")
        except Exception:
            pass
        try:
            cancel_btn = self.query_one("#transfer_cancel", Button)
            cancel_btn.disabled = True
        except Exception:
            pass
        try:
            ok_btn = self.query_one("#transfer_ok", Button)
            ok_btn.styles.display = "block"
            ok_btn.disabled = False
        except Exception:
            pass
        try:
            bar.display = True
        except Exception:
            pass

    def on_file_send_progress(self, info: dict) -> None:
        try:
            if not isinstance(info, dict):
                return
            phase = str(info.get("phase") or "")
            name = str(info.get("name") or "file")
            done = int(info.get("bytes_done") or 0)
            total = int(info.get("bytes_total") or 0)
        except Exception:
            return

        if phase == "encrypt":
            pct = ""
            if total > 0:
                try:
                    pct = f" ({min(100, int(done * 100 / max(1, total)))}%)"
                except Exception:
                    pct = ""
            txt = f"Sending {name}: {self._fmt_bytes(done)}/{self._fmt_bytes(total)}{pct}"
            self._set_transfer_ui(txt, visible=True)
        elif phase == "upload":
            pct = ""
            if total > 0:
                try:
                    pct = f" ({min(100, int(done * 100 / max(1, total)))}%)"
                except Exception:
                    pct = ""
            txt = f"Uploading to cloud: {name} {self._fmt_bytes(done)}/{self._fmt_bytes(total)}{pct}"
            self._set_transfer_ui(txt, visible=True)
        elif phase == "done":
            self._set_transfer_ui("", visible=False)

    def on_file_download_progress(self, info: dict) -> None:
        try:
            if not isinstance(info, dict):
                return
            phase = str(info.get("phase") or "")
            name = str(info.get("name") or "file")
            done = int(info.get("bytes_done") or 0)
            total = int(info.get("bytes_total") or 0)
        except Exception:
            return

        if phase == "download":
            pct = ""
            if total > 0:
                try:
                    pct = f" ({min(100, int(done * 100 / max(1, total)))}%)"
                except Exception:
                    pct = ""
            if total > 0:
                txt = f"Downloading: {name} {self._fmt_bytes(done)}/{self._fmt_bytes(total)}{pct}"
            else:
                txt = f"Downloading: {name} {self._fmt_bytes(done)}"
            self._set_transfer_ui(txt, visible=True)
        elif phase == "decrypt":
            pct = ""
            if total > 0:
                try:
                    pct = f" ({min(100, int(done * 100 / max(1, total)))}%)"
                except Exception:
                    pct = ""
            if total > 0:
                txt = f"Decrypting: {name} {self._fmt_bytes(done)}/{self._fmt_bytes(total)}{pct}"
            else:
                txt = f"Decrypting: {name} {self._fmt_bytes(done)}"
            self._set_transfer_ui(txt, visible=True)
        elif phase == "done":

            return

    async def _run_send_file_task(self, *, chat_id: str, path: str, caption: str, reply_to):
        try:
            if isinstance(chat_id, str) and chat_id.startswith("g:"):
                await self.net.send_group_file_offer(chat_id[2:], path, caption, reply_to, progress_cb=self.on_file_send_progress)
            else:
                await self.net.send_file_offer(chat_id, path, caption, reply_to, progress_cb=self.on_file_send_progress)

            self.composer.clear()
            self.reply_to = None
            self.reply_bar.hide_reply()
            self.set_focus(self.composer)
        except asyncio.CancelledError:
            self.system_log("File send cancelled")
            raise
        except Exception as e:
            self.system_log(f"File send failed: {e}")
        finally:
            self.on_file_send_progress({"phase": "done"})
            try:
                self._file_send_task = None
            except Exception:
                pass

    async def _run_download_file_task(
        self,
        *,
        file_id: str,
        file_key: str,
        record_count: int,
        record_hashes: list,
        sender: str | None,
        cloud_blob_sha256: str | None,
        save_path: str,
        name: str,
        bytes_total: int | None,
    ) -> None:
        out = None
        ok = False
        try:
            await self.net.download_ciphertext(
                str(file_id),
                int(record_count),
                list(record_hashes or []),
                seed=str(sender) if isinstance(sender, str) and sender else None,
                cloud_blob_sha256=str(cloud_blob_sha256) if isinstance(cloud_blob_sha256, str) and cloud_blob_sha256 else None,
                name=str(name),
                bytes_total_hint=int(bytes_total) if isinstance(bytes_total, int) and bytes_total > 0 else None,
                progress_cb=self.on_file_download_progress,
            )
            out = await self.net.decrypt_to_path(
                file_id=str(file_id),
                file_key_b64=str(file_key),
                out_path=str(save_path),
                name=str(name),
                progress_cb=self.on_file_download_progress,
            )
            ok = True
            self.system_log(f"Saved: {out}")
        except asyncio.CancelledError:
            self.system_log("Download cancelled")
            raise
        except Exception as e:
            self.system_log(f"Download failed: {e}")
        finally:
            try:
                self._file_download_task = None
            except Exception:
                pass

        if ok and out is not None:
            p = str(out)
            self._set_transfer_done_ui(self.t("download_done_status", path=p))
        else:
            self._set_transfer_ui("", visible=False)
    def _get_bool_setting(self, key: str, default: bool = False) -> bool:
        try:
            v = self.db.get_setting(key)
        except Exception:
            v = None
        if v is None:
            return bool(default)
        s = str(v).strip().lower()
        if s in ("1", "true", "yes", "on"):
            return True
        if s in ("0", "false", "no", "off", ""):
            return False
        return bool(default)

    def _set_bool_setting(self, key: str, value: bool) -> None:
        try:
            self.db.set_setting(key, "1" if value else "0")
        except Exception:
            pass

    def cloud_user_enabled(self) -> bool:
        return self._get_bool_setting("cloud_user_enabled", False)

    def cloud_user_publish_snapshots(self) -> bool:
        return self._get_bool_setting("cloud_user_publish_snapshots", False)

    def cloud_user_publish_files(self) -> bool:
        return self._get_bool_setting("cloud_user_publish_files", False)

    def cloud_chat_mode(self) -> str:
        try:
            v = (self.db.get_setting("cloud_chat_mode") or "all").strip().lower()
            return v if v in ("all", "selected") else "all"
        except Exception:
            return "all"

    def cloud_selected_chats(self) -> list[str]:
        try:
            raw = self.db.get_setting("cloud_selected_chats_json")
        except Exception:
            raw = None
        if not raw:
            return []
        try:
            v = json.loads(str(raw))
        except Exception:
            return []
        if not isinstance(v, list):
            return []
        return [str(x) for x in v if isinstance(x, str) and x]

    def set_cloud_selected_chats(self, chats: list[str]) -> None:
        try:
            cleaned = [str(x) for x in chats if isinstance(x, str) and x]
            out: list[str] = []
            seen = set()
            for x in cleaned:
                if x in seen:
                    continue
                seen.add(x)
                out.append(x)
            self.db.set_setting("cloud_selected_chats_json", json.dumps(out, ensure_ascii=False))
        except Exception:
            pass

    def toggle_chat_seed(self, peer_alias: str) -> None:

        try:
            self.db.set_setting("cloud_chat_mode", "selected")
        except Exception:
            pass
        cur = self.cloud_selected_chats()
        s = set(cur)
        if peer_alias in s:
            s.remove(peer_alias)
        else:
            s.add(peer_alias)
        self.set_cloud_selected_chats(list(s))

    def _restore_tmp_path(self) -> pathlib.Path:
        return APP_DIR / f"restore_{self.ident.alias}.db"

    def _backup_dir(self) -> pathlib.Path:
        p = APP_DIR / "backups" / self.ident.alias
        p.mkdir(parents=True, exist_ok=True)
        return p

    def list_backups(self) -> list[pathlib.Path]:
        p = self._backup_dir()
        items = [x for x in p.glob("*.db") if x.is_file()]
        items.sort(key=lambda x: x.stat().st_mtime, reverse=True)
        return items

    def create_backup(self, passphrase: str | None = None) -> pathlib.Path | None:
        dst_path = self._backup_dir() / f"backup_{int(now_ts())}.db"
        try:

            if hasattr(self.db, "export_backup"):
                self.db.export_backup(dst_path, passphrase=passphrase)
            else:

                dst_path.write_bytes(b"")
                raise RuntimeError("Database backend does not support export_backup")
            return dst_path
        except Exception as e:

            error_msg = f"Backup failed: {e}"
            print(f"[Backup Error] {error_msg}")
            try:
                self.notify(error_msg, severity="error")
            except Exception:
                pass
            try:
                dst_path.unlink(missing_ok=True)
            except Exception:
                pass
            try:
                from database import _marker_path
                _marker_path(dst_path).unlink(missing_ok=True)
            except Exception:
                pass
            return None

    def is_db_encrypted(self) -> bool:
        return self.db.is_encrypted() if hasattr(self.db, 'is_encrypted') else False

    def delete_backup_file(self, path: pathlib.Path) -> None:
        try:
            pathlib.Path(path).unlink(missing_ok=True)
        except Exception:
            pass

    def _stage_restore_bytes(self, db_bytes: bytes) -> bool:
        if not isinstance(db_bytes, (bytes, bytearray)) or len(db_bytes) < 16:
            return False
        if not bytes(db_bytes).startswith(b"SQLite format 3\x00"):
            return False
        try:
            tmp = self._restore_tmp_path()
            tmp.write_bytes(bytes(db_bytes))
            return True
        except Exception:
            return False

    async def cloud_sync_up(self) -> None:
        try:
            ok, reason = await self.net.publish_db_snapshot_if_needed(force=True, wait_for_ack=True, timeout=300.0)
            if ok:
                self.notify(self.t("cloud_sync_up_done"))
            else:
                error_details = {
                    "no_db_path": "Database path not found",
                    "snapshot_create_failed": "Failed to create snapshot",
                    "timeout": "Server did not respond (upload may be slow, try again)",
                }.get(reason.split(":")[0] if reason else "", reason)
                self.notify(f"{self.t('cloud_sync_up_failed')}: {error_details}", severity="error")
        except Exception as e:
            self.notify(f"Cloud sync failed: {e}", severity="error")

    @work
    async def cloud_sync_down(self) -> None:
        try:
            self.system_log(self.t("cloud_fetching") if hasattr(self, "t") else "Fetching from cloud…")
        except Exception:
            pass

        try:
            snap = await self.net.fetch_latest_db_snapshot(timeout=120.0)
        except Exception as e:
            msg = str(e) if e else ""

            suffix = ""
            try:
                import re

                m = re.search(r"\(nodes[^)]*\)", msg)
                if m:
                    suffix = " " + m.group(0)
            except Exception:
                suffix = ""
            if msg.startswith("NO_NODES"):
                self.notify(self.t("cloud_no_nodes") + suffix, severity="error")
            elif msg.startswith("TIMEOUT"):
                self.notify(self.t("cloud_fetch_timeout") + suffix, severity="error")
            elif msg.startswith("NOT_SEEDED"):
                self.notify(self.t("cloud_not_seeded") + suffix, severity="error")
            elif msg.startswith("NOT_FOUND"):
                self.notify(self.t("cloud_no_snapshot") + suffix, severity="warning")
            elif msg.startswith("BAD_NODE"):
                self.notify(self.t("cloud_bad_node") + suffix, severity="error")
            elif msg.startswith("DECRYPT_FAILED"):
                self.notify(self.t("cloud_bad_snapshot") + suffix, severity="error")
            else:
                self.notify(f"Cloud fetch failed: {msg or e}", severity="error")
            return
        finally:
            try:
                self.system_log("")
            except Exception:
                pass

        if not snap:
            self.notify(self.t("cloud_no_snapshot"), severity="warning")
            return

        self.create_backup()
        if not self._stage_restore_bytes(snap):
            self.notify(self.t("cloud_bad_snapshot"), severity="error")
            return
        self.notify(self.t("cloud_restore_staged"))
        self.exit(result="restart_restore")

    def restore_backup_file(self, path: pathlib.Path) -> None:
        src = pathlib.Path(path)

        try:
            from database import DB, _marker_path

            if DB.has_passphrase_file(src):
                tmp = self._restore_tmp_path()
                try:
                    import shutil

                    shutil.copy2(src, tmp)
                    shutil.copy2(_marker_path(src), _marker_path(tmp))
                except Exception:
                    self.notify(self.t("backup_read_err"), severity="error")
                    return
                self.create_backup()
                self.notify(self.t("backup_restore_staged"))
                self.exit(result="restart_restore")
                return
        except Exception:

            pass

        try:
            data = src.read_bytes()
        except Exception:
            self.notify(self.t("backup_read_err"), severity="error")
            return
        self.create_backup()
        if not self._stage_restore_bytes(data):
            self.notify(self.t("cloud_bad_snapshot"), severity="error")
            return
        self.notify(self.t("backup_restore_staged"))
        self.exit(result="restart_restore")

    async def seed_file_id(self, file_id: str) -> None:
        if not getattr(self.net, "seeding_enabled", True):
            self.notify(self.t("seeding_disabled"), severity="warning")
            return
        try:
            has = self.db.has_file(file_id)
            p = self.net._file_container_path(file_id)
            if not has and not p.exists():
                self.notify(self.t("seed_file_missing"), severity="warning")
                return
            await self.net.announce_file(file_id)
            self.notify(self.t("seed_file_done"))
        except Exception as e:
            self.notify(f"Seed failed: {e}", severity="error")

    def set_my_account(self, acc: dict) -> None:
        self.my_account = acc
        self.refresh_sidebar()

    def get_css_variables(self) -> dict[str, str]:
        from ui.themes import THEMES

        variables = super().get_css_variables()

        theme_name = getattr(self, "theme_name", "default")
        theme = THEMES.get(theme_name, THEMES.get("default", {}))

        if not theme:
            return variables

        bg = theme.get("bg", "#121212")
        panel = theme.get("panel", "#1e1e1e")
        text = theme.get("text", "#e0e0e0")
        muted = theme.get("muted", "#808080")
        border = theme.get("border", "#333333")
        accent = theme.get("accent", "#bb86fc")
        hover = theme.get("hover", "#2c2c2c")

        def lighten(hex_color: str, amount: int = 20) -> str:
            try:
                hex_color = hex_color.lstrip('#')
                r = min(255, int(hex_color[0:2], 16) + amount)
                g = min(255, int(hex_color[2:4], 16) + amount)
                b = min(255, int(hex_color[4:6], 16) + amount)
                return f"#{r:02x}{g:02x}{b:02x}"
            except:
                return hex_color

        def darken(hex_color: str, amount: int = 20) -> str:
            try:
                hex_color = hex_color.lstrip('#')
                r = max(0, int(hex_color[0:2], 16) - amount)
                g = max(0, int(hex_color[2:4], 16) - amount)
                b = max(0, int(hex_color[4:6], 16) - amount)
                return f"#{r:02x}{g:02x}{b:02x}"
            except:
                return hex_color

        variables.update({
            "surface": bg,
            "surface-lighten-1": lighten(bg, 15),
            "surface-lighten-2": lighten(bg, 30),
            "surface-lighten-3": lighten(bg, 45),
            "surface-darken-1": darken(bg, 15),
            "surface-darken-2": darken(bg, 30),
            "surface-darken-3": darken(bg, 45),
            "panel": panel,
            "panel-lighten-1": lighten(panel, 15),
            "panel-lighten-2": lighten(panel, 30),
            "panel-darken-1": darken(panel, 15),
            "panel-darken-2": darken(panel, 30),
            "primary": accent,
            "primary-lighten-1": lighten(accent, 20),
            "primary-lighten-2": lighten(accent, 40),
            "primary-lighten-3": lighten(accent, 60),
            "primary-darken-1": darken(accent, 20),
            "primary-darken-2": darken(accent, 40),
            "primary-darken-3": darken(accent, 60),
            "primary-background": bg,
            "primary-background-darken-1": darken(bg, 15),
            "primary-background-darken-2": darken(bg, 30),
            "primary-background-lighten-1": lighten(bg, 15),
            "primary-background-lighten-2": lighten(bg, 30),
            "secondary": border,
            "secondary-lighten-1": lighten(border, 20),
            "secondary-lighten-2": lighten(border, 40),
            "secondary-lighten-3": lighten(border, 60),
            "secondary-darken-1": darken(border, 20),
            "secondary-darken-2": darken(border, 40),
            "secondary-darken-3": darken(border, 60),
            "secondary-background": bg,
            "secondary-background-darken-1": darken(bg, 15),
            "secondary-background-lighten-1": lighten(bg, 15),
            "accent": accent,
            "accent-lighten-1": lighten(accent, 20),
            "accent-lighten-2": lighten(accent, 40),
            "accent-lighten-3": lighten(accent, 60),
            "accent-darken-1": darken(accent, 20),
            "accent-darken-2": darken(accent, 40),
            "accent-darken-3": darken(accent, 60),
            "text": text,
            "text-muted": muted,
            "text-disabled": muted,
            "background": bg,
            "foreground": text,
            "border": border,
            "border-blurred": border,
            "input": bg,
            "input-cursor-background": text,
            "input-cursor-foreground": bg,
            "input-selection-background": hover,
            "scrollbar": border,
            "scrollbar-hover": lighten(border, 20),
            "scrollbar-active": lighten(border, 30),
            "scrollbar-background": bg,
            "scrollbar-background-hover": bg,
            "scrollbar-background-active": bg,
            "scrollbar-corner-color": bg,
            "boost": lighten(bg, 10),
            "warning": "#e5a50a",
            "error": "#ba3c5b",
            "success": "#4EBF71",
        })

        return variables

    def set_theme(self, name: str, persist: bool = True) -> None:
        from ui.themes import THEMES, css_for_theme

        if name.startswith("system-") and name in THEMES:

            theme_colors = THEMES[name]
            css = css_for_theme(name, theme_colors)

            self._dynamic_theme_css = css

        if name.startswith("system-") and name in THEMES:
            actual_name = name
        else:
            actual_name = normalize_theme(name)

        for n in theme_list():
            self.remove_class(f"theme-{n}")

        for cls in list(self.classes):
            if cls.startswith("theme-system-"):
                self.remove_class(cls)

        self.add_class(f"theme-{actual_name}")
        self.theme_name = actual_name
        if persist:
            self.db.set_setting("theme", actual_name)

        self.refresh_css()

    def apply_button_style(self, style: str = "flat") -> None:
        try:

            self.remove_class("btn-style-flat")
            self.remove_class("btn-style-3d")

            if style == "default":
                self.add_class("btn-style-3d")
            else:
                self.add_class("btn-style-flat")
            self._button_style = style
        except Exception:
            pass

    def _refresh_static_texts(self) -> None:
        try:
            self.query_one("#btn_settings", Button).label = self.t("settings_button")
        except Exception:
            pass
        try:
            self.query_one("#btn_new", Button).label = self.t("btn_new")
        except Exception:
            pass
        try:
            self.query_one("#btn_new_rail", Button).label = self.t("btn_new_short")
        except Exception:
            pass
        try:
            self.query_one("#btn_toggle_sidebar", Button).label = self.t("btn_collapse")
        except Exception:
            pass
        try:
            self.query_one("#btn_expand_sidebar", Button).label = self.t("btn_expand")
        except Exception:
            pass
        try:

            self.query_one("#tab_chats", TabPane).title = self.t("tab_chats")
            self.query_one("#tab_requests", TabPane).title = self.t("tab_requests")

            tabs_c = self.query_one("#tabs", TabbedContent)

            try:
                t1 = tabs_c.query_one("#tab-tab_chats", Tab)
                t1.update(self.t("tab_chats"))
            except:
                pass

            try:
                t2 = tabs_c.query_one("#tab-tab_requests", Tab)
                t2.update(self.t("tab_requests"))
            except:
                pass

            try:
                tab_bar = tabs_c.query_one("TabBar")
                tabs = list(tab_bar.query(Tab))

                if len(tabs) >= 1:
                    t1 = tabs[0]
                    txt1 = self.t("tab_chats")

                    t1.label = txt1
                    if hasattr(t1, "update"):
                        t1.update(txt1)
                    t1.refresh()

                if len(tabs) >= 2:
                    t2 = tabs[1]
                    txt2 = self.t("tab_requests")
                    t2.label = txt2
                    if hasattr(t2, "update"):
                        t2.update(txt2)
                    t2.refresh()

                tab_bar.refresh()
            except:
                pass
        except Exception:
            pass

    def set_lang(self, code: str, persist: bool = True) -> None:
        code = normalize_lang(code)
        self.lang = code
        if persist:
            self.db.set_setting("lang", code)
        self._refresh_static_texts()
        try:
            self.refresh_sidebar()
        except Exception:
            pass
        try:
            for w in self._msg_widgets.values():
                w.refresh_content()
        except Exception:
            pass
        try:
            if isinstance(getattr(self, "screen", None), SettingsScreen):
                self.screen.refresh_labels()
        except Exception:
            pass
        try:
            if self.active_chat and self.reply_to:
                msgs = self.db.list_messages(self.active_chat, limit=2000)
                m = next((x for x in msgs if x["msg_id"] == self.reply_to), None)
                if m:
                    self.reply_bar.show_reply(m["sender_alias"], short_snip(m["body"], 80))
        except Exception:
            pass

    async def _apply_info_panel(self, animate: bool = True) -> None:
        try:
            panel = self.query_one("#top_info_panel", Vertical)
            tab = self.query_one("#info_tab", InfoTab)
        except Exception:
            return

        expanded = bool(self.info_expanded)
        try:
            tab.set_expanded(expanded)
        except Exception:
            try:
                tab.update("▾" if expanded else "▸")
            except Exception:
                pass
        target_h = 3

        if expanded:
            try:
                panel.styles.display = "block"
            except Exception:
                pass
            try:
                if animate:
                    panel.styles.height = 0
                if animate and hasattr(panel, "animate"):
                    res = panel.animate("styles.height", target_h, duration=0.18)
                    if inspect.isawaitable(res):
                        await res
                panel.styles.height = target_h
            except Exception:
                try:
                    panel.styles.height = target_h
                except Exception:
                    pass
        else:
            try:
                if animate and hasattr(panel, "animate"):
                    res = panel.animate("styles.height", 0, duration=0.18)
                    if inspect.isawaitable(res):
                        await res
                panel.styles.height = 0
            except Exception:
                try:
                    panel.styles.height = 0
                except Exception:
                    pass
            try:
                panel.styles.display = "none"
            except Exception:
                pass

    async def ask(self, screen):
        loop = asyncio.get_running_loop()
        fut = loop.create_future()
        def _done(result):
            if not fut.done():
                fut.set_result(result)
        self.push_screen(screen, _done)
        return await fut

    def set_sidebar_width(self, w: int):
        w = max(18, min(70, int(w)))
        self.sidebar_width = w
        self._saved_sidebar_width = w
        self._apply_sidebar_layout()

    def _apply_sidebar_layout(self):
        try:
            sb = self.query_one("#sidebar_full", Vertical)
            sb.styles.width = self.sidebar_width
        except Exception:
            pass
        try:
            full = self.query_one("#sidebar_full", Vertical)
            rail = self.query_one("#sidebar_rail", Vertical)
            if self.sidebar_collapsed:
                full.styles.display = "none"
                rail.styles.display = "block"
            else:
                rail.styles.display = "none"
                full.styles.display = "block"
        except Exception:
            pass

    def refresh_sidebar(self):
        try:
            chats_lv = self.query_one("#chat_list", ListView)
            req_in_lv = self.query_one("#req_in_list", ListView)
            req_out_lv = self.query_one("#req_out_list", ListView)
        except Exception:
            return

        chats_lv.clear()
        for c in self.db.list_chats():
            peer = c["peer_alias"]
            unread = c["unread"]
            if isinstance(peer, str) and peer.startswith("g:"):
                gid = peer[2:]
                g = self.db.get_group(gid)
                name = (g or {}).get("name") or gid
                members = self.db.list_group_members(gid)
                subtitle = self.t("group_members_sub", n=len(members))

                active_call = self._active_group_calls.get(gid, 0)
                it = ChatItem(peer, title=f"# {name}", subtitle=subtitle, unread=unread, locked=False, online=False, active_call=active_call)
                if peer == getattr(self, "active_chat", None):
                    it.add_class("active_chat")
                chats_lv.append(it)
            else:
                locked = self.db.load_session(peer) is not None
                online = peer in self.net.online_aliases
                peer_info = self.db.get_peer(peer)
                subtitle = peer_info["fp"] if peer_info else ""
                it = ChatItem(peer, subtitle=subtitle, unread=unread, locked=locked, online=online)
                if peer == getattr(self, "active_chat", None):
                    it.add_class("active_chat")
                chats_lv.append(it)

        req_in_lv.clear()
        for r in self.db.list_requests_in():
            pl = r.get("payload") or {}
            if isinstance(pl, dict) and pl.get("type") == "group_invite":
                req_in_lv.append(
                    GroupInviteInItem(
                        r["id"],
                        str(pl.get("from") or r["peer_alias"]),
                        str(pl.get("group_id") or ""),
                        str(pl.get("name") or "Group"),
                        r["received_at"],
                    )
                )
            else:
                req_in_lv.append(RequestInItem(r["id"], r["peer_alias"], r["peer_fp"], r["received_at"]))

        req_out_lv.clear()
        for r in self.db.list_requests_out():
            req_out_lv.append(RequestOutItem(r["id"], r["peer_alias"], r["state"], r["created_at"], r["updated_at"]))

        try:
            fp = self.ident.fp
            short_fp = f"{fp[:8]}…{fp[-6:]}" if fp and len(fp) > 20 else (fp or "")
            self.query_one("#user_box", Button).label = f"{self.ident.alias}  {short_fp}"
        except Exception:
            pass

        nin = self.db.count_requests_in()
        nout = self.db.count_requests_out()
        try:
            self.query_one("#req_in_title", Static).update(self.t("incoming_with_count", n=nin))
        except Exception:
            pass

        try:
            self.query_one("#req_out_title", Static).update(self.t("outgoing_with_count", n=nout))
        except Exception:
            pass

        try:
            tabs = self.query_one("#tabs", TabbedContent)
            if getattr(tabs, "active", None) == "tab_users":
                self.refresh_users_list()
        except Exception:
            pass

    def refresh_users_list(self):
        try:
            ul = self.query_one("#user_list", ListView)
            search_term = self.query_one("#user_search", Input).value.lower()
        except Exception:
            return

        try:
            if not getattr(self, "_users_requested_once", False):
                self._users_requested_once = True
                asyncio.create_task(self.net.refresh_users())
        except Exception:
            pass

        users = getattr(self.net, "all_users", [])

        import re
        try:

            pattern = re.compile(search_term)
            is_regex = True
        except re.error:
            is_regex = False

        desired: list[tuple[str, str, str]] = []
        for u in users:
            alias = u.get("alias", "")
            if alias == self.ident.alias:
                continue

            if search_term:
                if is_regex:
                    if not pattern.search(alias.lower()):
                        continue
                else:
                    if search_term not in alias.lower():
                        continue

            status = u.get("status", "offline")
            status_msg = u.get("status_msg", "")

            desired.append((alias, str(status), str(status_msg)))

        try:
            if desired:
                self._users_loaded_once = True
        except Exception:
            pass

        try:
            current_items = [c for c in ul.children if isinstance(c, UserItem)]
            current_aliases = [c.alias for c in current_items]
            desired_aliases = [a for (a, _, _) in desired]

            if current_aliases == desired_aliases:
                for item, (_, st, sm) in zip(current_items, desired):
                    if item.status != st or item.status_msg != sm:
                        item.status = st
                        item.status_msg = sm
                        try:
                            title = item.query_one(".user_item_title", Static)
                            status_char = "●"
                            title.update(f"{status_char} {item.alias}")
                            title.classes = f"user_item_title status_{st}"
                        except Exception:
                            try:
                                item.refresh(repaint=True)
                            except Exception:
                                pass

                        try:
                            sub = item.query_one(".user_item_sub", Static)
                            msg = sm
                            if len(msg) > 50:
                                msg = msg[:50] + "…"
                            sub.update(msg)
                        except Exception:

                            try:
                                item.refresh(recompose=True)
                            except Exception:
                                pass
                return
        except Exception:
            pass

        try:
            selected_alias = None
            try:
                idx = int(getattr(ul, "index", -1))
                if idx >= 0:
                    cur = [c for c in ul.children if isinstance(c, UserItem)]
                    if idx < len(cur):
                        selected_alias = cur[idx].alias
            except Exception:
                selected_alias = None

            ul.clear()
            for alias, st, sm in desired:
                ul.append(UserItem(alias, st, sm))

            if selected_alias:
                try:
                    aliases = [c.alias for c in ul.children if isinstance(c, UserItem)]
                    if selected_alias in aliases:
                        ul.index = aliases.index(selected_alias)
                except Exception:
                    pass
        except Exception:
            return

    async def on_input_changed(self, event: Input.Changed) -> None:
        if event.input.id == "user_search":
            self.refresh_users_list()

    async def update_profile(self, status: str = None, status_msg: str = None, bio: str = None, visibility: int = None):
        await self.net.update_profile(status, status_msg, bio, visibility)

        if isinstance(self.screen, SettingsScreen):

            pass

    async def unblock_user(self, fp: str):
        await self.net.unblock_user(fp)

    async def block_user(self, target: str):
        await self.net.block_user(target)

    @property
    def my_profile(self):
        return getattr(self.net, "my_profile", {})

    @property
    def blocked_users(self):
        return getattr(self.net, "blocked_users", set())

    async def refresh_active_chat(self):
        async with self._refresh_lock:
            if not self.active_chat:
                return

            peer = self.active_chat
            self.db.ensure_chat(peer)
            self.db.set_chat_unread(peer, 0)

            if self._rendered_peer != peer:
                self._chat_oldest_cursor = None
                self._chat_has_older = False
                self._chat_loading_older = False
                self._chat_rebuild_anchor_msg_id = None
                self._chat_window_size = 0

            if not self._chat_window_size:
                self._chat_window_size = self._chat_page_size

            limit = min(int(self._chat_window_size), int(self._chat_window_limit))
            msgs = self.db.list_messages_tail(peer, limit=limit)
            if msgs:
                self._chat_oldest_cursor = (int(msgs[0]["ts"]), str(msgs[0]["msg_id"]))
                try:
                    self._chat_has_older = self.db.has_messages_before(peer, self._chat_oldest_cursor[0], self._chat_oldest_cursor[1])
                except Exception:
                    self._chat_has_older = False
            else:
                self._chat_oldest_cursor = None
                self._chat_has_older = False
            desired_ids = [m["msg_id"] for m in msgs]

            scroll = self.query_one("#messages", VerticalScroll)
            def is_at_bottom() -> bool:
                try:
                    max_y = getattr(scroll, "max_scroll_y", None)
                    y = getattr(scroll, "scroll_y", None)
                    if isinstance(max_y, int) and isinstance(y, int):
                        return y >= max(0, max_y - 1)
                except Exception:
                    pass
                return True

            at_bottom_before = is_at_bottom()

            msg_map = {m["msg_id"]: m for m in msgs}

            def reply_depth(mid) -> int:
                d = 0
                cur = mid
                seen = set()
                while cur and cur in msg_map and d < 4:
                    if cur in seen:
                        break
                    seen.add(cur)
                    cur = msg_map[cur].get("reply_to")
                    d += 1
                return min(d, 3)

            def compute_header(prev_sender: str | None, prev_ts: int, prev_date: str, m: dict):
                sender = m["sender_alias"]
                ts = m["ts"]
                date = fmt_date(ts)
                sender_changed = sender != prev_sender
                date_changed = date != prev_date
                gap_changed = abs(ts - prev_ts) > 300
                show_header = sender_changed or date_changed or gap_changed
                return sender, ts, date, sender_changed, show_header

            def compute_message_groups(messages: list[dict]) -> dict[str, list[dict]]:
                groups: dict[str, list[dict]] = {}
                current_group: list[dict] = []
                prev_sender = None
                prev_ts = 0
                prev_date = ""

                for m in messages:
                    sender, ts, date, sender_changed, show_header = compute_header(prev_sender, prev_ts, prev_date, m)

                    if show_header:

                        if current_group:

                            for gm in current_group:
                                groups[gm["msg_id"]] = current_group
                        current_group = [m]
                    else:

                        current_group.append(m)

                    prev_sender, prev_ts, prev_date = sender, ts, date

                if current_group:
                    for gm in current_group:
                        groups[gm["msg_id"]] = current_group

                return groups

            async def full_rebuild(*, anchor_msg_id: str | None = None, force_bottom: bool = False):
                removed = scroll.remove_children()
                if inspect.isawaitable(removed):
                    await removed
                self._msg_widgets = {}

                msg_groups = compute_message_groups(msgs)

                prev_sender = None
                prev_ts = 0
                prev_date = ""

                for m in msgs:
                    msg_id = m["msg_id"]
                    w_id = f"msg_{msg_id}"

                    sender, ts, date, sender_changed, show_header = compute_header(prev_sender, prev_ts, prev_date, m)

                    rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                    indent = reply_depth(m.get("reply_to"))

                    group_msgs = msg_groups.get(msg_id) if show_header else None

                    if prev_sender is None:
                        cls = "grouped"
                    elif sender_changed:
                        cls = "break_user"
                    elif show_header:
                        cls = "break_time"
                    else:
                        cls = "grouped"

                    w = MessageWidget(m, show_header=show_header, indent=indent, reply_preview=rep, group_messages=group_msgs, msg_class=cls)
                    w.id = w_id
                    w.add_class(cls)

                    prev_sender, prev_ts, prev_date = sender, ts, date

                    r = scroll.mount(w)
                    if inspect.isawaitable(r):
                        await r
                    self._msg_widgets[msg_id] = w

                if force_bottom or (at_bottom_before and not anchor_msg_id):
                    scroll.scroll_end(animate=False)
                elif anchor_msg_id:
                    try:
                        w = self._msg_widgets.get(anchor_msg_id)
                        if w:
                            if hasattr(scroll, "scroll_to_widget"):
                                res = scroll.scroll_to_widget(w, animate=False)
                                if inspect.isawaitable(res):
                                    await res
                            else:
                                y = getattr(getattr(w, "region", None), "y", None)
                                if y is not None and hasattr(scroll, "scroll_to"):
                                    res = scroll.scroll_to(y=y, animate=False)
                                    if inspect.isawaitable(res):
                                        await res
                    except Exception:
                        pass
            if self._rendered_peer != peer:
                await full_rebuild(force_bottom=True)
                self._rendered_peer = peer
                self._rendered_ids = desired_ids
                self.request_refresh_sidebar()
                return

            rendered_ids = list(self._rendered_ids or [])

            if not at_bottom_before and rendered_ids and desired_ids:
                try:
                    last_rendered_id = rendered_ids[-1]
                except Exception:
                    last_rendered_id = None

                if isinstance(last_rendered_id, str) and last_rendered_id in msg_map:
                    try:
                        idx = desired_ids.index(last_rendered_id)
                    except ValueError:
                        idx = -1

                    if idx >= 0 and idx + 1 < len(desired_ids):
                        new_ids = [mid for mid in desired_ids[idx + 1 :] if isinstance(mid, str) and mid and mid not in self._msg_widgets]
                        if new_ids and len(rendered_ids) < int(self._chat_window_limit):
                            prev_sender: str | None = None
                            prev_ts: int = 0
                            prev_date: str = ""
                            try:
                                last_w = self._msg_widgets.get(last_rendered_id)
                                if last_w is not None:
                                    prev_sender = last_w.msg.get("sender_alias")
                                    prev_ts = int(last_w.msg.get("ts") or 0)
                                    prev_date = fmt_date(prev_ts)
                            except Exception:
                                prev_sender, prev_ts, prev_date = None, 0, ""

                            for mid in new_ids:
                                m = msg_map.get(mid)
                                if not isinstance(m, dict):
                                    continue

                                sender, ts, date, sender_changed, show_header = compute_header(prev_sender, prev_ts, prev_date, m)
                                rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                                indent = reply_depth(m.get("reply_to"))

                                if prev_sender is None:
                                    cls = "grouped"
                                elif sender_changed:
                                    cls = "break_user"
                                elif show_header:
                                    cls = "break_time"
                                else:
                                    cls = "grouped"

                                w = MessageWidget(m, show_header=show_header, indent=indent, reply_preview=rep, msg_class=cls)
                                w.id = f"msg_{mid}"
                                w.add_class(cls)

                                prev_sender, prev_ts, prev_date = sender, ts, date

                                try:
                                    r = scroll.mount(w)
                                    if inspect.isawaitable(r):
                                        await r
                                except Exception:
                                    break
                                self._msg_widgets[mid] = w

                            self._rendered_ids = rendered_ids + [mid for mid in new_ids if mid in self._msg_widgets]
                            self.request_refresh_sidebar()
                            return

            if rendered_ids and desired_ids == rendered_ids:
                any_missing = False
                any_changed = False
                for mid in desired_ids:
                    w = self._msg_widgets.get(mid)
                    if not w:
                        any_missing = True
                        break
                    nm = msg_map.get(mid)
                    if not nm:
                        continue
                    try:
                        if (
                            w.msg.get("local_state") != nm.get("local_state")
                            or w.msg.get("edited_ts") != nm.get("edited_ts")
                            or w.msg.get("body") != nm.get("body")
                            or w.msg.get("meta") != nm.get("meta")
                            or w.msg.get("kind") != nm.get("kind")
                        ):
                            w.msg = nm
                            w.refresh_content()
                            any_changed = True
                    except Exception:
                        any_changed = True
                if not any_missing:
                    if at_bottom_before:
                        scroll.scroll_end(animate=False)
                    if any_changed:
                        self.request_refresh_sidebar()
                    return

            if rendered_ids and len(desired_ids) >= len(rendered_ids) and desired_ids[-len(rendered_ids) :] == rendered_ids:
                old_first_id = rendered_ids[0]
                old_first_widget = self._msg_widgets.get(old_first_id)
                if not old_first_widget:
                    anchor = self._chat_rebuild_anchor_msg_id
                    self._chat_rebuild_anchor_msg_id = None
                    await full_rebuild(anchor_msg_id=anchor, force_bottom=False)
                    self._rendered_ids = desired_ids
                    self.request_refresh_sidebar()
                    return

                older_count = len(desired_ids) - len(rendered_ids)

                prev_sender = None
                prev_ts = 0
                prev_date = ""
                header_info: dict[str, tuple[bool, bool, str]] = {}
                for m in msgs:
                    sender, ts, date, sender_changed, show_header = compute_header(prev_sender, prev_ts, prev_date, m)
                    if prev_sender is None:
                        cls = "grouped"
                    elif sender_changed:
                        cls = "break_user"
                    elif show_header:
                        cls = "break_time"
                    else:
                        cls = "grouped"
                    header_info[str(m["msg_id"])] = (show_header, sender_changed, cls)
                    prev_sender, prev_ts, prev_date = sender, ts, date

                for m in msgs[:older_count]:
                    msg_id = str(m["msg_id"])
                    w_id = f"msg_{msg_id}"
                    show_header, _sender_changed, cls = header_info.get(msg_id, (True, True, "grouped"))
                    rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                    indent = reply_depth(m.get("reply_to"))

                    w = MessageWidget(m, show_header=show_header, indent=indent, reply_preview=rep, msg_class=cls)
                    w.id = w_id
                    w.add_class(cls)

                    r = scroll.mount(w, before=old_first_widget)
                    if inspect.isawaitable(r):
                        await r
                    self._msg_widgets[msg_id] = w

                for m in msgs[older_count:]:
                    mid = str(m["msg_id"])
                    w = self._msg_widgets.get(mid)
                    if not w:
                        continue

                    rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                    indent = reply_depth(m.get("reply_to"))
                    show_header, _sender_changed, cls = header_info.get(mid, (w.show_header, False, "grouped"))

                    needs_refresh = False
                    if getattr(w, "msg", None) != m:
                        w.msg = m
                        needs_refresh = True
                    if getattr(w, "indent", None) != indent:
                        w.indent = indent
                        needs_refresh = True
                    if getattr(w, "reply_preview", None) != rep:
                        w.reply_preview = rep
                        needs_refresh = True
                    if getattr(w, "show_header", None) != show_header:
                        w.show_header = show_header
                        needs_refresh = True

                    try:
                        w.remove_class("grouped")
                        w.remove_class("break_user")
                        w.remove_class("break_time")
                        w.add_class(cls)
                    except Exception:
                        pass

                    if needs_refresh:
                        w.refresh_content()

                self._rendered_ids = desired_ids

                anchor = self._chat_rebuild_anchor_msg_id
                self._chat_rebuild_anchor_msg_id = None
                try:
                    if anchor and anchor in self._msg_widgets and hasattr(scroll, "scroll_to_widget"):
                        res = scroll.scroll_to_widget(self._msg_widgets[anchor], animate=False)
                        if inspect.isawaitable(res):
                            await res
                    elif hasattr(scroll, "scroll_to_widget"):
                        res = scroll.scroll_to_widget(old_first_widget, animate=False)
                        if inspect.isawaitable(res):
                            await res
                except Exception:
                    pass

                self.request_refresh_sidebar()
                return

            if rendered_ids and desired_ids:
                rendered_pos = {mid: i for i, mid in enumerate(rendered_ids)}
                drop = rendered_pos.get(desired_ids[0])
                if isinstance(drop, int) and drop >= 0:
                    overlap_len = len(rendered_ids) - drop
                    if overlap_len >= 0 and desired_ids[:overlap_len] == rendered_ids[drop:]:
                        for mid in rendered_ids[:drop]:
                            w = self._msg_widgets.pop(mid, None)
                            if w is None:
                                continue
                            try:
                                r = w.remove()
                                if inspect.isawaitable(r):
                                    await r
                            except Exception:
                                pass

                        if desired_ids:
                            first_id = desired_ids[0]
                            first_w = self._msg_widgets.get(first_id)
                            if first_w is not None:
                                try:
                                    first_w.show_header = True
                                    first_w.refresh_content()
                                except Exception:
                                    pass
                                try:
                                    first_w.remove_class("break_user")
                                    first_w.remove_class("break_time")
                                    first_w.remove_class("grouped")
                                    first_w.add_class("grouped")
                                except Exception:
                                    pass

                        prev_sender: str | None = None
                        prev_ts: int = 0
                        prev_date: str = ""
                        if overlap_len > 0:
                            try:
                                pm = msg_map.get(desired_ids[overlap_len - 1])
                                if pm:
                                    prev_sender = pm.get("sender_alias")
                                    prev_ts = int(pm.get("ts") or 0)
                                    prev_date = fmt_date(prev_ts)
                            except Exception:
                                prev_sender, prev_ts, prev_date = None, 0, ""

                        for mid in desired_ids[overlap_len:]:
                            m = msg_map.get(mid)
                            if not m:
                                continue
                            w_id = f"msg_{mid}"

                            sender, ts, date, sender_changed, show_header = compute_header(prev_sender, prev_ts, prev_date, m)
                            rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                            indent = reply_depth(m.get("reply_to"))

                            if prev_sender is None:
                                cls = "grouped"
                            elif sender_changed:
                                cls = "break_user"
                            elif show_header:
                                cls = "break_time"
                            else:
                                cls = "grouped"

                            w = MessageWidget(m, show_header=show_header, indent=indent, reply_preview=rep, msg_class=cls)
                            w.id = w_id
                            w.add_class(cls)

                            prev_sender, prev_ts, prev_date = sender, ts, date

                            try:
                                r = scroll.mount(w)
                                if inspect.isawaitable(r):
                                    await r
                            except Exception:
                                anchor = self._chat_rebuild_anchor_msg_id
                                self._chat_rebuild_anchor_msg_id = None
                                await full_rebuild(anchor_msg_id=anchor, force_bottom=False)
                                self._rendered_ids = desired_ids
                                self.request_refresh_sidebar()
                                return

                            self._msg_widgets[mid] = w

                        self._rendered_ids = desired_ids
                        if at_bottom_before:
                            scroll.scroll_end(animate=False)
                        self.request_refresh_sidebar()
                        return
            if rendered_ids == desired_ids:
                for m in msgs:
                    mid = m["msg_id"]
                    w = self._msg_widgets.get(mid)
                    if not w:
                        await full_rebuild()
                        self._rendered_ids = desired_ids
                        self.request_refresh_sidebar()
                        return

                    rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                    indent = reply_depth(m.get("reply_to"))
                    if getattr(w, "msg", None) != m or getattr(w, "indent", None) != indent or getattr(w, "reply_preview", None) != rep:
                        w.msg = m
                        w.indent = indent
                        w.reply_preview = rep
                        w.refresh_content()

                if at_bottom_before:
                    scroll.scroll_end(animate=False)
                self.request_refresh_sidebar()
                return
            if rendered_ids and desired_ids[: len(rendered_ids)] == rendered_ids:
                for m in msgs[: len(rendered_ids)]:
                    mid = m["msg_id"]
                    w = self._msg_widgets.get(mid)
                    if w:
                        rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                        indent = reply_depth(m.get("reply_to"))
                        if getattr(w, "msg", None) != m or getattr(w, "indent", None) != indent or getattr(w, "reply_preview", None) != rep:
                            w.msg = m
                            w.indent = indent
                            w.reply_preview = rep
                            w.refresh_content()

                prev_sender = None
                prev_ts = 0
                prev_date = ""
                if msgs and len(rendered_ids) > 0:
                    last = msgs[len(rendered_ids) - 1]
                    prev_sender = last["sender_alias"]
                    prev_ts = last["ts"]
                    prev_date = fmt_date(prev_ts)

                for m in msgs[len(rendered_ids) :]:
                    msg_id = m["msg_id"]
                    w_id = f"msg_{msg_id}"
                    try:
                        if self.query(f"#{w_id}"):
                            await full_rebuild()
                            self._rendered_ids = desired_ids
                            self.request_refresh_sidebar()
                            return
                    except Exception:
                        pass

                    sender, ts, date, sender_changed, show_header = compute_header(prev_sender, prev_ts, prev_date, m)
                    rep = msg_map.get(m.get("reply_to")) if m.get("reply_to") else None
                    indent = reply_depth(m.get("reply_to"))

                    if prev_sender is None:
                        cls = "grouped"
                    elif sender_changed:
                        cls = "break_user"
                    elif show_header:
                        cls = "break_time"
                    else:
                        cls = "grouped"

                    w = MessageWidget(m, show_header=show_header, indent=indent, reply_preview=rep, msg_class=cls)
                    w.id = w_id
                    w.add_class(cls)

                    prev_sender, prev_ts, prev_date = sender, ts, date

                    try:
                        r = scroll.mount(w)
                        if inspect.isawaitable(r):
                            await r
                    except Exception:
                        await full_rebuild()
                        self._rendered_ids = desired_ids
                        self.request_refresh_sidebar()
                        return
                    self._msg_widgets[msg_id] = w

                self._rendered_ids = desired_ids

                if at_bottom_before:
                    scroll.scroll_end(animate=False)
                self.request_refresh_sidebar()
                return

            anchor = self._chat_rebuild_anchor_msg_id
            self._chat_rebuild_anchor_msg_id = None
            await full_rebuild(anchor_msg_id=anchor, force_bottom=False)
            self._rendered_ids = desired_ids
            self.request_refresh_sidebar()

    def _maybe_load_older_on_scroll_tick(self) -> None:
        if not self.active_chat:
            return
        if self._chat_loading_older:
            return
        if not self._chat_has_older:
            return
        try:
            scroll = self.query_one("#messages", VerticalScroll)
            y = getattr(scroll, "scroll_y", None)
        except Exception:
            return
        if isinstance(y, int) and y <= 1:
            asyncio.create_task(self._load_older_messages())

    async def _load_older_messages(self) -> None:
        if not self.active_chat:
            return
        if self._chat_loading_older:
            return
        if not self._chat_has_older:
            return
        if not self._chat_oldest_cursor:
            return

        self._chat_loading_older = True
        try:
            peer = self.active_chat
            old_first = None
            try:
                if self._rendered_ids:
                    old_first = str(self._rendered_ids[0])
            except Exception:
                old_first = None

            if not self._chat_window_size:
                self._chat_window_size = self._chat_page_size
            self._chat_window_size = min(int(self._chat_window_size) + int(self._chat_page_size), int(self._chat_window_limit))
            if self._chat_window_size >= self._chat_window_limit:

                pass

            self._chat_rebuild_anchor_msg_id = old_first
            await self.refresh_active_chat()
        finally:
            self._chat_loading_older = False

    def compose(self) -> ComposeResult:

        with Horizontal(id="root"):
            with Vertical(id="sidebar_full"):
                with Horizontal(id="sidebar_top"):
                    yield Button(self.t("btn_collapse"), id="btn_toggle_sidebar")
                    yield Button(self.t("btn_new"), id="btn_new", variant="primary")
                with TabbedContent(id="tabs"):
                    with TabPane(self.t("tab_chats"), id="tab_chats"):
                        yield ListView(id="chat_list")
                    with TabPane(self.t("tab_requests"), id="tab_requests"):
                        with Horizontal(id="req_panes"):
                            with Vertical(id="req_in"):
                                yield Static(self.t("incoming_with_count", n=0), id="req_in_title")
                                yield ListView(id="req_in_list")
                            with Vertical(id="req_out"):
                                yield Static(self.t("outgoing_with_count", n=0), id="req_out_title")
                                yield ListView(id="req_out_list")
                    with TabPane(self.t("tab_users"), id="tab_users"):
                        yield Input(placeholder=self.t("search_placeholder"), id="user_search")
                        yield ListView(id="user_list")
                with Horizontal(id="bottom_bar"):
                    yield Button("", id="user_box", classes="user_box_btn")
                    yield Button(self.t("settings_button"), id="btn_settings")

            with Vertical(id="sidebar_rail"):
                with Horizontal(id="rail_top"):
                    yield Button(self.t("btn_expand"), id="btn_expand_sidebar")
                with Vertical(id="rail_bottom"):
                    yield Button(self.t("btn_new_short"), id="btn_new_rail", variant="primary")

            yield SidebarResizeHandle()

            with Vertical(id="main"):
                with Horizontal(id="info_tab_bar"):
                    yield Static("", id="info_tab_spacer")
                    with Horizontal(id="update_container", classes="hidden"):
                        yield Static(self.t("update_available"), id="update_label")
                        yield Button(self.t("btn_update"), id="btn_update")
                    yield Static("", id="info_tab_spacer_right")
                    yield CallTab("☎", id="btn_call")
                    yield InfoTab("▸", id="info_tab")
                    yield Button("◂", id="btn_group_members", classes="header_btn hidden")
                with Vertical(id="top_info_panel"):
                    yield Static("", id="system_log")
                with Horizontal(id="call_bar"):
                    with Horizontal(id="call_controls"):
                        yield Button("Accept", id="call_accept", variant="primary")
                        yield Button("Decline", id="call_decline")
                    yield Static("", id="call_bar_spacer")

                with Vertical(id="call_panel"):
                    with Horizontal(id="call_participants"):
                        pass
                    with Horizontal(id="call_buttons"):
                        yield Button("🎤", id="call_mute")
                        yield Button("🔊", id="call_deafen")
                        yield Button("📞", id="call_hangup", variant="error")
                with VerticalScroll(id="messages"):
                    pass
                with Vertical(id="composer_frame"):
                    with Horizontal(id="transfer_bar"):
                        yield Static("", id="transfer_status")
                        yield Button(self.t("ok"), id="transfer_ok", variant="primary")
                        yield Button("Cancel", id="transfer_cancel", variant="error")
                    with Horizontal(id="find_bar", classes="hidden"):
                        yield Static("🔍 ", id="find_bar_icon")
                        yield Static("\"...\" ", id="find_bar_pattern")
                        yield Static("0/0 ", id="find_bar_status")
                        yield Static("[Esc] Exit • [↵] Next • [⇧↵] Prev", id="find_bar_hint")
                    with Horizontal(id="select_bar", classes="hidden"):
                        yield Static("📋 ", id="select_bar_icon")
                        yield Static("0 selected", id="select_bar_count")
                        yield Static(" | [Esc] Çık • [Del] Sil • [C] Temizle • [Ctrl+A] Tümü • [Orta Tık] Aralık", id="select_bar_hint")
                    yield self.reply_bar
                    with Horizontal(id="composer_row"):
                        yield Button("+", id="attach_btn")
                        yield self.composer
                        yield Button(">", id="send_btn", variant="primary")

                yield GroupMembersPanel(id="group_members_panel")

                yield PeerSearchPanel(id="peer_search_panel")
                yield CallUserPopover(id="call_user_popover")

                yield CommandPalette(id="command_palette")

    def is_voice_call_active(self, peer_alias: str, call_id: str) -> bool:
        return bool(self._call_peer == peer_alias and self._call_id == call_id and self._voice is not None)

    def is_group_call_active(self, group_id: str, call_id: str) -> bool:
        return bool(
            self._group_call_group == group_id
            and self._group_call_id == call_id
            and self._voice is not None
        )

    def _set_call_ui(self, visible: bool, text: str = "") -> None:

        if not visible:
            try:
                bar = self.query_one("#call_bar")
                bar.styles.display = "none"
                bar.styles.visibility = "hidden"
                bar.styles.height = 0
                try:
                    bar.refresh(layout=True, repaint=True)
                except Exception:
                    pass
            except Exception:
                pass
            try:
                panel = self.query_one("#call_panel")
                panel.styles.display = "none"
                panel.styles.visibility = "hidden"
                panel.styles.height = 0
                try:
                    panel.refresh(layout=True, repaint=True)
                except Exception:
                    pass
            except Exception:
                pass
            try:
                self.query_one("#call_accept", Button).styles.display = "none"
                self.query_one("#call_decline", Button).styles.display = "none"
            except Exception:
                pass
            self._stop_speaking_timer()
            try:
                pop = self.query_one("#call_user_popover")
                pop.close()
                try:
                    pop.refresh(layout=True, repaint=True)
                except Exception:
                    pass
            except Exception:
                pass

            try:
                self.refresh(layout=True, repaint=True)
            except Exception:
                pass
            try:
                self.screen.refresh(layout=True, repaint=True)
            except Exception:
                pass
            return

        ringing = self._call_ringing_from is not None and self._voice is None
        in_call = self._voice is not None
        dialing = (self._call_peer is not None and self._call_id is not None and self._voice is None and not ringing)

        try:
            bar = self.query_one("#call_bar")
            bar.styles.display = "block" if ringing else "none"
            bar.styles.visibility = "visible" if ringing else "hidden"
            bar.styles.height = "auto" if ringing else 0
        except Exception:
            pass

        try:
            self.query_one("#call_accept", Button).styles.display = "block" if ringing else "none"
            self.query_one("#call_decline", Button).styles.display = "block" if ringing else "none"
        except Exception:
            pass

        try:
            panel = self.query_one("#call_panel")
            panel.styles.display = "block" if (in_call or dialing) else "none"
            panel.styles.visibility = "visible" if (in_call or dialing) else "hidden"
            panel.styles.height = "auto" if (in_call or dialing) else 0
        except Exception:
            pass

        if in_call or dialing:
            self._refresh_call_panel()
            self._ensure_speaking_timer()
        else:
            self._stop_speaking_timer()
            try:
                self.query_one("#call_user_popover", CallUserPopover).close()
            except Exception:
                pass

    def _cooldown_ok(self, peer_alias: str) -> bool:
        try:
            until = float(self._call_cooldown_until.get(peer_alias, 0.0))
        except Exception:
            until = 0.0
        return time.time() >= until

    def _set_cooldown(self, peer_alias: str, seconds: float) -> None:
        try:
            self._call_cooldown_until[peer_alias] = time.time() + float(seconds)
        except Exception:
            pass

    def _ringtone_start(self) -> None:
        if self._ring_task is not None and not self._ring_task.done():
            return

        async def _ringer() -> None:
            while self._call_ringing_from is not None:
                try:
                    print("\a", end="", flush=True)
                except Exception:
                    pass
                await asyncio.sleep(1.2)

        try:
            self._ring_task = asyncio.create_task(_ringer())
        except Exception:
            self._ring_task = None

    def _ringtone_stop(self) -> None:
        task = self._ring_task
        self._ring_task = None
        if task is not None and not task.done():
            try:
                task.cancel()
            except Exception:
                pass

    def _start_incoming_ring(self, peer_alias: str, call_id: str, incoming_obj: dict) -> None:
        self._call_ringing_from = peer_alias
        self._call_ringing_id = call_id
        self._call_incoming_obj = incoming_obj
        self._call_chat_id = peer_alias
        self._set_call_ui(True, f"Incoming call from {peer_alias}")
        self._ringtone_start()

    def _clear_incoming_ring(self) -> None:
        self._call_ringing_from = None
        self._call_ringing_id = None
        self._call_incoming_obj = None
        self._call_chat_id = None
        self._ringtone_stop()
        if self._voice is None:
            try:
                self._set_call_ui(False, "")
            except Exception:
                pass

    async def _start_voice_engine(self, peer_alias: str, call_id: str, params: VoiceParams) -> None:
        async def _send(pcm: bytes) -> None:
            await self.net.send_voice_pcm(peer_alias, call_id, pcm)

        def _parse_dev(raw) -> int | None:
            s = str(raw or "").strip().lower()
            if not s or s == "default":
                return None
            try:
                return int(s)
            except Exception:
                return None

        try:
            in_raw = self.db.get_setting("audio_input_device") or "default"
        except Exception:
            in_raw = "default"
        try:
            out_raw = self.db.get_setting("audio_output_device") or "default"
        except Exception:
            out_raw = "default"
        try:
            mic_gain = float(self.db.get_setting("audio_mic_gain") or 1.0)
        except Exception:
            mic_gain = 1.0
        try:
            spk_gain = float(self.db.get_setting("audio_spk_gain") or 1.0)
        except Exception:
            spk_gain = 1.0

        v = VoiceEngine(
            params=params,
            send_pcm_cb=_send,
            input_device=_parse_dev(in_raw),
            output_device=_parse_dev(out_raw),
            mic_gain=mic_gain,
            speaker_gain=spk_gain,
            monitor_self=False,
        )
        await v.start()
        self._voice = v
        self._call_peer = peer_alias
        self._call_id = call_id
        self._call_params = params
        self._call_chat_id = peer_alias
        self._set_call_ui(True, f"In call with {peer_alias}")

    async def _start_group_voice_engine(self, group_id: str, call_id: str, params: VoiceParams) -> None:
        async def _send(pcm: bytes) -> None:
            await self.net.send_group_voice_pcm(group_id, call_id, pcm)

        def _parse_dev(raw) -> int | None:
            s = str(raw or "").strip().lower()
            if not s or s == "default":
                return None
            try:
                return int(s)
            except Exception:
                return None

        try:
            in_raw = self.db.get_setting("audio_input_device") or "default"
        except Exception:
            in_raw = "default"
        try:
            out_raw = self.db.get_setting("audio_output_device") or "default"
        except Exception:
            out_raw = "default"
        try:
            mic_gain = float(self.db.get_setting("audio_mic_gain") or 1.0)
        except Exception:
            mic_gain = 1.0
        try:
            spk_gain = float(self.db.get_setting("audio_spk_gain") or 1.0)
        except Exception:
            spk_gain = 1.0

        v = VoiceEngine(
            params=params,
            send_pcm_cb=_send,
            input_device=_parse_dev(in_raw),
            output_device=_parse_dev(out_raw),
            mic_gain=mic_gain,
            speaker_gain=spk_gain,
            monitor_self=False,
        )
        await v.start()
        self._voice = v
        self._group_call_group = group_id
        self._group_call_id = call_id
        self._call_params = params
        self._call_chat_id = group_id

        group_info = self.db.get_group(group_id)
        group_name = (group_info or {}).get("name", group_id[:8] if group_id else "Group")
        self._set_call_ui(True, f"Group call: {group_name}")

        self._update_group_members_panel()

    async def _stop_group_voice_engine(self) -> None:
        v = self._voice
        old_group_id = self._group_call_group
        self._voice = None
        self._group_call_group = None
        self._group_call_id = None
        self._group_call_participants.clear()
        try:
            if isinstance(old_group_id, str) and old_group_id:
                self._group_call_pending_targets.pop(old_group_id, None)
        except Exception:
            pass
        self._call_params = None
        self._call_chat_id = None

        if v is not None:
            try:
                asyncio.create_task(v.stop())
            except Exception:
                pass

        self._update_group_members_panel()

        try:
            self._set_call_ui(False, "")
        except Exception:
            pass

    async def _stop_voice_engine(self) -> None:
        v = self._voice
        self._voice = None
        self._call_peer = None
        self._call_id = None
        self._call_params = None
        self._call_chat_id = None

        if v is not None:
            try:
                asyncio.create_task(v.stop())
            except Exception:
                pass

        try:
            self._set_call_ui(False, "")
        except Exception:
            pass

    async def _force_end_call_ui(self) -> None:

        v = self._voice
        self._voice = None
        self._call_peer = None
        self._call_id = None
        self._call_params = None
        self._call_chat_id = None

        if v is not None:
            try:
                asyncio.create_task(v.stop())
            except Exception:
                pass

        try:
            self._clear_incoming_ring()
        except Exception:
            pass

        self._call_participants = set()
        self._call_participant_widgets = {}
        self._call_remote_muted = set()
        self._call_remote_gain = {}
        self._call_speaking_until = {}

        try:
            self._group_call_pending_targets.clear()
        except Exception:
            pass

        try:
            self._set_call_ui(False, "")
        except Exception:
            pass

        for wid in ("#call_bar", "#call_panel"):
            try:
                w = self.query_one(wid)
                w.styles.display = "none"
                w.styles.visibility = "hidden"
                w.styles.height = 0
            except Exception:
                pass

        try:
            self.refresh(layout=True, repaint=True)
        except Exception:
            pass

    async def on_voice_pcm(self, sender: str, call_id: str, pcm: bytes) -> None:
        if not self.is_voice_call_active(sender, call_id):
            return
        if self._voice is None:
            return
        if sender in self._call_remote_muted:
            return

        g = float(self._call_remote_gain.get(sender, 1.0))
        if g != 1.0:
            try:
                import numpy as np

                arr = np.frombuffer(pcm, dtype=np.int16).astype(np.float32)
                arr = np.clip(arr * g, -32768.0, 32767.0).astype(np.int16)
                pcm = arr.tobytes()
            except Exception:
                pass

        self._mark_speaking(sender, pcm)
        self._voice.push_remote_pcm(pcm)

    async def on_group_voice_pcm(self, group_id: str, call_id: str, sender: str, pcm: bytes) -> None:
        if not self.is_group_call_active(group_id, call_id):
            return
        if self._voice is None:
            return
        if sender in self._call_remote_muted:
            return

        g = float(self._call_remote_gain.get(sender, 1.0))
        if g != 1.0:
            try:
                import numpy as np
                arr = np.frombuffer(pcm, dtype=np.int16).astype(np.float32)
                arr = np.clip(arr * g, -32768.0, 32767.0).astype(np.int16)
                pcm = arr.tobytes()
            except Exception:
                pass

        self._mark_speaking(sender, pcm)
        self._voice.push_remote_pcm(pcm)

    async def on_group_call_state(self, group_id: str, call_id: str | None, participants: list[str], action: str | None, who: str | None) -> None:
        event = action

        if event == "query":
            if call_id and participants:

                self._pending_group_call_info = {
                    "group_id": group_id,
                    "call_id": call_id,
                    "participants": list(participants),
                }

                self._update_group_call_indicator(group_id, len(participants))
            else:
                self._pending_group_call_info = None
                self._update_group_call_indicator(group_id, 0)

            self._update_group_members_panel()
            return

        if group_id == self._group_call_group:
            if call_id:
                self._group_call_id = call_id
            self._group_call_participants = set(participants or [])

            try:
                pending = self._group_call_pending_targets.get(group_id)
                if pending:
                    pending.difference_update(self._group_call_participants)
                    if not pending:
                        self._group_call_pending_targets.pop(group_id, None)
            except Exception:
                pass

            self._update_group_members_panel()

            try:
                self._refresh_call_panel()
            except Exception:
                pass

            if event == "join" and who and who != self.net.ident.alias:
                self.notify(f"{who} joined the call")
            elif event == "leave" and who:
                self.notify(f"{who} left the call")

            if len(self._group_call_participants) <= 1 and self._voice is not None and event == "leave":
                self.notify("Everyone left the call")

    def _update_group_call_indicator(self, group_id: str, participant_count: int) -> None:

        try:

            if participant_count > 0:
                if not hasattr(self, "_active_group_calls"):
                    self._active_group_calls = {}
                self._active_group_calls[group_id] = participant_count
            else:
                if hasattr(self, "_active_group_calls"):
                    self._active_group_calls.pop(group_id, None)

            self.refresh_sidebar()
        except Exception:
            pass

    async def query_group_call(self, group_id: str) -> None:
        await self.net.group_call_query(group_id)

    async def join_ongoing_group_call(self, group_id: str, call_id: str) -> None:

        if self._voice is not None:
            if self._group_call_group:
                await self._stop_group_voice_engine()
            else:
                await self._stop_voice_engine()

        await self.net.group_call_join(group_id, call_id)

        params = VoiceParams()
        await self._start_group_voice_engine(group_id, call_id, params)

        self._group_call_participants.add(self.net.ident.alias)

    async def on_group_call_ring(self, group_id: str, call_id: str, caller: str) -> None:
        if caller in self.blocked_users:
            return

        if self._voice is not None:
            return

        self._group_call_ringing = {
            "group_id": group_id,
            "call_id": call_id,
            "caller": caller,
        }

        group_info = self.db.get_group(group_id)
        group_name = (group_info or {}).get("name", group_id[:8] if group_id else "Group")
        self._set_call_ui(True, f"Group call from {group_name}")
        self._ringtone_start()

    async def on_group_kicked(self, group_id: str, by: str) -> None:

        group_info = self.db.get_group(group_id)
        group_name = (group_info or {}).get("name", "Group")

        chat_id = f"g:{group_id}"
        if self.active_chat == chat_id:
            self.active_chat = None
            self._hide_group_members_panel()

        if self._group_call_group == group_id:
            await self._stop_group_voice_engine()

        self.notify(f"You were kicked from {group_name} by {by}", severity="warning")
        self.refresh_sidebar()

    async def on_group_member_left(self, group_id: str, alias: str, reason: str) -> None:

        chat_id = f"g:{group_id}"
        if self.active_chat == chat_id:
            self._update_group_members_panel()

        if reason == "kicked":
            self.notify(f"{alias} was kicked from the group")

        self.refresh_sidebar()

    def _update_group_members_panel(self) -> None:
        if not self.active_chat or not self.active_chat.startswith("g:"):
            return

        group_id = self.active_chat[2:]
        try:
            panel = self.query_one("#group_members_panel", GroupMembersPanel)

            group_info = self.db.get_group(group_id)
            group_name = (group_info or {}).get("name", "Group")
            members_list = self.db.list_group_members(group_id)

            members_data = []
            call_participants = self._group_call_participants if self._group_call_group == group_id else set()
            active_call_count = self._active_group_calls.get(group_id, 0)

            for alias in members_list:
                online = alias in self.net.online_aliases
                in_call = alias in call_participants
                members_data.append({
                    "alias": alias,
                    "online": online,
                    "in_call": in_call,
                })

            panel.update_members(members_data)
            panel.set_active_call(active_call_count if active_call_count > 0 else len(call_participants))
        except Exception:
            pass

    def _show_group_members_panel(self) -> None:
        if not self.active_chat or not self.active_chat.startswith("g:"):
            return

        group_id = self.active_chat[2:]
        try:
            panel = self.query_one("#group_members_panel", GroupMembersPanel)
            group_info = self.db.get_group(group_id)
            group_name = (group_info or {}).get("name", "Group")
            panel.show(group_id, group_name)
            self._update_group_members_panel()

            try:
                btn = self.query_one("#btn_group_members", Button)
                btn.remove_class("hidden")
                btn.label = "◀"
            except Exception:
                pass
        except Exception:
            pass

    def _hide_group_members_panel(self) -> None:
        try:
            panel = self.query_one("#group_members_panel", GroupMembersPanel)
            panel.hide()

            try:
                btn = self.query_one("#btn_group_members", Button)
                btn.label = "▶"

            except Exception:
                pass
        except Exception:
            pass

    def _toggle_group_members_panel(self) -> None:
        if not self.active_chat or not self.active_chat.startswith("g:"):
            return

        group_id = self.active_chat[2:]
        try:
            panel = self.query_one("#group_members_panel", GroupMembersPanel)
            group_info = self.db.get_group(group_id)
            group_name = (group_info or {}).get("name", "Group")
            panel.toggle(group_id, group_name)

            try:
                btn = self.query_one("#btn_group_members", Button)
                btn.label = "◀" if panel.has_class("visible") else "▶"
            except Exception:
                pass
            if panel.has_class("visible"):
                self._update_group_members_panel()
        except Exception:
            pass

    async def join_group_call(self, group_id: str) -> None:

        if self._voice is not None:
            if self._group_call_group:
                await self._stop_group_voice_engine()
            else:
                await self._stop_voice_engine()

        call_id = str(uuid.uuid4())

        await self.net.group_call_join(group_id, call_id)

        params = VoiceParams()
        await self._start_group_voice_engine(group_id, call_id, params)

        self._group_call_participants.add(self.net.ident.alias)

    async def leave_group_call(self) -> None:
        if self._group_call_group:
            await self.net.group_call_leave(self._group_call_group)
            await self._stop_group_voice_engine()

    async def ring_group_members(self, group_id: str, target: str | None = None) -> None:
        if not self._group_call_id or not self._group_call_group:
            return

        try:
            members = list(self.db.list_group_members(group_id))
        except Exception:
            members = []

        if isinstance(target, str) and target.strip():
            targets = [target.strip()]
        else:
            targets = [m for m in members if isinstance(m, str) and m and m != self.net.ident.alias]

        try:
            if targets:
                self._group_call_pending_targets[group_id] = set(targets)
            else:
                self._group_call_pending_targets.pop(group_id, None)
        except Exception:
            pass

        await self.net.group_call_ring(
            group_id,
            self._group_call_id,
            members,
            target
        )

        try:
            self._refresh_call_panel()
        except Exception:
            pass

    async def accept_group_call_ring(self) -> None:
        if not self._group_call_ringing:
            return

        group_id = self._group_call_ringing["group_id"]
        call_id = self._group_call_ringing["call_id"]

        self._group_call_ringing = None
        self._ringtone_stop()

        await self.net.group_call_join(group_id, call_id)

        params = VoiceParams()
        await self._start_group_voice_engine(group_id, call_id, params)
        self._group_call_participants.add(self.net.ident.alias)

    async def decline_group_call_ring(self) -> None:
        self._group_call_ringing = None
        self._ringtone_stop()
        self._set_call_ui(False, "")

    def on_remote_message_deleted(self, msg_info: dict) -> bool:
        try:
            from plugins import get_plugin_loader
            loader = get_plugin_loader()
            plugin = loader.get_plugin("message_history")
            if plugin and plugin.enabled:

                return plugin.record_delete(
                    msg_info.get("msg_id", ""),
                    msg_info.get("chat_id", ""),
                    msg_info.get("content", ""),
                    msg_info.get("sender", ""),
                    msg_info.get("ts", 0)
                )
        except Exception:
            pass
        return False

    def on_remote_message_edited(self, edit_info: dict) -> None:
        try:
            from plugins import get_plugin_loader
            loader = get_plugin_loader()
            plugin = loader.get_plugin("message_history")
            if plugin and plugin.enabled:

                plugin.record_edit(
                    edit_info.get("msg_id", ""),
                    edit_info.get("old_content", ""),
                    edit_info.get("ts", 0)
                )
        except Exception:
            pass

    async def on_call_offer(self, sender: str, call_id: str, obj: dict) -> None:
        if self._voice is not None:
            try:
                await self.net.send_call_answer(sender, call_id, accept=False)
            except Exception:
                pass
            return

        if sender in self.blocked_users:
            try:
                await self.net.send_call_answer(sender, call_id, accept=False)
            except Exception:
                pass
            return

        if not self._cooldown_ok(sender):
            try:
                await self.net.send_call_answer(sender, call_id, accept=False)
            except Exception:
                pass
            self._set_cooldown(sender, 6.0)
            return

        if self._call_ringing_from is not None:
            try:
                await self.net.send_call_answer(sender, call_id, accept=False)
            except Exception:
                pass
            return

        self._start_incoming_ring(sender, call_id, obj if isinstance(obj, dict) else {})
        self._set_cooldown(sender, 6.0)

    async def on_call_answer(self, sender: str, call_id: str, obj: dict) -> None:
        if self._call_peer != sender or self._call_id != call_id:
            return
        accept = bool(obj.get("accept"))
        if not accept:
            try:
                self.notify("Call rejected", severity="warning")
            except Exception:
                pass
            await self._stop_voice_engine()
            return
        if self._voice is not None:
            return
        params = self._call_params or VoiceParams()
        try:
            await self._start_voice_engine(sender, call_id, params)
        except Exception as e:
            try:
                self.notify(f"Audio start failed: {e}", severity="error")
            except Exception:
                pass
            await self._stop_voice_engine()

    async def on_call_hangup(self, sender: str, call_id: str, obj: dict) -> None:
        if self._call_ringing_from == sender and self._call_ringing_id == call_id:
            try:
                self.notify("Call missed")
            except Exception:
                pass
            await self._force_end_call_ui()
            return
        if self._call_peer != sender or self._call_id != call_id:
            return
        try:
            self.notify("Call ended")
        except Exception:
            pass
        await self._force_end_call_ui()

    def _participants_for_chat(self) -> list[str]:
        chat = self._call_chat_id or self.active_chat
        if not isinstance(chat, str) or not chat:
            return [self.ident.alias]

        if chat.startswith("g:"):
            gid = chat[2:]
            try:
                members = list(self.db.list_group_members(gid))
            except Exception:
                members = []

            try:
                bad: set[str] = {gid}
                if isinstance(self._group_call_id, str) and self._group_call_id:
                    bad.add(self._group_call_id)
                pending = self._pending_group_call_info
                if pending and pending.get("group_id") == gid:
                    cid = pending.get("call_id")
                    if isinstance(cid, str) and cid:
                        bad.add(cid)
                members = [m for m in members if isinstance(m, str) and m and m not in bad]
            except Exception:
                pass
            if self.ident.alias not in members:
                members.insert(0, self.ident.alias)
            rest = [m for m in members if m != self.ident.alias]
            return [self.ident.alias] + sorted(rest)

        peer = chat
        if peer == self.ident.alias:
            return [self.ident.alias]
        return [self.ident.alias, peer]

    def _refresh_call_panel(self) -> None:
        try:
            box = self.query_one("#call_participants", Horizontal)
        except Exception:
            return

        try:
            box.query("*").remove()
        except Exception:
            pass

        self._call_participant_widgets.clear()

        chat = self._call_chat_id or self.active_chat
        gid = chat[2:] if isinstance(chat, str) and chat.startswith("g:") else None
        pending = self._group_call_pending_targets.get(gid or "") if gid else None
        in_call_participants = self._group_call_participants if (gid and self._group_call_group == gid) else set()

        for alias in self._participants_for_chat():
            item = CallParticipantItem(alias)
            if alias == self.ident.alias:
                item.add_class("me")

            try:
                if (
                    gid
                    and self._voice is not None
                    and self._group_call_group == gid
                    and alias != self.ident.alias
                    and pending
                    and alias in pending
                    and alias not in in_call_participants
                ):
                    item.add_class("dim")
            except Exception:
                pass
            try:
                box.mount(item)
            except Exception:
                pass
            self._call_participant_widgets[alias] = item

        self._update_call_buttons()

    def _update_call_buttons(self) -> None:
        if self._voice is None:
            return
        try:
            mute_btn = self.query_one("#call_mute", Button)
            deafen_btn = self.query_one("#call_deafen", Button)

            muted = bool(self._voice.muted)
            deafened = bool(self._voice.deafened)

            mute_btn.label = "🔇" if muted else "🎤"
            deafen_btn.label = "🔇" if deafened else "🔊"

            mute_btn.set_class(muted, "active")
            deafen_btn.set_class(deafened, "active")
        except Exception:
            pass

    def _ensure_speaking_timer(self) -> None:
        if self._call_speaking_timer is not None:
            return
        try:

            self._call_speaking_timer = self.set_interval(0.2, self._speaking_tick)
        except Exception:
            self._call_speaking_timer = None

    def _stop_speaking_timer(self) -> None:
        t = self._call_speaking_timer
        self._call_speaking_timer = None
        if t is not None:
            try:
                t.stop()
            except Exception:
                pass
        self._call_speaking_until.clear()

    def _mark_speaking(self, alias: str, pcm: bytes) -> None:
        try:
            import numpy as np

            a = np.frombuffer(pcm, dtype=np.int16).astype(np.float32)
            if a.size == 0:
                return
            rms = float(np.sqrt(np.mean(a * a)))
        except Exception:
            return
        if rms < 350.0:
            return
        self._call_speaking_until[alias] = time.time() + 0.35

    def _speaking_tick(self) -> None:
        now = time.time()
        for alias, w in list(self._call_participant_widgets.items()):
            until = float(self._call_speaking_until.get(alias, 0.0))
            if until > now:
                w.add_class("speaking")
            else:
                w.remove_class("speaking")

    async def on_call_participant_item_context_requested(self, event: CallParticipantItem.ContextRequested) -> None:
        alias = event.alias
        if not isinstance(alias, str) or not alias:
            return

        is_self = alias == self.ident.alias
        remote_muted = alias in self._call_remote_muted
        gain = float(self._call_remote_gain.get(alias, 1.0))
        self_muted = bool(self._voice.muted) if self._voice is not None else False
        self_deafened = bool(self._voice.deafened) if self._voice is not None else False

        try:
            pop = self.query_one("#call_user_popover", CallUserPopover)
            pop.open(
                alias=alias,
                x=int(event.x),
                y=int(event.y),
                is_self=is_self,
                remote_muted=remote_muted,
                self_muted=self_muted,
                self_deafened=self_deafened,
                gain=gain,
            )
        except Exception:
            pass

    def on_call_user_popover_closed(self, event: CallUserPopover.Closed) -> None:
        try:
            self.query_one("#call_user_popover", CallUserPopover).close()
        except Exception:
            pass

    def on_group_members_panel_close_requested(self, event: GroupMembersPanel.CloseRequested) -> None:
        self._hide_group_members_panel()

    @work
    async def on_group_members_panel_call_requested(self, event: GroupMembersPanel.CallRequested) -> None:
        group_id = event.group_id
        if not group_id:
            return

        if self._group_call_group == group_id and self._voice is not None:
            await self.leave_group_call()
            self._update_group_members_panel()
            return

        if self._voice is not None:
            self.notify("End current call first", severity="warning")
            return

        pending_info = self._pending_group_call_info
        if pending_info and pending_info.get("group_id") == group_id:
            await self.join_ongoing_group_call(group_id, pending_info["call_id"])
        else:
            await self.join_group_call(group_id)
            await self.ring_group_members(group_id)

        self._update_group_members_panel()

    @work
    async def on_group_members_panel_member_context_requested(
        self, event: GroupMembersPanel.MemberContextRequested
    ) -> None:
        alias = event.alias
        group_id = event.group_id
        x, y = event.x, event.y

        if not group_id or not alias:
            return

        group_info = self.db.get_group(group_id)
        creator = (group_info or {}).get("creator", "")
        my_alias = self.net.ident.alias if self.net and self.net.ident else ""
        is_admin = creator == my_alias

        if alias == my_alias:
            return

        actions = []

        actions.append(("ring", "🔔 Ring"))

        if is_admin:
            actions.append(("kick", "❌ Kick"))

        if not actions:
            return

        result = await self.ask(ContextMenu("", actions, x=x, y=y))

        if result == "ring":
            await self._ring_group_member(group_id, alias)
        elif result == "kick":
            await self._kick_group_member(group_id, alias)

    async def _ring_group_member(self, group_id: str, alias: str) -> None:
        try:

            await self.net.send_group_ring(group_id, [alias])
            try:
                self._group_call_pending_targets.setdefault(group_id, set()).add(alias)
                self._refresh_call_panel()
            except Exception:
                pass
            self.notify(f"Ringing {alias}...")
        except Exception as e:
            self.notify(f"Failed to ring: {e}", severity="error")

    async def _kick_group_member(self, group_id: str, alias: str) -> None:
        try:
            ok = await self.ask(ConfirmDialog(
                self.t("confirm"),
                f"Kick {alias} from this group?",
            ))
            if not ok:
                return
            await self.net.kick_group_member(group_id, alias)
            self.notify(f"Kicked {alias}")
            self._update_group_members_panel()
        except Exception as e:
            self.notify(f"Failed to kick: {e}", severity="error")

    def on_call_user_popover_remote_mute_toggled(self, event: CallUserPopover.RemoteMuteToggled) -> None:
        alias = event.alias
        if not isinstance(alias, str) or not alias:
            return
        if alias in self._call_remote_muted:
            self._call_remote_muted.remove(alias)
        else:
            self._call_remote_muted.add(alias)

        try:
            pop = self.query_one("#call_user_popover", CallUserPopover)
            pop._remote_muted = alias in self._call_remote_muted
            pop._gain = float(self._call_remote_gain.get(alias, 1.0))
            pop._update_ui()
        except Exception:
            pass

    def on_call_user_popover_remote_gain_changed(self, event: CallUserPopover.RemoteGainChanged) -> None:
        alias = event.alias
        if not isinstance(alias, str) or not alias:
            return
        try:
            g = float(event.gain)
        except Exception:
            return
        g = max(0.0, min(3.0, g))
        self._call_remote_gain[alias] = g

    def on_call_user_popover_self_mute_toggled(self, event: CallUserPopover.SelfMuteToggled) -> None:
        if self._voice is None:
            return
        self._voice.set_muted(not self._voice.muted)
        self._update_call_buttons()

        try:
            pop = self.query_one("#call_user_popover", CallUserPopover)
            pop._self_muted = bool(self._voice.muted)
            pop._update_ui()
        except Exception:
            pass

    def on_call_user_popover_self_deafen_toggled(self, event: CallUserPopover.SelfDeafenToggled) -> None:
        if self._voice is None:
            return
        self._voice.set_deafened(not self._voice.deafened)
        self._update_call_buttons()
        try:
            pop = self.query_one("#call_user_popover", CallUserPopover)
            pop._self_deafened = bool(self._voice.deafened)
            pop._update_ui()
        except Exception:
            pass

    async def on_mount(self) -> None:
        self.set_theme(self.db.get_setting("theme") or "default", persist=False)
        self.set_lang(self.db.get_setting("lang") or "en", persist=False)
        self._apply_sidebar_layout()
        self.refresh_sidebar()
        self.set_focus(self.composer)
        self.system_log(self.t("connecting"))
        await self._apply_info_panel(animate=False)

        btn_style = self.db.get_setting("button_style") or "flat"
        self.apply_button_style(btn_style)

        try:
            from ui.widgets.appearance import ColorRulesManager
            self._color_rules_manager = ColorRulesManager(self.db)
        except Exception:
            self._color_rules_manager = None

        self._init_command_palette()

        self.loading_screen = LoadingScreen()
        self.push_screen(self.loading_screen)

        self._fps_timer = None

        try:

            self._chat_scroll_timer = self.set_interval(0.5, self._maybe_load_older_on_scroll_tick)
        except Exception:
            self._chat_scroll_timer = None

        self._net_task = asyncio.create_task(self._net_task_runner())
        self._alive_task = asyncio.create_task(self._alive_loop())
        self._refresh_users_task = asyncio.create_task(self._refresh_users_loop())

        self._file_send_task = None
        self._file_download_task = None
        try:
            self._set_transfer_ui("", visible=False)
        except Exception:
            pass

        chats = self.db.list_chats()
        if chats:
            self.active_chat = chats[0]["peer_alias"]
            await self.refresh_active_chat()

        try:
            self._set_call_ui(False, "")
        except Exception:
            pass

    def update_loading_status(self, text: str):
        if hasattr(self, "loading_screen"):
            self.loading_screen.update_status(text)

    async def on_connected(self):
        if hasattr(self, "loading_screen") and self.loading_screen.is_mounted:
            await self.pop_screen()
        asyncio.create_task(self._check_for_updates())

    async def _check_for_updates(self):
        try:
            from importlib.metadata import version as pkg_version
            current_version = pkg_version("antch")
        except Exception:
            current_version = "0.0.0"

        def _fetch_pypi_version():
            net_mode = "direct"
            try:
                gcfg = load_global_config()
                net_mode = gcfg.get("network", {}).get("mode", "direct")
                proxy_host = gcfg.get("network", {}).get("proxy_host", "127.0.0.1")
                proxy_port = gcfg.get("network", {}).get("proxy_port", 9050)

                if net_mode == "socks5" or net_mode == "proxy":
                    socks.set_default_proxy(socks.SOCKS5, proxy_host, proxy_port)
                    socket.socket = socks.socksocket

                ctx = ssl.create_default_context()
                req = urllib.request.Request(
                    "https://pypi.org/pypi/antch/json",
                    headers={"User-Agent": "antch-updater"}
                )
                with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
                    data = json.loads(resp.read().decode())
                    return data.get("info", {}).get("version", "0.0.0")
            except Exception:
                return None
            finally:
                if net_mode == "socks5":
                    socket.socket = socks._orgsocket

        latest = await asyncio.get_event_loop().run_in_executor(None, _fetch_pypi_version)
        if latest is None:
            return

        def _version_tuple(v: str):
            parts = v.replace("dev", ".").replace("a", ".").replace("b", ".").split(".")
            return tuple(int(x) for x in parts if x.isdigit())

        try:
            if _version_tuple(latest) > _version_tuple(current_version):
                self._latest_version = latest
                self._current_version = current_version
                container = self.query_one("#update_container", Horizontal)
                container.remove_class("hidden")
        except Exception:
            pass

    def show_fatal_error(self, message: str):
        self.push_screen(FatalErrorScreen(message))

    async def _net_task_runner(self):
        try:
            await self.net.run()

            if hasattr(self, "loading_screen") and self.loading_screen.is_mounted:
                self.show_fatal_error("Connection ended before login completed.")
        except Exception as e:
            self.system_log(f"Network error: {e}")

            if hasattr(self, "loading_screen") and self.loading_screen.is_mounted:

                 self.show_fatal_error(str(e))

    async def _alive_loop(self):
        while True:
            try:

                status = self.net.my_profile.get("status", "online")
                if status != "offline":
                    await self.net.send_alive()
            except:
                pass
            await asyncio.sleep(20)

    async def _refresh_users_loop(self):
        while True:
            try:
                tabs = self.query_one("#tabs", TabbedContent)
                if tabs.active == "tab_users":
                    await self.net.refresh_users()
            except:
                pass
            await asyncio.sleep(10)

    def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
        if event.tab.id == "tab_users":
            try:
                self._users_requested_once = True
            except Exception:
                pass
            asyncio.create_task(self.net.refresh_users())

    def action_shrink_sidebar(self):
        if self.sidebar_collapsed:
            self.sidebar_collapsed = False
        self.set_sidebar_width(self.sidebar_width - 2)

    def action_grow_sidebar(self):
        if self.sidebar_collapsed:
            self.sidebar_collapsed = False
        self.set_sidebar_width(self.sidebar_width + 2)

    def _init_command_palette(self) -> None:
        try:
            palette = self.query_one("#command_palette", CommandPalette)

            commands = get_builtin_commands()

            try:
                from plugins import get_plugin_loader
                loader = get_plugin_loader()
                plugin_cmds = loader.get_all_commands()
                commands = merge_plugin_commands(commands, plugin_cmds)
            except Exception:
                pass

            palette.set_commands(commands)
        except Exception as e:
            pass

    def _refresh_command_palette(self) -> None:
        self._init_command_palette()

    def on_composer_slash_command_requested(self, event: Composer.SlashCommandRequested) -> None:
        try:
            palette = self.query_one("#command_palette", CommandPalette)

            self._refresh_command_palette()
            palette.show(event.filter_text)

            try:
                composer_frame = self.query_one("#composer_frame")
                palette.styles.bottom = 4
                palette.styles.left = 2
            except Exception:
                pass
        except Exception:
            pass

    def on_composer_slash_command_filter_changed(self, event: Composer.SlashCommandFilterChanged) -> None:
        try:
            palette = self.query_one("#command_palette", CommandPalette)
            palette.update_filter(event.filter_text)
        except Exception:
            pass

    def on_composer_slash_command_dismissed(self, event: Composer.SlashCommandDismissed) -> None:
        try:
            palette = self.query_one("#command_palette", CommandPalette)
            palette.hide()
        except Exception:
            pass

    def on_composer_slash_command_navigate(self, event: Composer.SlashCommandNavigate) -> None:
        try:
            palette = self.query_one("#command_palette", CommandPalette)

            if event.direction == "up":
                palette.select_prev()
            elif event.direction == "down":
                palette.select_next()
            elif event.direction == "tab":

                cmd = palette.get_selected_command()
                if cmd:
                    self.composer.insert_command(cmd.name, add_space=True)
                    palette.hide()
            elif event.direction == "confirm":

                cmd = palette.get_selected_command()
                if cmd:
                    self.composer.insert_command(cmd.name, add_space=True)
                    palette.hide()
        except Exception:
            pass

    def on_command_palette_command_selected(self, event: CommandPalette.CommandSelected) -> None:
        cmd = event.command

        if event.insert_only:

            self.composer.insert_command(cmd.name, add_space=True)
        else:

            self.composer.insert_command(cmd.name, add_space=True)

        self.set_focus(self.composer)

    @work
    async def on_composer_submitted(self, message: Composer.Submitted):
        text = message.text

        if text.startswith("/"):
            handled = await self._handle_slash_command(text)
            if handled:
                return

        await self._send_text_msg(text)

    async def _handle_slash_command(self, text: str) -> bool:
        parts = text[1:].split(None, 1)
        if not parts:
            return False

        cmd_name = parts[0].lower()
        args_str = parts[1] if len(parts) > 1 else ""
        args = args_str.split() if args_str else []

        try:
            from plugins import get_plugin_loader
            loader = get_plugin_loader()
            if loader.handle_command(cmd_name, args):
                return True
        except Exception:
            pass

        return await self._handle_builtin_command(cmd_name, args_str, args)

    async def _handle_builtin_command(self, cmd: str, args_str: str, args: list) -> bool:

        if cmd == "help":
            await self._cmd_help(args)
            return True

        elif cmd == "me":
            if args_str:
                await self._send_text_msg(f"*{args_str}*")
            return True

        elif cmd == "shrug":
            text = f"{args_str} ¯\\_(ツ)_/¯" if args_str else "¯\\_(ツ)_/¯"
            await self._send_text_msg(text)
            return True

        elif cmd == "tableflip":
            text = f"{args_str} (╯°□°)╯︵ ┻━┻" if args_str else "(╯°□°)╯︵ ┻━┻"
            await self._send_text_msg(text)
            return True

        elif cmd == "unflip":
            text = f"{args_str} ┬─┬ノ( º _ ºノ)" if args_str else "┬─┬ノ( º _ ºノ)"
            await self._send_text_msg(text)
            return True

        elif cmd == "nick":
            if len(args) >= 2:
                alias = args[0]
                name = " ".join(args[1:])
                self.db.set_nick(alias, name)
                self.notify(f"Set nickname for {alias}: {name}")
                self.refresh_sidebar()
            else:
                self.notify("Usage: /nick <alias> <name>", severity="warning")
            return True

        elif cmd == "block":
            if args:
                alias = args[0]
                self.db.block_peer(alias)
                self.notify(f"Blocked {alias}")
                self.refresh_sidebar()
            else:
                self.notify("Usage: /block <alias>", severity="warning")
            return True

        elif cmd == "unblock":
            if args:
                alias = args[0]
                self.db.unblock_peer(alias)
                self.notify(f"Unblocked {alias}")
                self.refresh_sidebar()
            else:
                self.notify("Usage: /unblock <alias>", severity="warning")
            return True

        elif cmd == "clear":
            if self.active_chat:
                ok = await self.ask(ConfirmDialog(
                    "Clear Chat",
                    "Are you sure you want to clear all messages in this chat?",
                    ok_key="Clear", cancel_key="Cancel"
                ))
                if ok:
                    self.db.clear_chat(self.active_chat)
                    await self.refresh_active_chat()
                    self.notify("Chat cleared")
            return True

        elif cmd == "ping":
            self.notify("🏓 Pong!")
            return True

        elif cmd == "status":
            if args:
                status = args[0].lower()
                valid = ["online", "away", "busy", "offline"]
                if status in valid:
                    self.db.set_setting("my_status", status)
                    await self.net.send_presence(status)
                    self.notify(f"Status set to {status}")
                else:
                    self.notify(f"Invalid status. Use: {', '.join(valid)}", severity="warning")
            else:
                self.notify("Usage: /status <online|away|busy|offline>", severity="warning")
            return True

        elif cmd in ("whisper", "w", "msg", "dm"):
            if len(args) >= 2:
                alias = args[0]
                msg = " ".join(args[1:])
                await self.net.send_text(alias, msg)
                self.notify(f"Whispered to {alias}")
            else:
                self.notify("Usage: /whisper <alias> <message>", severity="warning")
            return True

        elif cmd == "spoiler":
            if args_str:
                await self._send_text_msg(f"||{args_str}||")
            return True

        elif cmd == "find":
            if args_str:
                self._find_start(args_str)
            else:
                self.notify(self.t("cmd_find_usage"), severity="warning")
            return True

        elif cmd == "join":
            if args:
                group_id = args[0]
                self.notify(f"Joining group: {group_id}")

            else:
                self.notify("Usage: /join <group_id>", severity="warning")
            return True

        elif cmd == "leave":
            if self.active_chat and self.active_chat.startswith("g:"):

                self.notify("Left group")
            else:
                self.notify("Not in a group", severity="warning")
            return True

        elif cmd == "invite":
            if self.active_chat and self.active_chat.startswith("g:") and args:
                alias = args[0]
                group_id = self.active_chat[2:]
                await self.net.group_invite(group_id, alias)
                self.notify(f"Invited {alias} to group")
            else:
                self.notify("Usage: /invite <alias> (in a group)", severity="warning")
            return True

        elif cmd == "kick":
            if self.active_chat and self.active_chat.startswith("g:") and args:
                alias = args[0]
                group_id = self.active_chat[2:]

                self.notify(f"Kicked {alias}")
            else:
                self.notify("Usage: /kick <alias> (in a group)", severity="warning")
            return True

        return False

    async def _cmd_help(self, args: list) -> None:
        try:
            palette = self.query_one("#command_palette", CommandPalette)
            commands = palette._commands

            if args:

                cmd_name = args[0].lower().lstrip("/")
                for cmd in commands:
                    if cmd.name == cmd_name or cmd_name in cmd.aliases:
                        help_text = f"/{cmd.name}"
                        if cmd.usage:
                            help_text = cmd.usage
                        if cmd.description:
                            help_text += f"\n{cmd.description}"
                        if cmd.aliases:
                            help_text += f"\nAliases: {', '.join(cmd.aliases)}"
                        self.notify(help_text, timeout=10)
                        return
                self.notify(f"Unknown command: {cmd_name}", severity="warning")
            else:

                by_category = {}
                for cmd in commands:
                    cat = cmd.category or "General"
                    if cat not in by_category:
                        by_category[cat] = []
                    by_category[cat].append(cmd.name)

                help_lines = ["Available commands:"]
                for cat, cmds in sorted(by_category.items()):
                    help_lines.append(f"\n[{cat}]")
                    help_lines.append(", ".join(f"/{c}" for c in sorted(cmds)))

                self.notify("\n".join(help_lines), timeout=15)
        except Exception as e:
            self.notify("Type / to see available commands", timeout=5)

    def action_cancel_reply(self):
        self.reply_to = None
        self.reply_bar.hide_reply()

    async def action_paste(self):
        text = ""
        try:
            text = pyperclip.paste()
        except Exception:
            pass
        if text:
            try:
                self.composer.insert(text)
            except Exception:
                self.composer.text = (self.composer.text or "") + text

    async def _send_text_msg(self, text: str):
        if not self.active_chat:
            self.system_log(self.t("select_chat_first"))
            return

        if isinstance(self.active_chat, str) and self.active_chat.startswith("g:"):
            gid = self.active_chat[2:]
            await self.net.send_group_text(gid, text, self.reply_to)
        else:
            await self.net.send_text(self.active_chat, text, self.reply_to)

        self.reply_to = None
        self.reply_bar.hide_reply()
        self.set_focus(self.composer)

    async def on_info_tab_toggled(self, event: InfoTab.Toggled) -> None:
        self.info_expanded = not self.info_expanded
        await self._apply_info_panel(animate=True)

    @work
    async def on_call_tab_pressed(self, event: CallTab.Pressed) -> None:
        await self._handle_call_pressed()

    async def _handle_call_pressed(self) -> None:
        peer = self.active_chat
        if not isinstance(peer, str) or not peer:
            return

        if peer.startswith("g:"):
            group_id = peer[2:]

            if self._group_call_group == group_id and self._voice is not None:

                await self.leave_group_call()
                return

            if self._voice is not None:
                self.notify("End current call first", severity="warning")
                return

            ongoing_info = self._active_group_calls.get(group_id)
            pending_info = self._pending_group_call_info

            if pending_info and pending_info.get("group_id") == group_id:

                await self.join_ongoing_group_call(
                    group_id,
                    pending_info["call_id"]
                )
                return

            await self.join_group_call(group_id)

            await self.ring_group_members(group_id)
            return

        if peer in self.blocked_users:
            self.notify("User is blocked", severity="warning")
            return
        if not self._cooldown_ok(peer):
            self.notify("Call cooldown active", severity="warning")
            return
        if self._call_peer and self._call_id:
            try:
                await self.net.send_call_hangup(self._call_peer, self._call_id, reason="hangup")
            except Exception:
                pass
            await self._force_end_call_ui()
            return

        if peer not in self.net.sessions:
            self.notify("No E2E session yet (accept handshake first)", severity="warning")
            return

        call_id = str(uuid.uuid4())
        self._call_peer = peer
        self._call_id = call_id
        self._call_params = VoiceParams()
        self._call_chat_id = peer
        self._set_call_ui(True, f"Calling {peer}…")
        try:
            await self.net.send_call_offer(
                peer,
                call_id,
                rate=self._call_params.rate,
                channels=self._call_params.channels,
                frame_ms=self._call_params.frame_ms,
            )
            self._set_cooldown(peer, 6.0)
        except Exception as e:
            self.notify(f"Call failed: {e}", severity="error")
            await self._stop_voice_engine()

    def _open_peer_search(self, *, initial: str = "") -> None:
        try:
            panel = self.query_one("#peer_search_panel", PeerSearchPanel)
            panel.open(initial=initial)
        except Exception:
            pass

    def _close_peer_search(self) -> None:
        try:
            panel = self.query_one("#peer_search_panel", PeerSearchPanel)
            panel.close()
        except Exception:
            pass

    @work
    async def on_peer_search_panel_submitted(self, event: PeerSearchPanel.Submitted) -> None:
        alias = (event.alias or "").strip()
        if not alias:
            return
        self._close_peer_search()
        await self.net.start_handshake(alias)
        try:
            tabs = self.query_one("#tabs", TabbedContent)
            tabs.active = "tab_requests"
        except Exception:
            pass
        self.system_log(self.t("request_sent", alias=alias))
        self.refresh_sidebar()

    def on_peer_search_panel_closed(self, event: PeerSearchPanel.Closed) -> None:
        self._close_peer_search()

    @work
    async def on_button_pressed(self, event: Button.Pressed) -> None:
        bid = event.button.id
        if not bid:
            return

        if bid == "btn_update":
            current = getattr(self, "_current_version", "?")
            latest = getattr(self, "_latest_version", "?")
            await self.push_screen(InfoDialog(
                self.t("update_available"),
                self.t("update_available_body", current=current, latest=latest),
            ))
            return

        if bid == "select_delete":
            await self._delete_selected_messages()
            return
        if bid == "select_cancel":
            self._exit_select_mode()
            return

        if bid in ("call_accept", "call_decline"):

            if self._group_call_ringing:
                if bid == "call_decline":
                    await self.decline_group_call_ring()
                else:
                    await self.accept_group_call_ring()
                return

            if not self._call_ringing_from or not self._call_ringing_id:
                return
            peer = self._call_ringing_from
            call_id = self._call_ringing_id
            incoming = self._call_incoming_obj or {}

            if bid == "call_decline":
                self._clear_incoming_ring()
                try:
                    await self.net.send_call_answer(peer, call_id, accept=False)
                except Exception:
                    pass
                return

            self._clear_incoming_ring()
            try:
                params = VoiceParams(
                    rate=int(incoming.get("rate") or 48000),
                    channels=int(incoming.get("channels") or 1),
                    frame_ms=int(incoming.get("frame_ms") or 20),
                )
            except Exception:
                params = VoiceParams()

            try:
                await self.net.send_call_answer(peer, call_id, accept=True)
            except Exception:
                pass
            try:
                await self._start_voice_engine(peer, call_id, params)
            except Exception as e:
                try:
                    self.notify(f"Audio start failed: {e}", severity="error")
                except Exception:
                    pass
                await self._stop_voice_engine()
            return

        if bid == "btn_call":
            await self._handle_call_pressed()
            return

        if bid in ("call_mute", "call_deafen", "call_hangup"):
            if bid == "call_hangup":

                if self._group_call_group:
                    await self.leave_group_call()
                    return

                if self._call_ringing_from and self._call_ringing_id:
                    peer = self._call_ringing_from
                    call_id = self._call_ringing_id
                    self._clear_incoming_ring()
                    try:
                        await self.net.send_call_answer(peer, call_id, accept=False)
                    except Exception:
                        pass

                if self._call_peer and self._call_id:
                    try:
                        await self.net.send_call_hangup(self._call_peer, self._call_id, reason="hangup")
                    except Exception:
                        pass

                await self._force_end_call_ui()
                return

            if self._voice is None:
                return
            if bid == "call_mute":
                self._voice.set_muted(not self._voice.muted)
                self._update_call_buttons()
                return
            if bid == "call_deafen":
                self._voice.set_deafened(not self._voice.deafened)
                self._update_call_buttons()
                return

        if bid == "transfer_cancel":
            td = getattr(self, "_file_download_task", None)
            if td is not None and hasattr(td, "cancel") and not td.done():
                try:
                    td.cancel()
                except Exception:
                    pass

            ts = getattr(self, "_file_send_task", None)
            if ts is not None and hasattr(ts, "cancel") and not ts.done():
                try:
                    ts.cancel()
                except Exception:
                    pass
            return

        if bid == "transfer_ok":
            self._set_transfer_ui("", visible=False)
            return

        if bid == "user_box":
            current_status = self.my_profile.get("status", "online")
            current_bio = self.my_profile.get("bio", "")
            current_status_msg = self.my_profile.get("status_msg", "")

            widget = event.button
            region = widget.region

            x = region.x

            y = region.y - 31

            result = await self.ask(MyStatusDialog(current_status, current_status_msg, current_bio, x=x, y=y))
            if result == "edit_profile":
                await self.push_screen(SettingsScreen())
            elif isinstance(result, dict):
                await self.update_profile(status=result.get("status"), status_msg=result.get("status_msg"))
            elif isinstance(result, str) and result:
                await self.update_profile(status=result)
            return

        if bid == "btn_settings":
            await self.push_screen(SettingsScreen())
            return
        if bid == "btn_toggle_sidebar":
            self._saved_sidebar_width = self.sidebar_width
            self.sidebar_collapsed = True
            self._apply_sidebar_layout()
            return

        if bid == "btn_group_members":
            self._toggle_group_members_panel()
            return

        if bid == "btn_expand_sidebar":
            self.sidebar_collapsed = False
            self.sidebar_width = max(18, self._saved_sidebar_width)
            self._apply_sidebar_layout()
            self.refresh_sidebar()
            return

        if bid == "send_btn":
            text = self.composer.text.strip()
            if text:
                await self._send_text_msg(text)
                self.composer.clear()
            return

        if bid == "attach_btn":
            if not self.active_chat:
                self.system_log(self.t("select_chat_first"))
                return
            caption = (self.composer.text or "").strip()
            path = await self.ask(FileManagerDialog())
            if not path:
                return
            try:
                t = getattr(self, "_file_send_task", None)
                if t is not None and not t.done():
                    self.system_log("A file send is already in progress")
                    return

                self._set_transfer_ui("Preparing file…", visible=True)
                self._file_send_task = asyncio.create_task(
                    self._run_send_file_task(
                        chat_id=str(self.active_chat),
                        path=str(path),
                        caption=str(caption or ""),
                        reply_to=self.reply_to,
                    )
                )
            except Exception as e:
                self.system_log(f"File send failed: {e}")
            return

        if bid in ("btn_new", "btn_new_rail"):
            choice = await self.ask(NewChatMenu())
            if choice == "new":
                self._open_peer_search()
                return
            if choice == "group":
                result = await self.ask(CreateGroupDialog())
                if not result:
                    return
                gname = result.get("name", "").strip()
                members = result.get("members", [])
                if not gname:
                    return
                chat_id = await self.net.create_group(str(gname), members)
                self.active_chat = chat_id
                await self.refresh_active_chat()
                self.refresh_sidebar()
                self.set_focus(self.composer)
                return
            if choice == "import":
                path = await self.ask(TextPromptDialog(self.t("dlg_import_chat"), placeholder="/path/to/chat.json"))
                if not path:
                    return
                await self._import_chat(path)
                self.refresh_sidebar()
                return
            return

    async def _import_chat(self, path: str):
        p = pathlib.Path(path)
        if not p.exists():
            self.system_log("File not found")
            return
        data = json.loads(p.read_text())
        peer = (data.get("peer") or {}).get("alias")
        if not peer:
            self.system_log("Invalid export: no peer alias")
            return
        self.db.ensure_chat(peer)
        for m in data.get("messages", []):
            if self.db.get_message(m["msg_id"]):
                continue
            self.db.add_message(
                m["msg_id"],
                peer,
                m["sender_alias"],
                int(m["ts"]),
                m["body"],
                m.get("reply_to"),
                m.get("edited_ts"),
                m.get("local_state", "ok"),
            )
        self.active_chat = peer
        await self.refresh_active_chat()

        if peer and peer.startswith("g:"):

            try:
                btn = self.query_one("#btn_group_members", Button)
                btn.remove_class("hidden")
            except Exception:
                pass
            self._update_group_members_panel()
            self._show_group_members_panel()
        else:

            try:
                btn = self.query_one("#btn_group_members", Button)
                btn.add_class("hidden")
            except Exception:
                pass
            self._hide_group_members_panel()

    @work
    async def on_list_view_selected(self, event: ListView.Selected) -> None:
        item = event.item
        if isinstance(item, ChatItem):
            self.active_chat = item.peer_alias
            await self.refresh_active_chat()
            self.set_focus(self.composer)

            if item.peer_alias.startswith("g:"):
                group_id = item.peer_alias[2:]

                try:
                    btn = self.query_one("#btn_group_members", Button)
                    btn.remove_class("hidden")
                except Exception:
                    pass
                self._update_group_members_panel()
                self._show_group_members_panel()
                await self.query_group_call(group_id)
            else:

                try:
                    btn = self.query_one("#btn_group_members", Button)
                    btn.add_class("hidden")
                except Exception:
                    pass
                self._hide_group_members_panel()
        elif isinstance(item, RequestInItem):

            from plugins.base import HookType
            request_data = {
                "req_id": item.req_id,
                "peer_alias": item.peer_alias,
                "peer_fp": item.peer_fp,
                "received_at": item.received_at
            }
            hook_results = self._plugin_loader.call_hook(HookType.ON_REQUEST_CLICK, request_data)

            action = None
            for result in hook_results:
                if asyncio.iscoroutine(result):
                    result = await result
                if result in ("accept", "decline"):
                    action = result
                    break

            if action is None:
                action = await self.ask(RequestDialog(item.peer_alias, item.peer_fp))

            if action == "accept":
                selected = next((r for r in self.db.list_requests_in() if r["id"] == item.req_id), None)
                if selected:
                    peer_eph = selected["payload"]["eph_pub"]
                    await self.net.send_hs2(item.peer_alias, peer_eph, item.peer_fp)
                self.db.delete_request_in(item.req_id)
                self.refresh_sidebar()
                self.active_chat = item.peer_alias
                await self.refresh_active_chat()
            elif action == "decline":
                await self.net.send_hs_reject(item.peer_alias)
                self.db.delete_request_in(item.req_id)
                self.refresh_sidebar()
        elif isinstance(item, GroupInviteInItem):
            selected = next((r for r in self.db.list_requests_in() if r["id"] == item.req_id), None)
            payload = (selected or {}).get("payload") if selected else None
            if not isinstance(payload, dict):
                return
            body = self.t("group_invite_body", name=item.group_name, from_alias=item.from_alias)
            ok = await self.ask(ConfirmDialog(self.t("group_invite_title"), body, ok_key="accept", cancel_key="decline"))
            if ok:
                chat_id = self.net.accept_group_invite(payload)
                self.db.delete_request_in(item.req_id)
                self.refresh_sidebar()
                if chat_id:
                    self.active_chat = chat_id
                    await self.refresh_active_chat()
            else:
                self.db.delete_request_in(item.req_id)
                self.refresh_sidebar()
        elif isinstance(item, UserItem):

            users = getattr(self.net, "all_users", [])
            user_data = next((u for u in users if u["alias"] == item.alias), None)
            if user_data:

                region = item.region

                x = region.x + region.width
                y = region.y
                await self.ask(UserProfileDialog(user_data, x=x, y=y))
        elif isinstance(item, RequestOutItem):
            try:
                region = item.region
                x = int(region.x + region.width)
                y = int(region.y)
            except Exception:
                x = 0
                y = 0
            choice = await self.ask(
                ContextMenu(
                    self.t("menu_outgoing_request"),
                    [("retry", self.t("menu_retry")), ("delete", self.t("menu_delete_record"))],
                    x=x,
                    y=y,
                )
            )
            if choice == "retry":
                await self.net.start_handshake(item.peer_alias)
                self.refresh_sidebar()
            elif choice == "delete":
                self.db.delete_request_out(item.req_id)
                self.refresh_sidebar()

    @work
    async def on_message_widget_file_download_requested(self, event: MessageWidget.FileDownloadRequested) -> None:
        meta = event.file_meta or {}
        sender = None
        try:
            sender = getattr(event.sender, "msg", {}).get("sender_alias")
        except Exception:
            sender = None
        file_id = meta.get("file_id")
        name = meta.get("name") or (file_id or "file")
        file_key = meta.get("file_key")
        record_count = meta.get("record_count")
        record_hashes = meta.get("record_hashes")
        cloud_blob_sha = meta.get("cloud_blob_sha256")
        bytes_total = meta.get("bytes_total")

        if not file_id or not file_key or not record_count:
            self.system_log("Missing file metadata")
            return

        try:
            default_dir = str(getattr(self.net, "downloads_dir", APP_DIR / "downloads"))
        except Exception:
            default_dir = str(APP_DIR / "downloads")
        save_path = await self.ask(SaveFileDialog(suggested_name=str(name), start_dir=default_dir))
        if not save_path:
            return

        try:
            t1 = getattr(self, "_file_send_task", None)
            if t1 is not None and not t1.done():
                self.system_log("A file send is already in progress")
                return
            t2 = getattr(self, "_file_download_task", None)
            if t2 is not None and not t2.done():
                self.system_log("A download is already in progress")
                return

            self._set_transfer_ui(f"Preparing download: {name}", visible=True)
            self._file_download_task = asyncio.create_task(
                self._run_download_file_task(
                    file_id=str(file_id),
                    file_key=str(file_key),
                    record_count=int(record_count),
                    record_hashes=list(record_hashes or []),
                    sender=str(sender) if isinstance(sender, str) and sender else None,
                    cloud_blob_sha256=str(cloud_blob_sha) if isinstance(cloud_blob_sha, str) and cloud_blob_sha else None,
                    save_path=str(save_path),
                    name=str(name),
                    bytes_total=int(bytes_total) if isinstance(bytes_total, int) else None,
                )
            )
        except Exception as e:
            self.system_log(f"Download failed: {e}")
            try:
                self.on_file_download_progress({"phase": "done"})
            except Exception:
                pass

    @work
    async def on_message_widget_image_preview_requested(self, event: MessageWidget.ImagePreviewRequested) -> None:
        import shutil
        import subprocess
        import tempfile
        import pathlib

        meta = getattr(event, "file_meta", None) or {}
        sender = None
        try:
            sender = getattr(event.sender, "msg", {}).get("sender_alias")
        except Exception:
            sender = None
        file_id = meta.get("file_id")
        file_key = meta.get("file_key")
        record_count = meta.get("record_count")
        record_hashes = meta.get("record_hashes")
        name = meta.get("name") or (file_id or "image")
        cloud_blob_sha = meta.get("cloud_blob_sha256")

        if not file_id or not file_key or not record_count:
            self.system_log("Missing image metadata")
            return

        try:
            self.system_log("Preparing preview (download + decrypt)…")
            await self.net.download_ciphertext(
                file_id,
                int(record_count),
                list(record_hashes or []),
                seed=str(sender) if isinstance(sender, str) and sender else None,
                cloud_blob_sha256=str(cloud_blob_sha) if isinstance(cloud_blob_sha, str) and cloud_blob_sha else None,
                name=str(name),
            )

            tmp_dir = pathlib.Path(tempfile.gettempdir()) / "e2e-client-previews"
            tmp_dir.mkdir(parents=True, exist_ok=True)

            try:
                safe_name = self.net._safe_filename(str(name))
            except Exception:
                safe_name = str(name).replace("/", "_").replace("\\", "_")

            out_path = tmp_dir / f"full_{file_id}_{safe_name}"
            if not out_path.exists() or out_path.stat().st_size <= 0:
                await self.net.decrypt_to_path(file_id=file_id, file_key_b64=str(file_key), out_path=str(out_path))
            path = str(out_path)
        except Exception as e:
            self.system_log(f"Preview failed: {e}")
            return

        def _run() -> None:
            import os

            kitty = shutil.which("kitty")
            xdg_open = shutil.which("xdg-open")

            if kitty:
                try:
                    sz = os.get_terminal_size()
                    cols = max(20, int(sz.columns))
                    rows = max(10, int(sz.lines) - 2)
                    place = f"{cols}x{rows}@0x0"
                    cmd = [kitty, "+kitten", "icat", "--clear", "--scale-up", f"--place={place}", path]
                except Exception:
                    cmd = [kitty, "+kitten", "icat", "--clear", "--scale-up", path]

                subprocess.run(cmd, check=False)
            elif xdg_open:
                subprocess.run([xdg_open, path], check=False)
            else:
                print(path)

            try:
                input("\nPress Enter to return...")
            except Exception:
                pass

        try:
            with self.suspend():
                _run()
        except Exception as e:
            self.system_log(f"Preview failed: {e}")

    @work
    async def on_message_widget_context_requested(self, event: MessageWidget.ContextRequested) -> None:
        msg = event.sender.msg
        peer = msg["peer_alias"]
        mid = msg["msg_id"]
        sender = msg["sender_alias"]
        is_me = sender == self.ident.alias
        kind = msg.get("kind") or "text"
        meta = msg.get("meta") or {}
        edited = msg.get("edited_ts")

        is_deleted = meta.get("_deleted", False)
        has_history = False
        if not is_deleted:
            try:
                from plugins import get_plugin_loader
                loader = get_plugin_loader()
                plugin = loader.get_plugin("message_history")
                if plugin and plugin.enabled:
                    is_deleted = plugin._is_message_deleted(mid)

                    if not is_deleted:
                        history = plugin._get_message_history(mid)
                        has_history = bool(history)
            except Exception:
                pass

        actions = []

        if is_deleted:

            actions.append(("info", self.t("menu_info")))
        else:

            actions.append(("reply", self.t("menu_reply")))
            if is_me:
                actions += [("edit", self.t("menu_edit")), ("delete", self.t("menu_delete"))]
            if kind == "file":
                actions.append(("seed", self.t("menu_seed")))

            actions.append(("info", self.t("menu_info")))

            plugin_menu_items = []
            try:
                from plugins import get_plugin_loader, HookType
                loader = get_plugin_loader()

                message_data = {
                    "msg_id": mid,
                    "peer_alias": peer,
                    "sender_alias": sender,
                    "content": msg.get("body", ""),
                    "text": msg.get("body", ""),
                    "kind": kind,
                    "meta": meta,
                    "ts": msg.get("ts", 0),
                    "is_me": is_me,
                }

                hook_results = loader.call_hook(HookType.ON_MESSAGE_CONTEXT_MENU, message_data)
                for result in hook_results:
                    if isinstance(result, list):
                        for item in result:
                            if isinstance(item, dict) and "id" in item and "label" in item:
                                plugin_menu_items.append(item)

                static_items = loader.get_all_context_menu_items("message")
                for item in static_items:
                    plugin_menu_items.append({
                        "id": item.id,
                        "label": item.label,
                        "icon": item.icon,
                        "callback": item.callback,
                    })
            except Exception as e:
                print(f"[Main] Plugin context menu error: {e}")

            if plugin_menu_items:
                actions.append(("---", "───────────"))
                for pitem in plugin_menu_items:
                    item_id = f"plugin_{pitem['id']}"
                    item_label = pitem.get('icon', '') + ' ' + pitem['label'] if pitem.get('icon') else pitem['label']
                    actions.append((item_id, item_label.strip()))

        choice = await self.ask(ContextMenu(self.t("menu_message"), actions, x=event.x, y=event.y))

        if choice and choice.startswith("plugin_"):
            plugin_item_id = choice[7:]
            try:

                for pitem in plugin_menu_items:
                    if pitem['id'] == plugin_item_id:
                        callback = pitem.get('callback')
                        if callback:

                            import asyncio
                            if asyncio.iscoroutinefunction(callback):
                                await callback(message_data)
                            else:
                                callback(message_data)
                        break
            except Exception as e:
                self.notify(f"Plugin action error: {e}", severity="error")
            return
        if choice == "reply":
            self.reply_to = mid
            self.reply_bar.show_reply(sender, short_snip(msg["body"], 80))
            self.set_focus(self.composer)
        elif choice == "edit":

            try:
                prev_mid = getattr(self, "_inline_edit_mid", None)
                if prev_mid and prev_mid != mid:
                    w_prev = self._msg_widgets.get(prev_mid)
                    if w_prev and hasattr(w_prev, "cancel_inline_edit"):
                        w_prev.cancel_inline_edit()
            except Exception:
                pass
            try:
                setattr(self, "_inline_edit_mid", mid)
            except Exception:
                pass
            try:
                if hasattr(event.sender, "start_inline_edit"):
                    event.sender.start_inline_edit()
            except Exception:
                pass
        elif choice == "delete":
            ok = await self.ask(ConfirmDialog(self.t("dlg_delete_message"), self.t("dlg_delete_message_body"), ok_key="Delete", cancel_key="Cancel"))
            if ok:

                content = msg.get("body", "")
                msg_sender = msg.get("sender_alias", "")
                msg_ts = msg.get("ts", 0)
                prevent_delete = False
                try:
                    from plugins import get_plugin_loader
                    loader = get_plugin_loader()
                    plugin = loader.get_plugin("message_history")
                    if plugin and plugin.enabled:
                        prevent_delete = plugin.record_delete(mid, peer, content, msg_sender, msg_ts)
                except Exception:
                    pass

                if not prevent_delete:
                    self.db.delete_message(mid)
                else:

                    try:
                        self.db.update_message_meta(mid, {"_deleted": True, "_deleted_ts": now_ts()})
                    except Exception:
                        self.db.delete_message(mid)

                await self.net.send_control_delete(peer, mid)
                await self.refresh_active_chat()
        elif choice == "info":
            info = f"From: {sender}\nTime: {fmt_date(msg['ts'])}\nMessage ID: {mid}"
            await self.ask(ConfirmDialog(self.t("dlg_message_info"), info, ok_key="OK", cancel_key="Close"))
        elif choice == "history":

            try:
                from plugins import get_plugin_loader
                loader = get_plugin_loader()
                plugin = loader.get_plugin("message_history")
                if plugin and plugin.enabled:
                    plugin.show_message_history_dialog({"id": mid})
                else:
                    self.notify("Message History plugin not enabled")
            except Exception as e:
                self.notify(f"Could not show history: {e}")
        elif choice == "seed" and kind == "file":
            meta = msg.get("meta") or {}
            fid = None
            try:
                fid = (meta or {}).get("file_id")
            except Exception:
                fid = None
            if isinstance(fid, str) and fid:
                await self.seed_file_id(fid)

    async def on_message_widget_history_requested(self, event: MessageWidget.HistoryRequested) -> None:
        mid = str(getattr(event, "msg_id", "") or "")
        if not mid:
            return
        try:
            from plugins import get_plugin_loader
            loader = get_plugin_loader()
            plugin = loader.get_plugin("message_history")
            if plugin and plugin.enabled:
                plugin.show_message_history_dialog({"id": mid})
            else:
                self.notify(self.t("plugin_not_enabled") if hasattr(self, "t") else "Plugin not enabled")
        except Exception as e:
            try:
                self.notify(f"Could not show history: {e}")
            except Exception:
                pass

    async def on_message_widget_inline_edit_submitted(self, event: MessageWidget.InlineEditSubmitted) -> None:
        mid = str(event.msg_id)
        new_text = str(event.new_text or "")
        if not mid:
            return

        w = self._msg_widgets.get(mid)
        msg = getattr(w, "msg", None) if w else None
        if not isinstance(msg, dict):
            await self.refresh_active_chat()
            return

        peer = msg.get("peer_alias")
        old = str(msg.get("body") or "")
        if not isinstance(peer, str) or not peer:
            return
        if not new_text.strip() or new_text == old:
            try:
                if w and hasattr(w, "cancel_inline_edit"):
                    w.cancel_inline_edit()
            except Exception:
                pass
            return

        try:
            from plugins import get_plugin_loader
            loader = get_plugin_loader()
            plugin = loader.get_plugin("message_history")
            if plugin and plugin.enabled:
                plugin.record_edit(mid, old)
        except Exception:
            pass

        self.db.update_message_body(mid, new_text, now_ts())
        await self.net.send_control_edit(peer, mid, new_text)

        try:
            if w and isinstance(w.msg, dict):
                w.msg = dict(w.msg)
                w.msg["body"] = new_text
                w.msg["edited_ts"] = now_ts()
                if hasattr(w, "cancel_inline_edit"):
                    w.cancel_inline_edit()
                else:
                    w.refresh_content()
        except Exception:
            await self.refresh_active_chat()

        try:
            setattr(self, "_inline_edit_mid", None)
        except Exception:
            pass

    async def on_message_widget_jump_requested(self, event: MessageWidget.JumpRequested) -> None:
        w = self._msg_widgets.get(event.target_msg_id)
        if not w:
            self.system_log("Original message not found.")
            return
        scroll = self.query_one("#messages", VerticalScroll)

        try:
            if hasattr(scroll, "scroll_to_widget"):
                res = scroll.scroll_to_widget(w, center=True, animate=True)
                if inspect.isawaitable(res):
                    await res
            else:
                y = getattr(getattr(w, "region", None), "y", None)
                if y is not None and hasattr(scroll, "scroll_to"):
                    res = scroll.scroll_to(y=y, animate=True)
                    if inspect.isawaitable(res):
                        await res
        except Exception as e:
            try:
                self.system_log(f"Jump failed: {e}")
            except Exception:
                pass

        try:
            w.add_class("flash")
            self.set_timer(0.8, lambda: w.remove_class("flash"))
        except Exception:
            pass

    @work
    async def on_mouse_down(self, event: events.MouseDown) -> None:

        try:
            pop = self.query_one("#call_user_popover", CallUserPopover)
            if pop.styles.display != "none":
                rx = int(pop.region.x)
                ry = int(pop.region.y)
                rw = int(pop.region.width)
                rh = int(pop.region.height)
                inside = (rx <= event.screen_x < rx + rw) and (ry <= event.screen_y < ry + rh)
                if not inside:
                    pop.close()
        except Exception:
            pass

        if event.button != 3:
            return
        hit = None
        try:
            hit = self.get_widget_at(event.screen_x, event.screen_y)
        except Exception:
            return
        widget = hit[0] if isinstance(hit, tuple) and hit else hit
        if not widget:
            return

        li = widget
        for _ in range(12):
            if isinstance(li, ChatItem):
                break
            li = getattr(li, "parent", None)
            if li is None:
                break

        if not isinstance(li, ChatItem):
            try:
                lv = self.query_one("#chat_list", ListView)
                if lv.highlighted_child and isinstance(lv.highlighted_child, ChatItem):
                    li = lv.highlighted_child
            except Exception:
                li = None

        if isinstance(li, ChatItem):
            peer = li.peer_alias
            if isinstance(peer, str) and peer.startswith("g:"):
                choice = await self.ask(
                    ContextMenu(
                        self.t("menu_chat"),
                        [
                            ("open", self.t("menu_open")),
                            ("seed", self.t("menu_seed")),
                            ("info", self.t("menu_info")),
                            ("add_member", self.t("menu_add_member")),
                            ("delete", self.t("menu_delete_chat")),
                        ],
                        x=event.screen_x,
                        y=event.screen_y,
                    )
                )
            else:
                choice = await self.ask(
                    ContextMenu(
                        self.t("menu_chat"),
                        [
                            ("open", self.t("menu_open")),
                            ("seed", self.t("menu_seed")),
                            ("export", self.t("menu_export")),
                            ("delete", self.t("menu_delete_chat")),
                        ],
                        x=event.screen_x,
                        y=event.screen_y,
                    )
                )
            if choice == "open":
                self.active_chat = peer
                await self.refresh_active_chat()
            elif choice == "seed":
                self.toggle_chat_seed(peer)
                self.notify(self.t("seed_chat_toggled"))
            elif choice == "info" and isinstance(peer, str) and peer.startswith("g:"):
                gid = peer[2:]
                g = self.db.get_group(gid) or {}
                name = g.get("name") or gid
                members = self.db.list_group_members(gid)
                body = self.t("group_info_body", name=str(name), gid=str(gid), members="\n".join(members))
                await self.ask(ConfirmDialog(self.t("group_info_title"), body, ok_key="ok", cancel_key="close"))
            elif choice == "add_member" and isinstance(peer, str) and peer.startswith("g:"):
                alias = await self.ask(TextPromptDialog(self.t("dlg_add_member"), placeholder="alias"))
                if alias:
                    gid = peer[2:]
                    self.db.add_group_member(gid, str(alias))
                    await self.net.send_group_invite(gid, str(alias))
                    await self.net.broadcast_group_member_added(gid, str(alias))
                    self.refresh_sidebar()
            elif choice == "export":
                default = str(APP_DIR / "exports" / f"chat_{peer}_{fmt_date(now_ts())}.json")
                pathlib.Path(default).parent.mkdir(parents=True, exist_ok=True)
                path = await self.ask(TextPromptDialog(self.t("dlg_export_chat"), initial=default))
                if path:
                    await self._export_chat(peer, path)
                    self.system_log(f"Exported to {path}")
            elif choice == "delete":
                ok = await self.ask(ConfirmDialog(self.t("dlg_delete_chat"), self.t("dlg_delete_chat_body", peer=peer), ok_key="Delete", cancel_key="Cancel"))
                if ok:
                    self.db.delete_chat(peer)
                    if self.active_chat == peer:
                        self.active_chat = None
                        self.query_one("#messages", VerticalScroll).remove_children()
                    self.refresh_sidebar()
            event.stop()

    async def _export_chat(self, peer_alias: str, path: str):
        msgs = self.db.list_messages(peer_alias, limit=100000)
        peer = self.db.get_peer(peer_alias)
        data = {
            "version": 1,
            "exported_at": now_ts(),
            "me": {"alias": self.ident.alias, "fp": self.ident.fp},
            "peer": {"alias": peer_alias, "fp": peer["fp"] if peer else None},
            "messages": msgs,
        }
        pathlib.Path(path).write_text(json.dumps(data, indent=2, ensure_ascii=False))

    def watch_sidebar_collapsed(self, old: bool, new: bool):
        self._apply_sidebar_layout()

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--alias", required=False)
    ap.add_argument("--server", required=False)
    ap.add_argument("--db", default=None)
    args = ap.parse_args()

    alias = args.alias
    server = args.server
    db_path = None
    passphrase = None

    while True:
        if not alias:
            from launcher import Launcher
            launcher = Launcher()
            res = launcher.run()
            if not res:
                print("No account selected.")
                return
            alias = res["alias"]
            server = res["server"]
            db_path = res["db_path"]
            passphrase = res.get("passphrase")

        if not db_path:
            db_path = pathlib.Path(args.db) if args.db else (APP_DIR / f"client_{alias}.db")

        db = DB(db_path, passphrase=passphrase)

        if args.server:
            db.set_setting("server", args.server)
        elif not server:

            server = db.get_setting("server")
            if not server:
                 pass

        if not server:
            print("Server URL is required. Please provide --server or set it in the launcher.")
            return

        ident = Identity.load_or_create(db, alias)

        global_config = load_global_config()

        app = ChatApp(ident, db, server, global_config)
        ret = app.run()

        try:
            db.close()
        except:
            pass

        if ret == "restart_restore":
            try:
                restore_path = APP_DIR / f"restore_{alias}.db"
                if restore_path.exists():
                    restore_path.replace(pathlib.Path(db_path))

                    try:
                        from database import _marker_path

                        restore_marker = _marker_path(restore_path)
                        target_marker = _marker_path(pathlib.Path(db_path))
                        if restore_marker.exists():
                            restore_marker.replace(target_marker)
                        else:
                            target_marker.unlink(missing_ok=True)
                    except Exception:
                        pass
            except Exception:
                pass
            continue

        if ret == "logout":
            alias = None
            server = None
            db_path = None
            passphrase = None
            continue
        else:
            break

if __name__ == "__main__":
    main()
