from __future__ import annotations

import re
from datetime import datetime, timedelta

from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.events import Key
from textual.message import Message
from textual.screen import Screen
from textual.widgets import Footer, Input, ListItem, ListView, Static, TextArea


# ── Status indicators ────────────────────────────────────────────────

STATUS_DOTS = {
    "available": "[#00c853]\u2022[/]",
    "away": "[#ffd600]\u2022[/]",
    "dnd": "[#ff1744]\u2022[/]",
    "offline": "[#555]\u2022[/]",
}

STATUS_LABELS = {
    "available": "[#00c853]ON[/]",
    "away": "[#ffd600]AFK[/]",
    "dnd": "[#ff1744]DND[/]",
    "offline": "[#555]OFF[/]",
}

# ── Code block regex ─────────────────────────────────────────────────

CODE_BLOCK_RE = re.compile(r"```(\w+)?\n(.*?)```", re.DOTALL)
INLINE_CODE_RE = re.compile(r"`([^`]+)`")

# ── Name colors ──────────────────────────────────────────────────────

NAME_COLORS = [
    "#ff8c00", "#00e676", "#448aff", "#ff6e40", "#e040fb",
    "#18ffff", "#ffd740", "#69f0ae", "#40c4ff", "#ff4081",
]


def _name_color(name: str) -> str:
    return NAME_COLORS[hash(name) % len(NAME_COLORS)]


def _escape_rich(text: str) -> str:
    return text.replace("[", r"\[")


def _render_inline(text: str) -> str:
    # Escape Rich markup in user content first
    text = _escape_rich(text)
    result = INLINE_CODE_RE.sub(lambda m: f"[bold reverse] {m.group(1)} [/]", text)
    result = re.sub(r"\*\*(.+?)\*\*", r"[bold]\1[/]", result)
    result = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"[italic]\1[/]", result)
    return result


def _parse_message_parts(content: str) -> list[tuple[str, str | tuple[str, str]]]:
    parts: list[tuple[str, str | tuple[str, str]]] = []
    last_end = 0
    for match in CODE_BLOCK_RE.finditer(content):
        before = content[last_end:match.start()]
        if before.strip():
            parts.append(("text", before))
        lang = match.group(1) or "text"
        code = match.group(2).rstrip()
        parts.append(("code", (lang, code)))
        last_end = match.end()
    remaining = content[last_end:]
    if remaining.strip():
        parts.append(("text", remaining))
    if not parts:
        parts.append(("text", content))
    return parts


# ── Chat input (multi-line, enter to send, shift+enter for newline) ──


class ChatInput(TextArea):
    BINDINGS = []

    def __init__(self, **kwargs) -> None:
        super().__init__(
            language=None,
            show_line_numbers=False,
            tab_behavior="focus",
            soft_wrap=True,
            **kwargs,
        )
        self._typing_conv_id: str | None = None

    async def _on_key(self, event: Key) -> None:
        if event.key == "enter":
            event.stop()
            event.prevent_default()
            content = self.text.strip()
            if content:
                self.post_message(ChatInput.Submitted(content))
                self.clear()
            return
        if event.key in ("shift+enter", "ctrl+enter"):
            self.insert("\n")
            event.stop()
            event.prevent_default()
            return

        # Let ctrl+key combos bubble up to screen bindings (ctrl+d, ctrl+n, etc.)
        if event.key.startswith("ctrl+") and event.key not in ("ctrl+a", "ctrl+e", "ctrl+k"):
            return

        # Send typing indicator for regular keystrokes
        if event.key not in ("escape", "tab", "shift+tab", "up", "down", "left", "right") and self._typing_conv_id:
            self.post_message(ChatInput.Typing(self._typing_conv_id))

        await super()._on_key(event)

    class Submitted(Message):
        def __init__(self, value: str) -> None:
            super().__init__()
            self.value = value

    class Typing(Message):
        def __init__(self, conversation_id: str) -> None:
            super().__init__()
            self.conversation_id = conversation_id


# ── Sidebar ──────────────────────────────────────────────────────────


class ConversationItem(ListItem):
    def __init__(self, conv: dict, my_id: str) -> None:
        super().__init__(id=f"conv-{conv['id']}")
        self.conv = conv
        self.my_id = my_id

    @property
    def display_title(self) -> str:
        if self.conv.get("type") == "group":
            return self.conv.get("name") or "group"
        others = [m for m in (self.conv.get("members") or []) if m["id"] != self.my_id]
        if others:
            return others[0].get("display_name") or others[0].get("username", "?")
        return "chat"

    @property
    def other_status(self) -> str:
        if self.conv.get("type") != "dm":
            return "available"
        others = [m for m in (self.conv.get("members") or []) if m["id"] != self.my_id]
        if others:
            return others[0].get("status", "offline")
        return "offline"

    @property
    def preview(self) -> str:
        lm = self.conv.get("last_message")
        if not lm:
            return ""
        content = lm.get("content", "")
        sender = lm.get("sender_username", "")
        preview = content.replace("\n", " ")[:30]
        if lm.get("message_type") == "code":
            preview = "sent code"
        elif lm.get("message_type") == "file":
            preview = "sent a file"
        return f"{sender}: {preview}" if sender else preview

    @property
    def unread(self) -> int:
        return self.conv.get("unread_count", 0)

    def compose(self) -> ComposeResult:
        status_label = STATUS_LABELS.get(self.other_status, STATUS_LABELS["offline"])
        unread = f" [bold $primary]({self.unread})[/]" if self.unread else ""
        muted = " [dim]\U0001f507[/]" if self.conv.get("is_muted") else ""
        if self.conv.get("type") == "group":
            status_label = "[dim]GRP[/]"
        title = _escape_rich(self.display_title.upper())
        preview = _escape_rich(self.preview) if self.preview else ""
        yield Static(f" {status_label} [bold]{title}[/]{unread}{muted}", classes="conv-name")
        if preview:
            yield Static(f"     [dim]{preview}[/]", classes="conv-preview")


# ── Message rendering ────────────────────────────────────────────────


def _parse_time(ts: str) -> datetime | None:
    try:
        return datetime.fromisoformat(ts.replace("Z", "+00:00"))
    except Exception:
        return None


def _format_time(dt: datetime) -> str:
    now = datetime.now(dt.tzinfo)
    if dt.date() == now.date():
        return dt.strftime("%H:%M")
    elif dt.date() == (now - timedelta(days=1)).date():
        return "yesterday " + dt.strftime("%H:%M")
    else:
        return dt.strftime("%b %d %H:%M")


class CodeLabel(Static):
    pass


class CodeBlock(Static):
    pass


class MessageBubble(Static):
    pass


class MessageWidget(Vertical):
    def __init__(
        self,
        msg: dict,
        is_mine: bool,
        show_name: bool = True,
        show_time: bool = True,
    ) -> None:
        cls = "msg msg-mine" if is_mine else "msg msg-other"
        if not show_name:
            cls += " msg-cont"
        super().__init__(classes=cls, id=f"msg-{msg.get('id', '')}")
        self._msg = msg
        self._is_mine = is_mine
        self._show_name = show_name
        self._show_time = show_time

    def compose(self) -> ComposeResult:
        msg = self._msg
        username = msg.get("sender_username", "???")
        content = msg.get("content", "")
        message_type = msg.get("message_type", "text")
        is_deleted = msg.get("is_deleted", False)
        edited_at = msg.get("edited_at")
        ts = msg.get("created_at", "")
        dt = _parse_time(ts)
        time_str = _format_time(dt) if dt else ""
        color = "#ff8c00" if self._is_mine else _name_color(username)
        tag = _escape_rich(username.upper())
        edited_mark = "[dim]*[/]" if edited_at else ""

        # Reply preview
        reply_preview = msg.get("reply_to_preview")
        if reply_preview:
            r_sender = _escape_rich(reply_preview.get("sender_username", "?"))
            r_content = _escape_rich(reply_preview.get("content", "")[:50].replace("\n", " "))
            yield Static(
                f"       [dim]\u2514 @{r_sender}: {r_content}[/]",
                classes="reply-preview",
            )

        if is_deleted:
            if self._show_name:
                yield Static(
                    f"[dim]{time_str}[/] [{color}]{tag}[/] [dim]> -- deleted --[/]",
                    classes="msg-header",
                )
            else:
                yield MessageBubble("[dim]       > -- deleted --[/]")
            return

        if message_type == "file":
            file_name = msg.get("file_name", "file")
            file_size = msg.get("file_size", 0)
            size_str = f"{file_size / 1024:.0f}KB" if file_size < 1024 * 1024 else f"{file_size / (1024*1024):.1f}MB"
            if self._show_name:
                yield Static(
                    f"[dim]{time_str}[/] [{color}]{tag}[/] > {edited_mark}",
                    classes="msg-header",
                )
            yield MessageBubble(f"       \U0001f4ce [bold]{_escape_rich(file_name)}[/] [dim]({size_str})[/]")
        elif message_type == "code":
            lines = content.split("\n", 1)
            lang = ""
            code = content
            if len(lines) > 1:
                first = lines[0].strip()
                if first and not first.startswith(" ") and len(first) < 15:
                    lang = first
                    code = lines[1]
            if not lang:
                lang = "text"
            if self._show_name:
                yield Static(
                    f"[dim]{time_str}[/] [{color}]{tag}[/] > {edited_mark}",
                    classes="msg-header",
                )
            yield CodeLabel(f" {lang} ")
            yield CodeBlock(_escape_rich(code))
        else:
            parts = _parse_message_parts(content)
            has_only_text = all(p[0] == "text" for p in parts)

            if has_only_text and len(content) < 120 and "\n" not in content:
                # Short message: inline on one line
                rendered = _render_inline(content)
                if self._show_name:
                    yield Static(
                        f"[dim]{time_str}[/] [{color}]{tag}[/] > {rendered}{edited_mark}",
                        classes="msg-header",
                    )
                else:
                    yield MessageBubble(f"       > {rendered}{edited_mark}")
            else:
                # Long or mixed message: header + content below
                if self._show_name:
                    yield Static(
                        f"[dim]{time_str}[/] [{color}]{tag}[/] > {edited_mark}",
                        classes="msg-header",
                    )
                for part_type, part_content in parts:
                    if part_type == "code":
                        lang, code = part_content
                        yield CodeLabel(f" {lang} ")
                        yield CodeBlock(_escape_rich(code))
                    else:
                        rendered = _render_inline(part_content)
                        if rendered.strip():
                            yield MessageBubble(rendered)

        # Reactions
        reactions = msg.get("reactions", [])
        if reactions:
            badges = " ".join(
                f"[bold]{r.get('emoji', '?')}[/]({r.get('count', 0)})"
                for r in reactions
            )
            yield Static(f"       [dim]{badges}[/]", classes="reaction-badges")


class TimeSeparator(Static):
    def __init__(self, text: str) -> None:
        super().__init__(f"[dim]--- {text} ---[/]", classes="time-sep")


class UnreadMarker(Static):
    def __init__(self) -> None:
        super().__init__("[#ff8c00 bold]\u2500\u2500 NEW \u2500\u2500[/]", classes="unread-marker")


class EmptyState(Static):
    def __init__(self, text: str) -> None:
        super().__init__(f"[dim]{text}[/]", id="empty-state")


class TypingIndicator(Static):
    def __init__(self) -> None:
        super().__init__("", id="typing-indicator")


# ── Main chat screen ─────────────────────────────────────────────────


class ChatScreen(Screen):
    BINDINGS = [
        Binding("ctrl+n", "new_chat", "new", priority=True),
        Binding("ctrl+g", "new_group", "group", priority=True),
        Binding("ctrl+f", "search_messages", "find", priority=True),
        Binding("ctrl+d", "delete_chat", "del", priority=True),
        Binding("ctrl+o", "open_settings", "settings", priority=True),
        Binding("ctrl+l", "logout", "logout", priority=True),
        Binding("ctrl+q", "quit", "quit", priority=True),
        Binding("escape", "focus_sidebar", show=False, priority=True),
        Binding("tab", "focus_next_panel", show=False, priority=True),
        Binding("shift+tab", "focus_prev_panel", show=False, priority=True),
        Binding("pageup", "load_more", show=False, priority=True),
    ]

    def __init__(self) -> None:
        super().__init__()
        self._conversations: list[dict] = []
        self._active_conv: dict | None = None
        self._my_user: dict | None = None
        self._ws = None
        self._typing_timers: dict[str, float] = {}
        self._last_typing_sent: float = 0
        self._filter_query: str = ""
        self._oldest_message_time: str | None = None
        self._loading_more: bool = False
        self._has_more: bool = True
        self._editing_message_id: str | None = None
        self._last_own_messages: list[dict] = []
        self._reply_to: dict | None = None
        self._last_messages: list[dict] = []  # all recent messages for reactions

    def compose(self) -> ComposeResult:
        yield Static("", id="status-bar")
        with Horizontal(id="chat-layout"):
            with Vertical(id="sidebar"):
                yield Input(placeholder="filter...", id="sidebar-search")
                yield ListView(id="conv-list")
            with Vertical(id="main-panel"):
                yield Static("", id="chat-header")
                yield VerticalScroll(id="messages")
                yield TypingIndicator()
                yield ChatInput(id="msg-input", classes="hidden")
        yield Footer()

    def _update_status_bar(self) -> None:
        from datetime import datetime
        now = datetime.now().strftime("%H:%M")
        username = self._my_user.get("username", "?") if self._my_user else "?"
        # WS connected means we're available unless explicitly set otherwise
        if self._ws and self._ws.is_connected and self._my_user:
            if self._my_user.get("status") == "offline":
                self._my_user["status"] = "available"
        status = self._my_user.get("status", "offline") if self._my_user else "offline"
        status_label = STATUS_LABELS.get(status, STATUS_LABELS["offline"])
        n_convs = len(self._conversations)
        ws_state = "[#00c853]CONNECTED[/]" if (self._ws and self._ws.is_connected) else "[#ff1744]DISCONNECTED[/]"
        bar = (
            f" [bold $primary]TERMCHAT[/]"
            f"  [dim]\u2502[/]  {status_label} [bold]{_escape_rich(username.upper())}[/]"
            f"  [dim]\u2502[/]  {ws_state}"
            f"  [dim]\u2502[/]  [dim]{n_convs} chats[/]"
            f"  [dim]\u2502[/]  [dim]{now}[/]"
        )
        self.query_one("#status-bar", Static).update(bar)

    async def on_mount(self) -> None:
        self._load_data()
        self.set_interval(30, self._tick_status_bar)

    def _tick_status_bar(self) -> None:
        if self._my_user:
            self._update_status_bar()

    @work(exclusive=True)
    async def _load_data(self) -> None:
        import asyncio
        api = self.app.api
        try:
            self._my_user = await api.get_me()
        except Exception:
            from termchat.auth import clear_tokens
            clear_tokens()
            from termchat.screens.login import LoginScreen
            self.app.switch_screen(LoginScreen())
            return

        username = self._my_user.get("username", "")
        self._update_status_bar()
        self.query_one("#chat-header", Static).update(
            "[dim]select a conversation[/]"
        )

        container = self.query_one("#messages", VerticalScroll)
        await container.mount(
            EmptyState(f"connected as [bold]{_escape_rich(username)}[/]\nctrl+n new chat  ctrl+o settings  /help commands")
        )

        # Load conversations and start WS in parallel
        self._start_ws()
        await self._refresh_conversations()

    async def _refresh_conversations(self) -> None:
        api = self.app.api
        try:
            self._conversations = await api.get_conversations()
        except Exception:
            self._conversations = []

        await self._render_conversation_list()

    async def _render_conversation_list(self) -> None:
        conv_list = self.query_one("#conv-list", ListView)
        active_id = self._active_conv.get("id") if self._active_conv else None
        await conv_list.clear()

        my_id = self._my_user["id"] if self._my_user else ""
        query = self._filter_query.lower()

        highlight_idx = None
        idx = 0
        for conv in self._conversations:
            item = ConversationItem(conv, my_id)
            if query and query not in item.display_title.lower():
                continue
            await conv_list.append(item)
            if active_id and conv.get("id") == active_id:
                highlight_idx = idx
                # Keep _active_conv in sync with latest data
                self._active_conv = conv
            idx += 1

        if highlight_idx is not None:
            conv_list.index = highlight_idx

    # ── Sidebar search ────────────────────────────────────────────

    @on(Input.Changed, "#sidebar-search")
    def on_sidebar_search(self, event: Input.Changed) -> None:
        self._filter_query = event.value.strip()
        self._filter_sidebar()

    @work(exclusive=False)
    async def _filter_sidebar(self) -> None:
        await self._render_conversation_list()

    # ── WebSocket ──────────────────────────────────────────────────

    @work(exclusive=True, group="websocket")
    async def _start_ws(self) -> None:
        from termchat.ws import WsClient

        api = self.app.api
        token = api._access_token
        if not token:
            return

        self._ws = WsClient(
            token, self._handle_ws_message, self._on_ws_reconnect, self._on_ws_disconnect
        )
        try:
            await self._ws.connect()
            await self._ws.listen()
        except Exception:
            pass

    async def _on_ws_disconnect(self) -> None:
        """Called when WS disconnects."""
        self._update_status_bar()
        try:
            header = self.query_one("#chat-header", Static)
            current = header.renderable
            if "RECONNECTING" not in str(current):
                self.notify("[#ff1744]connection lost — reconnecting...[/]", severity="warning")
        except Exception:
            pass

    async def _on_ws_reconnect(self) -> None:
        """Called when WS reconnects after a drop. Refetch to catch missed messages."""
        self._update_status_bar()
        self.notify("[#00c853]reconnected[/]")
        await self._refresh_conversations()
        if self._active_conv:
            # Reload current conversation to get any messages we missed
            try:
                api = self.app.api
                messages = await api.get_messages(self._active_conv["id"], limit=10)
                if messages:
                    container = self.query_one("#messages", VerticalScroll)
                    # Check which messages are already displayed
                    existing_ids = {
                        w.id.replace("msg-", "")
                        for w in container.query(MessageWidget)
                        if w.id
                    }
                    for msg in messages:
                        if msg.get("id") not in existing_ids:
                            is_mine = self._my_user and msg.get("sender_id") == self._my_user["id"]
                            await container.mount(MessageWidget(msg, is_mine))
                    container.scroll_end(animate=False)
            except Exception:
                pass

    async def _handle_ws_message(self, data: dict) -> None:
        msg_type = data.get("type", "")

        if msg_type == "message":
            message = data.get("data", {})
            conv_id = message.get("conversation_id")

            # Clear typing indicator for this sender
            sender_key = f"{conv_id}:{message.get('sender_username', '')}"
            self._typing_timers.pop(sender_key, None)
            self._update_typing_display()

            if self._active_conv and self._active_conv.get("id") == conv_id:
                is_mine = (
                    self._my_user
                    and message.get("sender_id") == self._my_user["id"]
                )
                if is_mine:
                    self._last_own_messages.append(message)
                    if len(self._last_own_messages) > 5:
                        self._last_own_messages.pop(0)
                self._last_messages.append(message)
                if len(self._last_messages) > 10:
                    self._last_messages.pop(0)
                container = self.query_one("#messages", VerticalScroll)
                # Remove empty state if present
                empty = container.query("#empty-state")
                for e in empty:
                    e.remove()
                widget = MessageWidget(message, is_mine)
                await container.mount(widget)
                container.scroll_end(animate=False)

                if self._ws and not is_mine:
                    await self._ws.mark_read(conv_id)
                # Clear unread count locally so sidebar updates immediately
                if self._active_conv:
                    self._active_conv["unread_count"] = 0
            else:
                is_mine = (
                    self._my_user
                    and message.get("sender_id") == self._my_user["id"]
                )
                if not is_mine:
                    name = message.get("sender_username", "someone")
                    content = message.get("content", "")[:40]
                    mtype = message.get("message_type", "text")
                    if mtype == "code":
                        content = "sent code"
                    elif mtype == "file":
                        content = "sent a file"
                    if message.get("is_deleted"):
                        content = "deleted a message"
                    # Check if this conversation is muted
                    conv_data = next(
                        (c for c in self._conversations if c.get("id") == conv_id),
                        None,
                    )
                    is_muted = conv_data.get("is_muted", False) if conv_data else False
                    self.notify(f"{_escape_rich(name)}: {_escape_rich(content)}")
                    if not is_muted:
                        self.app.bell()

            self._refresh_sidebar_worker()

        elif msg_type == "message_edited":
            message = data.get("data", {})
            msg_id = message.get("id")
            if msg_id:
                try:
                    widget = self.query_one(f"#msg-{msg_id}", MessageWidget)
                    parent = widget.parent
                    is_mine = self._my_user and message.get("sender_id") == self._my_user["id"]
                    new_widget = MessageWidget(message, is_mine)
                    await parent.mount(new_widget, after=widget)
                    widget.remove()
                except Exception:
                    pass

        elif msg_type == "message_deleted":
            msg_data = data.get("data", {})
            msg_id = msg_data.get("id")
            if msg_id:
                try:
                    widget = self.query_one(f"#msg-{msg_id}", MessageWidget)
                    parent = widget.parent
                    deleted_msg = {
                        **msg_data,
                        "is_deleted": True,
                        "content": "",
                        "sender_username": msg_data.get("sender_username", ""),
                        "sender_display_name": "",
                        "message_type": "text",
                        "edited_at": None,
                        "created_at": "",
                    }
                    is_mine = self._my_user and msg_data.get("sender_id") == self._my_user["id"]
                    new_widget = MessageWidget(deleted_msg, is_mine, show_name=False)
                    await parent.mount(new_widget, after=widget)
                    widget.remove()
                except Exception:
                    pass

        elif msg_type == "typing":
            conv_id = data.get("conversation_id")
            username = data.get("username", "")
            if self._active_conv and self._active_conv.get("id") == conv_id:
                import time
                key = f"{conv_id}:{username}"
                self._typing_timers[key] = time.time() + 3
                self._update_typing_display()
                self.set_timer(3.5, self._clear_stale_typing)

        elif msg_type == "user_status":
            self._refresh_sidebar_worker()

        elif msg_type == "read_receipt":
            # In DMs, show "seen" on own messages
            recv_conv_id = data.get("conversation_id")
            recv_user_id = data.get("user_id")
            read_at = data.get("read_at")
            if (
                self._active_conv
                and self._active_conv.get("id") == recv_conv_id
                and self._active_conv.get("type") == "dm"
                and self._my_user
                and recv_user_id != self._my_user["id"]
                and read_at
            ):
                # Update the last own message to show "seen"
                container = self.query_one("#messages", VerticalScroll)
                for widget in reversed(list(container.query(MessageWidget))):
                    if widget._is_mine and not widget._msg.get("is_deleted"):
                        # Check if the read_at is >= this message's created_at
                        msg_time = widget._msg.get("created_at", "")
                        if msg_time and read_at >= msg_time:
                            widget._msg["_seen"] = True
                            try:
                                # Add seen indicator if not already there
                                existing = widget.query(".seen-indicator")
                                if not existing:
                                    await widget.mount(
                                        Static("[dim]\u2713 seen[/]", classes="seen-indicator")
                                    )
                            except Exception:
                                pass
                        break

        elif msg_type == "reaction_updated":
            rdata = data.get("data", {})
            msg_id = rdata.get("message_id")
            if msg_id:
                try:
                    widget = self.query_one(f"#msg-{msg_id}", MessageWidget)
                    widget._msg["reactions"] = rdata.get("reactions", [])
                    # Re-render the widget
                    parent = widget.parent
                    is_mine = self._my_user and widget._msg.get("sender_id") == self._my_user["id"]
                    new_widget = MessageWidget(widget._msg, is_mine, widget._show_name, widget._show_time)
                    await parent.mount(new_widget, after=widget)
                    widget.remove()
                except Exception:
                    pass

        elif msg_type == "conversation_updated":
            conv_id = data.get("conversation_id")
            username = data.get("username", "someone")
            self.notify(f"{_escape_rich(username)} left the conversation")
            if self._active_conv and self._active_conv.get("id") == conv_id:
                self._select_conversation(self._active_conv)
            self._refresh_sidebar_worker()

        elif msg_type == "conversation_deleted":
            conv_id = data.get("conversation_id")
            if self._active_conv and self._active_conv.get("id") == conv_id:
                self._active_conv = None
                self.query_one("#chat-header", Static).update("[dim]conversation deleted[/]")
                container = self.query_one("#messages", VerticalScroll)
                await container.remove_children()
                self.query_one("#msg-input", ChatInput).add_class("hidden")
            self._refresh_sidebar_worker()

    def _update_typing_display(self) -> None:
        import time
        now = time.time()
        active = []
        for key, expires in list(self._typing_timers.items()):
            if expires > now:
                username = key.split(":", 1)[1] if ":" in key else key
                active.append(username)
            else:
                del self._typing_timers[key]

        indicator = self.query_one("#typing-indicator", TypingIndicator)
        if active:
            if len(active) == 1:
                indicator.update(f"[dim italic]{_escape_rich(active[0])} is typing...[/]")
            else:
                names = ", ".join(_escape_rich(n) for n in active[:3])
                indicator.update(f"[dim italic]{names} are typing...[/]")
        else:
            indicator.update("")

    def _clear_stale_typing(self) -> None:
        self._update_typing_display()

    @work(exclusive=True, group="sidebar_refresh")
    async def _refresh_sidebar_worker(self) -> None:
        await self._refresh_conversations()
        if self._my_user:
            self._update_status_bar()

    # ── Conversation selection ─────────────────────────────────────

    @on(ListView.Selected, "#conv-list")
    def on_conv_selected(self, event: ListView.Selected) -> None:
        item = event.item
        if isinstance(item, ConversationItem):
            self._select_conversation(item.conv)

    @work(exclusive=True)
    async def _select_conversation(self, conv: dict) -> None:
        self._active_conv = conv
        self._typing_timers.clear()
        self._update_typing_display()
        self._editing_message_id = None
        self._last_own_messages = []
        self._last_messages = []
        self._reply_to = None
        api = self.app.api

        my_id = self._my_user["id"] if self._my_user else ""
        if conv.get("type") == "group":
            members = conv.get("members") or []
            names = [m.get("username", "?") for m in members if m["id"] != my_id]
            n = len(members)
            title = (
                f" [bold]{_escape_rich(conv.get('name', 'GROUP').upper())}[/]"
                f"  [dim]\u2502[/]  [dim]{n} members[/]"
                f"  [dim]\u2502[/]  [dim]{', '.join(_escape_rich(n.upper()) for n in names[:4])}[/]"
            )
        else:
            others = [m for m in (conv.get("members") or []) if m["id"] != my_id]
            if others:
                other = others[0]
                name = other.get("display_name") or other.get("username", "?")
                status = other.get("status", "offline")
                sl = STATUS_LABELS.get(status, STATUS_LABELS["offline"])
                last_seen_suffix = ""
                if status == "offline":
                    last_seen_at = other.get("last_seen_at")
                    if last_seen_at:
                        ls_dt = _parse_time(str(last_seen_at))
                        if ls_dt:
                            now = datetime.now(ls_dt.tzinfo)
                            diff = now - ls_dt
                            if diff.total_seconds() < 60:
                                last_seen_suffix = "  [dim]last seen just now[/]"
                            elif diff.total_seconds() < 3600:
                                mins = int(diff.total_seconds() // 60)
                                last_seen_suffix = f"  [dim]last seen {mins}m ago[/]"
                            elif diff.total_seconds() < 86400:
                                hrs = int(diff.total_seconds() // 3600)
                                last_seen_suffix = f"  [dim]last seen {hrs}h ago[/]"
                            else:
                                days = int(diff.total_seconds() // 86400)
                                last_seen_suffix = f"  [dim]last seen {days}d ago[/]"
                title = f" {sl}  [bold]{_escape_rich(name.upper())}[/]  [dim]@{_escape_rich(other.get('username', ''))}[/]{last_seen_suffix}"
            else:
                title = " [dim]CHAT[/]"

        self.query_one("#chat-header", Static).update(title)

        msg_input = self.query_one("#msg-input", ChatInput)
        msg_input.remove_class("hidden")
        msg_input._typing_conv_id = conv["id"]

        container = self.query_one("#messages", VerticalScroll)
        await container.remove_children()

        try:
            messages = await api.get_messages(conv["id"])
        except Exception:
            messages = []

        if not messages:
            await container.mount(EmptyState(">> no messages yet"))
            self._oldest_message_time = None
            self._has_more = False
        else:
            unread_count = conv.get("unread_count", 0)
            unread_idx = len(messages) - unread_count if unread_count > 0 else -1

            prev_sender = None
            prev_time = None

            for i, msg in enumerate(messages):
                dt = _parse_time(msg.get("created_at", ""))

                if dt and prev_time:
                    diff = dt - prev_time
                    if diff > timedelta(minutes=30):
                        await container.mount(TimeSeparator(_format_time(dt)))

                if i == unread_idx and unread_idx > 0:
                    await container.mount(UnreadMarker())

                sender = msg.get("sender_username", "")
                is_mine = self._my_user and msg.get("sender_id") == self._my_user["id"]
                show_name = sender != prev_sender
                show_time = show_name

                await container.mount(MessageWidget(msg, is_mine, show_name, show_time))

                if is_mine and not msg.get("is_deleted"):
                    self._last_own_messages.append(msg)

                prev_sender = sender
                prev_time = dt

        # Keep only the last 5 own messages
        self._last_own_messages = self._last_own_messages[-5:]

        if messages:
            self._oldest_message_time = messages[0].get("created_at")
            self._has_more = len(messages) >= 50
        else:
            self._oldest_message_time = None
            self._has_more = False

        container.scroll_end(animate=False)

        if self._ws:
            await self._ws.mark_read(conv["id"])
            # Clear unread count locally and refresh sidebar
            conv["unread_count"] = 0
            await self._render_conversation_list()

        msg_input.focus()

    # ── Send message ───────────────────────────────────────────────

    def on_chat_input_submitted(self, message: ChatInput.Submitted) -> None:
        content = message.value.strip()
        if not content or not self._active_conv:
            return

        # Editing mode: save edit instead of sending new message
        if self._editing_message_id:
            self._do_edit_message(self._editing_message_id, content)
            self._editing_message_id = None
            return

        if content.startswith("/"):
            self._handle_command(content)
            return

        self._send(content)

    def on_chat_input_typing(self, message: ChatInput.Typing) -> None:
        import time
        now = time.time()
        if now - self._last_typing_sent > 2:
            self._last_typing_sent = now
            self._send_typing(message.conversation_id)

    @work(exclusive=False)
    async def _send_typing(self, conversation_id: str) -> None:
        if self._ws:
            await self._ws.send_typing(conversation_id)

    def _handle_command(self, cmd: str) -> None:
        parts = cmd.split(None, 1)
        command = parts[0].lower()
        arg = parts[1] if len(parts) > 1 else ""

        if command == "/code":
            if arg:
                self._send(arg, message_type="code")
            else:
                self.notify("usage: /code [lang] <code>", severity="warning")

        elif command == "/status":
            if arg in ("available", "away", "dnd"):
                self._update_status(arg)
            else:
                self.notify("usage: /status available|away|dnd", severity="warning")

        elif command == "/clear":
            container = self.query_one("#messages", VerticalScroll)
            container.remove_children()

        elif command == "/profile":
            if arg.startswith("@"):
                self._show_user_profile(arg[1:])
            elif arg:
                self._show_user_profile(arg)
            else:
                self.action_view_profile()

        elif command == "/edit":
            if not self._last_own_messages:
                self.notify("no message to edit", severity="warning")
            elif arg:
                # /edit <text> — replace last message directly
                last = self._last_own_messages[-1]
                self._do_edit_message(last["id"], arg)
            else:
                # /edit with no args — enter edit mode
                last = self._last_own_messages[-1]
                self._editing_message_id = last["id"]
                msg_input = self.query_one("#msg-input", ChatInput)
                msg_input.load_text(last.get("content", ""))
                msg_input.focus()
                self.notify("[dim]editing — enter to save, esc to cancel[/]")

        elif command == "/delete":
            if not self._last_own_messages:
                self.notify("no message to delete", severity="warning")
            else:
                last = self._last_own_messages[-1]
                from termchat.screens.confirm import ConfirmScreen
                self.app.push_screen(
                    ConfirmScreen("delete your last message?"),
                    lambda confirmed: self._on_delete_msg_confirmed(confirmed, last["id"]),
                )

        elif command == "/search":
            if not arg:
                self.action_search_messages()
            else:
                self._inline_search(arg)

        elif command == "/rename":
            if not self._active_conv or self._active_conv.get("type") != "group":
                self.notify("only works in group chats", severity="warning")
            elif not arg:
                self.notify("usage: /rename <new name>", severity="warning")
            else:
                self._rename_group(arg)

        elif command == "/kick":
            if not self._active_conv or self._active_conv.get("type") != "group":
                self.notify("only works in group chats", severity="warning")
            elif not arg:
                self.notify("usage: /kick @username", severity="warning")
            else:
                username = arg.lstrip("@")
                self._kick_member(username)

        elif command == "/add":
            if not self._active_conv or self._active_conv.get("type") != "group":
                self.notify("only works in group chats", severity="warning")
            elif not arg:
                self.notify("usage: /add @username", severity="warning")
            else:
                username = arg.lstrip("@")
                self._add_member_by_username(username)

        elif command == "/reply":
            if not self._last_messages:
                self.notify("no message to reply to", severity="warning")
            else:
                last = self._last_messages[-1]
                self._reply_to = last
                sender = last.get("sender_username", "?")
                content = last.get("content", "")[:40].replace("\n", " ")
                self.notify(f"[dim]replying to @{sender}: {_escape_rich(content)}...[/]")

        elif command == "/react":
            if not arg:
                self.notify("usage: /react <emoji>", severity="warning")
            elif not self._last_messages:
                self.notify("no message to react to", severity="warning")
            else:
                last = self._last_messages[-1]
                self._do_react(last["id"], arg.strip())

        elif command == "/block":
            if not arg:
                self.notify("usage: /block @username", severity="warning")
            else:
                self._do_block(arg.lstrip("@"))

        elif command == "/unblock":
            if not arg:
                self.notify("usage: /unblock @username", severity="warning")
            else:
                self._do_unblock(arg.lstrip("@"))

        elif command == "/mute":
            if not self._active_conv:
                self.notify("no active chat", severity="warning")
            else:
                self._do_mute(True)

        elif command == "/unmute":
            if not self._active_conv:
                self.notify("no active chat", severity="warning")
            else:
                self._do_mute(False)

        elif command == "/file":
            if not arg:
                self.notify("usage: /file <path>", severity="warning")
            else:
                import os
                path = os.path.expanduser(arg.strip())
                if not os.path.isfile(path):
                    self.notify(f"file not found: {path}", severity="error")
                elif os.path.getsize(path) > 2 * 1024 * 1024:
                    self.notify("file too large (max 2MB)", severity="warning")
                else:
                    self._upload_file(path)

        elif command == "/notes":
            self._open_notes()

        elif command == "/logout":
            self.action_logout()

        elif command == "/download":
            if not self._last_messages:
                self.notify("no message to download from", severity="warning")
            else:
                last = self._last_messages[-1]
                file_url = last.get("file_url")
                if not file_url:
                    self.notify("last message has no file", severity="warning")
                else:
                    dest = arg.strip() if arg else ""
                    self._download_file(file_url, last.get("file_name", "file"), dest)

        elif command == "/password":
            from termchat.screens.password import PasswordScreen
            self.app.push_screen(PasswordScreen())

        elif command == "/help":
            from termchat.screens.help import HelpScreen
            self.app.push_screen(HelpScreen())

        else:
            self.notify(f"unknown: {command}", severity="warning")

    @work(exclusive=False)
    async def _send(self, content: str, message_type: str = "text") -> None:
        if not self._active_conv:
            return
        conv_id = self._active_conv["id"]
        reply_to_id = self._reply_to.get("id") if self._reply_to else None
        self._reply_to = None

        # Try WebSocket first, fall back to HTTP
        if self._ws and self._ws.is_connected:
            try:
                await self._ws.send_message(conv_id, content, message_type, reply_to_id)
                return
            except Exception:
                pass

        # HTTP fallback
        try:
            api = self.app.api
            msg = await api.send_message(conv_id, content, message_type, reply_to_id)
            # Manually render since we won't get WS echo
            if msg:
                container = self.query_one("#messages", VerticalScroll)
                empty = container.query("#empty-state")
                for e in empty:
                    e.remove()
                widget = MessageWidget(msg, is_mine=True)
                await container.mount(widget)
                container.scroll_end(animate=False)
        except Exception:
            self.notify("failed to send", severity="error")

    @work(exclusive=False)
    async def _download_file(self, file_url: str, file_name: str, dest: str) -> None:
        try:
            import os
            from termchat.config import SERVER_URL
            url = f"{SERVER_URL}{file_url}"
            resp = await self.app.api._client.get(url)
            resp.raise_for_status()
            if dest:
                save_path = os.path.expanduser(dest)
            else:
                save_path = os.path.join(os.path.expanduser("~/Downloads"), file_name)
            os.makedirs(os.path.dirname(save_path), exist_ok=True)
            with open(save_path, "wb") as f:
                f.write(resp.content)
            self.notify(f"saved to {save_path}")
        except Exception as exc:
            self.notify(f"download failed: {exc}", severity="error")

    @work(exclusive=False)
    async def _upload_file(self, path: str) -> None:
        if not self._active_conv:
            return
        try:
            import os
            self.notify(f"uploading {os.path.basename(path)}...")
            await self.app.api.upload_file(self._active_conv["id"], path)
        except Exception as exc:
            self.notify(f"upload failed: {exc}", severity="error")

    def _on_delete_msg_confirmed(self, confirmed: bool, message_id: str) -> None:
        if confirmed:
            self._do_delete_message(message_id)

    @work(exclusive=False)
    async def _do_edit_message(self, message_id: str, new_content: str) -> None:
        if not self._active_conv:
            return
        try:
            api = self.app.api
            await api.edit_message(self._active_conv["id"], message_id, new_content)
            # Update tracked message
            for msg in self._last_own_messages:
                if msg.get("id") == message_id:
                    msg["content"] = new_content
                    break
            self.notify("message edited")
        except Exception:
            self.notify("failed to edit", severity="error")

    @work(exclusive=False)
    async def _do_react(self, message_id: str, emoji: str) -> None:
        if not self._active_conv:
            return
        try:
            api = self.app.api
            result = await api.toggle_reaction(self._active_conv["id"], message_id, emoji)
            action = result.get("action", "toggled")
            self.notify(f"reaction {action}: {emoji}")
        except Exception:
            self.notify("failed to react", severity="error")

    @work(exclusive=False)
    async def _do_delete_message(self, message_id: str) -> None:
        if not self._active_conv:
            return
        try:
            api = self.app.api
            await api.delete_message(self._active_conv["id"], message_id)
            # Remove from tracked messages
            self._last_own_messages = [m for m in self._last_own_messages if m.get("id") != message_id]
            self.notify("message deleted")
        except Exception:
            self.notify("failed to delete", severity="error")

    @work(exclusive=False)
    async def _open_notes(self) -> None:
        """Open or create a 'notes to self' DM with yourself."""
        if not self._my_user:
            return
        my_id = self._my_user["id"]
        # Check if self-DM already exists
        for conv in self._conversations:
            if conv.get("type") == "dm":
                members = conv.get("members", [])
                if len(members) == 2 and all(m["id"] == my_id for m in members):
                    self._select_conversation(conv)
                    return
                if len(members) == 1 and members[0]["id"] == my_id:
                    self._select_conversation(conv)
                    return
        # Create self-DM
        try:
            api = self.app.api
            conv = await api.create_conversation("dm", [my_id])
            await self._refresh_conversations()
            self._select_conversation(conv)
        except Exception:
            self.notify("failed to create notes", severity="error")

    @work(exclusive=False)
    async def _do_block(self, username: str) -> None:
        try:
            await self.app.api.block_user(username)
            self.notify(f"blocked @{username}")
        except Exception as exc:
            msg = str(exc)
            if "400" in msg:
                self.notify("already blocked or cannot block", severity="warning")
            elif "404" in msg:
                self.notify(f"user '{username}' not found", severity="warning")
            else:
                self.notify("failed to block", severity="error")

    @work(exclusive=False)
    async def _do_unblock(self, username: str) -> None:
        try:
            await self.app.api.unblock_user(username)
            self.notify(f"unblocked @{username}")
        except Exception as exc:
            msg = str(exc)
            if "404" in msg:
                self.notify("user not blocked", severity="warning")
            else:
                self.notify("failed to unblock", severity="error")

    @work(exclusive=False)
    async def _do_mute(self, mute: bool) -> None:
        if not self._active_conv:
            return
        try:
            await self.app.api.mute_conversation(self._active_conv["id"], mute)
            self._active_conv["is_muted"] = mute
            self.notify("muted" if mute else "unmuted")
            await self._refresh_conversations()
        except Exception:
            self.notify("failed", severity="error")

    @work(exclusive=False)
    async def _update_status(self, status: str) -> None:
        try:
            api = self.app.api
            await api.set_status(status)
            if self._my_user:
                self._my_user["status"] = status
            self._update_status_bar()
            self.notify(f"status: {status}")
        except Exception:
            self.notify("failed to update status", severity="error")

    # ── Actions ────────────────────────────────────────────────────

    def action_focus_sidebar(self) -> None:
        if self._editing_message_id:
            self._editing_message_id = None
            self.query_one("#msg-input", ChatInput).clear()
            self.notify("edit cancelled")
            return
        # Deselect conversation — show welcome screen
        if self._active_conv:
            self._deselect_conversation()
        self.query_one("#conv-list", ListView).focus()

    @work(exclusive=False)
    async def _deselect_conversation(self) -> None:
        self._active_conv = None
        self._last_own_messages = []
        self._last_messages = []
        self._reply_to = None
        self._typing_timers.clear()
        self._update_typing_display()
        self.query_one("#chat-header", Static).update("")
        container = self.query_one("#messages", VerticalScroll)
        await container.remove_children()
        username = self._my_user.get("username", "?") if self._my_user else "?"
        await container.mount(
            EmptyState(
                f"[bold $primary]TERMCHAT[/]\n\n"
                f"[dim]signed in as[/] [bold]{_escape_rich(username)}[/]\n\n"
                f"[dim]ctrl+n[/] new chat    [dim]ctrl+g[/] new group\n"
                f"[dim]ctrl+o[/] settings    [dim]/help[/] commands"
            )
        )
        self.query_one("#msg-input", ChatInput).add_class("hidden")
        # Deselect in sidebar
        conv_list = self.query_one("#conv-list", ListView)
        conv_list.index = None

    def action_focus_next_panel(self) -> None:
        """Tab cycles: sidebar-search -> conv-list -> msg-input."""
        focused = self.app.focused
        search = self.query_one("#sidebar-search", Input)
        conv_list = self.query_one("#conv-list", ListView)
        msg_input = self.query_one("#msg-input", ChatInput)

        if focused is search:
            conv_list.focus()
        elif focused is conv_list or (focused and focused.parent is conv_list):
            if not msg_input.has_class("hidden"):
                msg_input.focus()
            else:
                search.focus()
        else:
            search.focus()

    def action_focus_prev_panel(self) -> None:
        """Shift+Tab cycles backwards."""
        focused = self.app.focused
        search = self.query_one("#sidebar-search", Input)
        conv_list = self.query_one("#conv-list", ListView)
        msg_input = self.query_one("#msg-input", ChatInput)

        if focused is msg_input:
            conv_list.focus()
        elif focused is conv_list or (focused and focused.parent is conv_list):
            search.focus()
        else:
            if not msg_input.has_class("hidden"):
                msg_input.focus()
            else:
                conv_list.focus()

    def action_open_settings(self) -> None:
        from termchat.screens.settings import SettingsScreen
        self.app.push_screen(SettingsScreen(self._my_user), self._on_settings_result)

    def _on_settings_result(self, result: str | None) -> None:
        if result == "logout":
            self._do_logout()
        elif result == "profile_updated":
            self._reload_my_user()

    @work(exclusive=False)
    async def _reload_my_user(self) -> None:
        try:
            self._my_user = await self.app.api.get_me()
            self._update_status_bar()
        except Exception:
            pass

    def action_new_chat(self) -> None:
        from termchat.screens.new_chat import NewChatScreen
        self.app.push_screen(NewChatScreen(), self._on_chat_created)

    def action_new_group(self) -> None:
        from termchat.screens.group import GroupScreen
        self.app.push_screen(GroupScreen(), self._on_chat_created)

    def action_delete_chat(self) -> None:
        if not self._active_conv:
            self.notify("no active chat", severity="warning")
            return
        from termchat.screens.confirm import ConfirmScreen
        conv = self._active_conv
        conv_type = conv.get("type", "dm")
        if conv_type == "dm":
            msg = "delete this conversation?\nmessages will be removed for both users"
        else:
            msg = "leave this group?"
        self.app.push_screen(ConfirmScreen(msg), self._on_delete_confirmed)

    def _on_delete_confirmed(self, confirmed: bool) -> None:
        if confirmed:
            self._delete_active_conv()

    @work(exclusive=True)
    async def _delete_active_conv(self) -> None:
        conv = self._active_conv
        if not conv:
            return
        try:
            api = self.app.api
            await api.delete_conversation(conv["id"])
        except Exception:
            self.notify("failed to delete", severity="error")
            return

        self._active_conv = None
        self.query_one("#chat-header", Static).update("select a conversation")
        container = self.query_one("#messages", VerticalScroll)
        await container.remove_children()
        self.query_one("#msg-input", ChatInput).add_class("hidden")
        await self._refresh_conversations()
        self.notify("chat deleted")

    def action_view_profile(self) -> None:
        if self._my_user:
            self._open_my_profile()

    @work(exclusive=False)
    async def _open_my_profile(self) -> None:
        from termchat.screens.profile import ProfileEditScreen, ProfileViewScreen
        try:
            user = await self.app.api.get_profile()
        except Exception:
            user = self._my_user

        def on_view_result(result: str | None) -> None:
            if result == "edit":
                self.app.push_screen(
                    ProfileEditScreen(user),
                    self._on_profile_edited,
                )

        self.app.push_screen(ProfileViewScreen(user, is_me=True), on_view_result)

    def _on_profile_edited(self, result: dict | None) -> None:
        if result:
            self._my_user = result

    @work(exclusive=False)
    async def _show_user_profile(self, username: str) -> None:
        from termchat.screens.profile import ProfileViewScreen
        try:
            api = self.app.api
            user = await api.get_user_profile(username)
            is_me = self._my_user and user.get("id") == self._my_user["id"]
            self.app.push_screen(ProfileViewScreen(user, is_me=is_me))
        except Exception:
            self.notify(f"user '{_escape_rich(username)}' not found", severity="warning")

    def action_set_status(self) -> None:
        from termchat.screens.status import StatusScreen
        self.app.push_screen(StatusScreen())

    def action_pick_theme(self) -> None:
        from termchat.screens.theme import ThemeScreen
        self.app.push_screen(ThemeScreen())

    def action_logout(self) -> None:
        self._do_logout()

    @work(exclusive=True)
    async def _do_logout(self) -> None:
        from termchat.auth import clear_tokens
        if self._ws:
            await self._ws.disconnect()
            self._ws = None
        clear_tokens()
        self.app.api._access_token = None
        self.app.api._refresh_token = None
        self.notify("logged out")

        from termchat.screens.login import LoginScreen
        self.app.switch_screen(LoginScreen())

    @work(exclusive=False)
    async def _on_chat_created(self, conv: dict | None) -> None:
        if conv:
            await self._refresh_conversations()
            self._select_conversation(conv)

    # ── Pagination ──────────────────────────────────────────────

    def action_load_more(self) -> None:
        if self._active_conv and self._has_more and not self._loading_more:
            self._load_older_messages()

    @work(exclusive=True, group="load_more")
    async def _load_older_messages(self) -> None:
        if not self._active_conv or not self._oldest_message_time or self._loading_more:
            return
        self._loading_more = True
        try:
            api = self.app.api
            messages = await api.get_messages(
                self._active_conv["id"],
                before=self._oldest_message_time,
                limit=50,
            )
            if not messages:
                self._has_more = False
                self.notify("no older messages")
                return

            container = self.query_one("#messages", VerticalScroll)

            widgets_to_add = []
            prev_sender = None
            prev_time = None
            for msg in messages:
                dt = _parse_time(msg.get("created_at", ""))
                if dt and prev_time:
                    diff = dt - prev_time
                    if diff > timedelta(minutes=30):
                        widgets_to_add.append(TimeSeparator(_format_time(dt)))

                sender = msg.get("sender_username", "")
                is_mine = self._my_user and msg.get("sender_id") == self._my_user["id"]
                show_name = sender != prev_sender
                widgets_to_add.append(MessageWidget(msg, is_mine, show_name, show_name))
                prev_sender = sender
                prev_time = dt

            first_child = container.children[0] if container.children else None
            for widget in reversed(widgets_to_add):
                if first_child:
                    await container.mount(widget, before=first_child)
                    first_child = widget
                else:
                    await container.mount(widget)

            self._oldest_message_time = messages[0].get("created_at")
        finally:
            self._loading_more = False

    # ── Search ────────────────────────────────────────────────

    def action_search_messages(self) -> None:
        if not self._active_conv:
            self.notify("select a conversation first", severity="warning")
            return
        from termchat.screens.search import SearchScreen
        self.app.push_screen(SearchScreen(self._active_conv["id"]))

    @work(exclusive=False)
    async def _inline_search(self, query: str) -> None:
        if not self._active_conv:
            return
        try:
            api = self.app.api
            results = await api.search_messages(self._active_conv["id"], query)
            if not results:
                self.notify("no results found")
                return

            container = self.query_one("#messages", VerticalScroll)
            await container.remove_children()
            await container.mount(
                Static(f"[dim]\u2500\u2500 search results for '{_escape_rich(query)}' ({len(results)}) \u2500\u2500[/]", classes="time-sep")
            )

            prev_sender = None
            for msg in results:
                sender = msg.get("sender_username", "")
                is_mine = self._my_user and msg.get("sender_id") == self._my_user["id"]
                show_name = sender != prev_sender
                await container.mount(MessageWidget(msg, is_mine, show_name, show_name))
                prev_sender = sender

            container.scroll_end(animate=False)
        except Exception:
            self.notify("search failed", severity="error")

    # ── Group management ──────────────────────────────────────

    @work(exclusive=False)
    async def _rename_group(self, name: str) -> None:
        try:
            api = self.app.api
            await api.rename_conversation(self._active_conv["id"], name)
            self.notify(f"renamed to: {name}")
            await self._refresh_conversations()
            self.query_one("#chat-header", Static).update(f"# {_escape_rich(name)}")
        except Exception:
            self.notify("failed to rename", severity="error")

    @work(exclusive=False)
    async def _kick_member(self, username: str) -> None:
        try:
            api = self.app.api
            members = self._active_conv.get("members", [])
            target = next((m for m in members if m.get("username") == username), None)
            if not target:
                self.notify(f"'{username}' is not in this group", severity="warning")
                return
            await api.remove_member(self._active_conv["id"], target["id"])
            self.notify(f"removed {username}")
            await self._refresh_conversations()
        except Exception as exc:
            msg = str(exc)
            if "403" in msg:
                self.notify("only the group creator can remove members", severity="error")
            else:
                self.notify("failed to remove member", severity="error")

    @work(exclusive=False)
    async def _add_member_by_username(self, username: str) -> None:
        try:
            api = self.app.api
            users = await api.search_users(username)
            exact = next((u for u in users if u.get("username") == username), None)
            if not exact:
                self.notify(f"user '{username}' not found", severity="warning")
                return
            await api.add_members(self._active_conv["id"], [exact["id"]])
            self.notify(f"added {username}")
            await self._refresh_conversations()
        except Exception:
            self.notify("failed to add member", severity="error")

    def action_quit(self) -> None:
        self.app.exit()
