"""Interactive CLI client for encrypted chat (X3DH + Double Ratchet)."""

import asyncio
import getpass
import logging
import os
import re

from chat_core import ChatClient, IdentityKeyChanged


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


async def prompt(text: str) -> str:
    """Non-blocking terminal input."""
    return await asyncio.get_event_loop().run_in_executor(None, lambda: input(text).strip())


async def prompt_password(text: str = "Password: ") -> str:
    """Non-blocking hidden password input (M3 fix)."""
    return await asyncio.get_event_loop().run_in_executor(None, lambda: getpass.getpass(text))


# M3 fix: strip terminal control/escape sequences from untrusted text
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]")


def _sanitize_text(s) -> str:
    """Remove control characters and ANSI escape sequences."""
    if not isinstance(s, str):
        s = str(s) if s is not None else ""
    return _CONTROL_RE.sub("", s)


def _safe_filename(name: str) -> str:
    """Sanitize remote filename: basename only, no path traversal, no NUL."""
    name = os.path.basename(name)
    name = name.replace("\x00", "")
    if not name or name.startswith("."):
        name = "download"
    return name


def _human_size(n: int) -> str:
    if n >= 1024 * 1024:
        return f"{n / (1024*1024):.1f} MB"
    if n >= 1024:
        return f"{n / 1024:.0f} KB"
    return f"{n} B"


async def _select_conversation(client: ChatClient, label: str = "Select conversation") -> tuple[dict | None, list[dict]]:
    """List conversations and let user pick one. Returns (conv, convs) or (None, [])."""
    convs = await client.list_conversations()
    if not convs:
        print("[*] No conversations.")
        return None, []

    def conv_label(c):
        if c.get("name"):
            return _sanitize_text(c["name"])
        others = [_sanitize_text(m.get("username") or m.get("email") or "?") for m in c["members"] if m.get("email") != client.email]
        return ", ".join(others) if others else _sanitize_text(client.username)

    print()
    for i, c in enumerate(convs):
        print(f"  {i+1}) {conv_label(c)}")
    choice = await prompt(f"{label}: ")
    try:
        idx = int(choice) - 1
        if not (0 <= idx < len(convs)):
            print("[!] Invalid selection.")
            return None, convs
    except ValueError:
        print("[!] Invalid selection.")
        return None, convs
    return convs[idx], convs


async def interactive_menu(client: ChatClient):
    """Interactive terminal menu."""
    while True:
        print("\n--- Encrypted Chat ---")
        print("1)  Send direct message")
        print("2)  Send to conversation")
        print("3)  Read messages")
        print("4)  Create group conversation")
        print("5)  Add member to group")
        print("6)  Send image")
        print("7)  Send file")
        print("8)  Invitations")
        print("9)  Leave group")
        print("10) Rename group")
        print("11) Delete conversation")
        print("12) Search messages")
        print("13) My profile")
        print("14) View user profile")
        print("15) Manage devices")
        print("16) React to message")
        print("17) Pin/Unpin message")
        print("18) View pinned messages")
        print("19) Forward message")
        print("20) Verify contact")
        print("21) Show my fingerprint")
        print("22) Change password")
        print("23) Change username")
        print("q)  Quit")

        choice = await prompt("> ")

        if choice == "1":
            email = await prompt("To (email): ")
            if not email:
                continue
            text = await prompt("Message: ")
            if not text:
                continue
            conv_id, msg = await client.find_or_create_conversation(email)
            if not conv_id:
                print(f"[!] {msg}")
                continue
            convs = await client.list_conversations()
            members = []
            for c in convs:
                if c["conversation_id"] == conv_id:
                    members = c["members"]
                    break
            try:
                ok, result = await client.send_message(conv_id, text, members)
            except IdentityKeyChanged as ikc:
                print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
                continue
            print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")

        elif choice == "2":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            text = await prompt("Message: ")
            if not text:
                continue
            try:
                ok, result = await client.send_message(conv["conversation_id"], text, conv["members"])
            except IdentityKeyChanged as ikc:
                print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
                continue
            print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")

        elif choice == "3":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            messages = await client.get_messages(conv["conversation_id"])
            if not messages:
                print("[*] No messages.")
                continue
            _print_messages(messages, client, conv)

            action = await prompt("\nAction (r=reply, d=delete, dl=download file, empty=back): ")
            if not action:
                continue
            if action.lower().startswith("dl"):
                await _download_file_action(client, messages)
                continue
            if action.lower().startswith("d"):
                await _delete_message_action(client, messages)
                continue
            if action.lower().startswith("r"):
                reply_choice = await prompt("Reply to message #: ")
            else:
                reply_choice = action
            try:
                reply_idx = int(reply_choice) - 1
                if not (0 <= reply_idx < len(messages)):
                    print("[!] Invalid message number.")
                    continue
            except ValueError:
                print("[!] Invalid number.")
                continue
            reply_to_id = messages[reply_idx]["message_id"]
            text = await prompt("Message: ")
            if not text:
                continue
            try:
                ok, result = await client.send_message(conv["conversation_id"], text, conv["members"], reply_to=reply_to_id)
            except IdentityKeyChanged as ikc:
                print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
                continue
            print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")

        elif choice == "4":
            name = await prompt("Group name (empty for none): ")
            members_input = await prompt("Member emails (comma-separated): ")
            members = [m.strip() for m in members_input.split(",") if m.strip()]
            if not members:
                continue
            conv_id, msg = await client.create_conversation(members, name=name.strip() or None)
            if conv_id:
                print(f"[+] Group created with: {', '.join(members)}")
            else:
                print(f"[!] {msg}")

        elif choice == "5":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            email = await prompt("Email to add: ")
            ok, msg = await client.add_member(conv["conversation_id"], email)
            print(f"[{'+'if ok else '!'}] {msg or 'Invitation sent.'}")

        elif choice == "6":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            image_path = await prompt("Image path: ")
            if not image_path:
                continue
            try:
                ok, msg = await client.send_image(conv["conversation_id"], image_path, conv["members"])
            except IdentityKeyChanged as ikc:
                print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
                continue
            print(f"[{'+'if ok else '!'}] {msg}")

        elif choice == "7":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            file_path = await prompt("File path: ")
            if not file_path:
                continue
            if not os.path.isfile(file_path):
                print("[!] File not found.")
                continue
            try:
                ok, msg = await client.send_file(conv["conversation_id"], file_path, conv["members"])
            except IdentityKeyChanged as ikc:
                print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
                continue
            print(f"[{'+'if ok else '!'}] {msg}")

        elif choice == "8":
            await _invitations_menu(client)

        elif choice == "9":
            conv, _ = await _select_conversation(client, "Select group to leave")
            if not conv:
                continue
            confirm = await prompt(f"Leave '{conv.get('name', 'this conversation')}'? (y/n): ")
            if confirm.lower() != "y":
                continue
            ok, msg = await client.leave_group(conv["conversation_id"])
            print(f"[{'+'if ok else '!'}] {msg}")

        elif choice == "10":
            conv, _ = await _select_conversation(client, "Select group to rename")
            if not conv:
                continue
            name = await prompt("New name: ")
            if not name:
                continue
            ok, msg = await client.rename_conversation(conv["conversation_id"], name.strip())
            print(f"[{'+'if ok else '!'}] {msg}")

        elif choice == "11":
            conv, _ = await _select_conversation(client, "Select conversation to delete")
            if not conv:
                continue
            confirm = await prompt("Delete this conversation? This cannot be undone. (y/n): ")
            if confirm.lower() != "y":
                continue
            ok, msg = await client.delete_conversation(conv["conversation_id"])
            print(f"[{'+'if ok else '!'}] {msg}")

        elif choice == "12":
            conv, _ = await _select_conversation(client, "Select conversation to search")
            if not conv:
                continue
            query = await prompt("Search query: ")
            if not query:
                continue
            # First ensure we have messages cached by fetching them
            await client.get_messages(conv["conversation_id"])
            results = client.search_messages(conv["conversation_id"], query)
            if not results:
                print("[*] No matches found.")
                continue
            print(f"\n[*] {len(results)} match(es):")
            for r in results:
                sender = _sanitize_text(r.get("sender", "???"))
                text = _sanitize_text(r.get("text", ""))
                ts = r.get("created_at", "")[:16]
                # Highlight match in text
                idx = text.lower().find(query.lower())
                if idx >= 0:
                    text = text[:idx] + "\033[33m" + text[idx:idx+len(query)] + "\033[0m" + text[idx+len(query):]
                print(f"  [{ts}] {sender}: {text}")

        elif choice == "13":
            await _my_profile_menu(client)

        elif choice == "14":
            email = await prompt("User email: ")
            if not email:
                continue
            # Need to find user_id from email — try via conversation members
            user_id = None
            convs = await client.list_conversations()
            for c in convs:
                for m in c.get("members", []):
                    if m.get("email") == email:
                        user_id = m.get("user_id") or m.get("id")
                        break
                if user_id:
                    break
            if not user_id:
                print("[!] User not found in your conversations.")
                continue
            profile = await client.get_profile(user_id)
            if not profile:
                print("[!] Could not load profile.")
                continue
            _print_profile(profile)

        elif choice == "15":
            await _devices_menu(client)

        elif choice == "16":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            messages = await client.get_messages(conv["conversation_id"])
            if not messages:
                print("[*] No messages.")
                continue
            _print_messages(messages, client, conv)
            msg_choice = await prompt("React to message #: ")
            try:
                msg_idx = int(msg_choice) - 1
                if not (0 <= msg_idx < len(messages)):
                    print("[!] Invalid message number.")
                    continue
            except ValueError:
                print("[!] Invalid number.")
                continue
            print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown")
            reaction = await prompt("Reaction: ").strip().lower()
            if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"):
                print("[!] Invalid reaction.")
                continue
            ok, msg = await client.react_message(messages[msg_idx]["message_id"], reaction, "add")
            print(f"[{'+'if ok else '!'}] {msg}")

        elif choice == "17":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            messages = await client.get_messages(conv["conversation_id"])
            if not messages:
                print("[*] No messages.")
                continue
            _print_messages(messages, client, conv)
            msg_choice = await prompt("Pin/Unpin message #: ")
            try:
                msg_idx = int(msg_choice) - 1
                if not (0 <= msg_idx < len(messages)):
                    print("[!] Invalid message number.")
                    continue
            except ValueError:
                print("[!] Invalid number.")
                continue
            m = messages[msg_idx]
            action = "unpin" if m.get("pinned_at") else "pin"
            ok, msg = await client.pin_message(m["message_id"], conv["conversation_id"], action)
            print(f"[{'+'if ok else '!'}] {action.capitalize()}: {msg}")

        elif choice == "18":
            conv, _ = await _select_conversation(client)
            if not conv:
                continue
            pinned = await client.get_pinned_messages(conv["conversation_id"])
            if not pinned:
                print("[*] No pinned messages.")
                continue
            print(f"\n[*] {len(pinned)} pinned message(s):")
            for p in pinned:
                print(f"  {p.get('message_id', '?')[:8]}... pinned at {p.get('pinned_at', '?')}")

        elif choice == "19":
            conv, _ = await _select_conversation(client, "Select source conversation")
            if not conv:
                continue
            messages = await client.get_messages(conv["conversation_id"])
            if not messages:
                print("[*] No messages.")
                continue
            _print_messages(messages, client, conv)
            msg_choice = await prompt("Forward message #: ")
            try:
                msg_idx = int(msg_choice) - 1
                if not (0 <= msg_idx < len(messages)):
                    print("[!] Invalid message number.")
                    continue
            except ValueError:
                print("[!] Invalid number.")
                continue
            target_conv, _ = await _select_conversation(client, "Select target conversation")
            if not target_conv:
                continue
            fwd_msg = messages[msg_idx]
            fwd_msg["conversation_id"] = conv["conversation_id"]
            try:
                ok, result = await client.forward_message(
                    target_conv["conversation_id"], fwd_msg, target_conv["members"]
                )
            except IdentityKeyChanged as ikc:
                print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
                continue
            print(f"[{'+'if ok else '!'}] {'Forwarded.' if ok else result}")

        elif choice == "20":
            # Verify contact — show safety number for a DM conversation
            conv, _ = await _select_conversation(client, "Select DM to verify")
            if not conv:
                continue
            # Find peer user_id
            peer_uid = ""
            peer_name = ""
            for m in conv.get("members", []):
                if m.get("email") != client.email:
                    peer_uid = m.get("user_id") or m.get("id") or ""
                    peer_name = _sanitize_text(m.get("username") or m.get("email") or "?")
                    break
            if not peer_uid:
                print("[!] Could not identify peer user.")
                continue
            # Ensure we have their identity key in cache
            info = await client._get_user_info(user_id=peer_uid)
            if not info or not info.get("identity_key_bytes"):
                print("[!] Could not retrieve identity key for this user.")
                continue
            status = client.get_verification_status(peer_uid)
            print(f"\n--- Verification: {peer_name} ---")
            print(f"Status: {status.upper()}")
            safety = client.get_safety_number(peer_uid)
            if safety:
                print(f"\nSafety Number:\n{safety}")
            fp = client.get_peer_fingerprint(peer_uid)
            if fp:
                print(f"\nTheir Fingerprint:\n{fp}")
            my_fp = client.get_my_fingerprint()
            if my_fp:
                print(f"\nYour Fingerprint:\n{my_fp}")
            if status != "verified":
                action = await prompt("\nMark as verified? (y/n): ")
                if action.lower() == "y":
                    client.verify_contact(peer_uid, info["identity_key_bytes"],
                                          method="safety_number")
                    print("[+] Contact marked as verified.")
            else:
                action = await prompt("\nRemove verification? (y/n): ")
                if action.lower() == "y":
                    client.unverify_contact(peer_uid)
                    print("[+] Verification removed.")

        elif choice == "21":
            # Show own fingerprint
            fp = client.get_my_fingerprint()
            if fp:
                print(f"\n--- Your Fingerprint ---\n{fp}")
            else:
                print("[!] Not logged in or identity key not available.")

        elif choice == "22":
            # Change password
            old_pw = getpass.getpass("Current password: ")
            new_pw = getpass.getpass("New password: ")
            confirm_pw = getpass.getpass("Confirm new password: ")
            if new_pw != confirm_pw:
                print("[!] Passwords do not match.")
            elif not new_pw:
                print("[!] Password cannot be empty.")
            else:
                ok, msg = client.change_password(old_pw, new_pw)
                if ok:
                    print(f"[+] {msg}")
                else:
                    print(f"[!] {msg}")

        elif choice == "23":
            new_un = await prompt("New username: ")
            new_un = new_un.strip() if new_un else ""
            if not new_un:
                print("[!] Username cannot be empty.")
            else:
                ok, msg = await client.change_username(new_un)
                if ok:
                    print(f"[+] {msg}")
                else:
                    print(f"[!] {msg}")

        elif choice in ("q", "Q", "quit", "exit"):
            print("[*] Bye.")
            break


def _print_messages(messages, client, conv):
    """Print messages to terminal."""
    print()
    for i, m in enumerate(messages):
        if m.get("deleted"):
            print(f"  #{i+1} [Message deleted]")
            continue
        reply_info = ""
        if m.get("reply_to"):
            for j, orig in enumerate(messages):
                if orig["message_id"] == m["reply_to"]:
                    reply_info = f" (reply to #{j+1})"
                    break
            else:
                reply_info = " (reply to older message)"
        image_info = ""
        if m.get("image"):
            img = m["image"]
            image_info = f" [Image: {_sanitize_text(img.get('filename', '?'))} ({_human_size(img.get('size', 0))})]"
        file_info = ""
        if m.get("file"):
            fi = m["file"]
            file_info = f" [File: {_sanitize_text(fi.get('filename', '?'))} ({_human_size(fi.get('size', 0))})]"
        read_info = ""
        if m.get("sender") == client.username:
            read_by = m.get("read_by", [])
            delivered_to = m.get("delivered_to", [])
            member_map = {}
            for mem in conv.get("members", []):
                uid = mem.get("user_id") or mem.get("id", "")
                if uid:
                    member_map[uid] = _sanitize_text(mem.get("username") or mem.get("email") or "?")
            my_uid = client.session.get("user_id", "") if client.session else ""
            others_read = [r for r in read_by if r.get("user_id") != my_uid]
            others_delivered = [d for d in delivered_to if d.get("user_id") != my_uid]
            if others_read:
                names = ", ".join(member_map.get(r["user_id"], r["user_id"][:8]) for r in others_read)
                read_info = f" [\u2713\u2713 Read by {names}]"
            elif others_delivered:
                read_info = " [\u2713\u2713 Delivered]"
            else:
                read_info = " [\u2713 Sent]"
        pin_info = ""
        if m.get("pinned_at"):
            pin_info = " \U0001f4cc"
        reaction_info = ""
        reactions = m.get("reactions", [])
        if reactions:
            grouped = {}
            for r in reactions:
                grouped.setdefault(r["reaction"], 0)
                grouped[r["reaction"]] += 1
            _REMOJI = {"thumbsup": "\U0001f44d", "heart": "\u2764\ufe0f", "laugh": "\U0001f602",
                       "surprised": "\U0001f62e", "sad": "\U0001f622", "thumbsdown": "\U0001f44e"}
            parts = [f"{_REMOJI.get(k, k)}{v}" for k, v in grouped.items()]
            reaction_info = " [" + " ".join(parts) + "]"
        fwd_info = ""
        if m.get("forwarded_from"):
            fwd_sender = _sanitize_text(m["forwarded_from"].get("sender", "?"))
            fwd_info = f" (fwd from {fwd_sender})"
        text = _sanitize_text(m.get("text", ""))
        sender = _sanitize_text(m.get("sender", "?"))
        print(f"  #{i+1} {sender}: {text}{image_info}{file_info}{reply_info}{read_info}{pin_info}{reaction_info}{fwd_info}")


async def _delete_message_action(client, messages):
    del_choice = await prompt("Delete message #: ")
    try:
        del_idx = int(del_choice) - 1
        if not (0 <= del_idx < len(messages)):
            print("[!] Invalid message number.")
            return
    except ValueError:
        print("[!] Invalid number.")
        return
    ok, msg = await client.delete_message(messages[del_idx]["message_id"])
    print(f"[{'+'if ok else '!'}] {msg}")


async def _download_file_action(client, messages):
    dl_choice = await prompt("Download from message #: ")
    try:
        dl_idx = int(dl_choice) - 1
        if not (0 <= dl_idx < len(messages)):
            print("[!] Invalid message number.")
            return
    except ValueError:
        print("[!] Invalid number.")
        return
    m = messages[dl_idx]
    file_info = m.get("file") or m.get("image")
    if not file_info:
        print("[!] No file/image in this message.")
        return
    filename = _safe_filename(file_info.get("filename", "download"))
    save_path = await prompt(f"Save as [{filename}]: ")
    if not save_path:
        save_path = filename
    data = await client.download_file(file_info["file_id"], file_info)
    if data:
        with open(save_path, "wb") as f:
            f.write(data)
        print(f"[+] Saved to {save_path} ({_human_size(len(data))})")
    else:
        print("[!] Download failed.")


async def _invitations_menu(client):
    invitations = await client.list_invitations()
    if not invitations:
        print("[*] No pending invitations.")
        return
    print("\nPending invitations:")
    for i, inv in enumerate(invitations):
        inv_name = _sanitize_text(inv.get("conversation_name") or inv.get("conversation_id", "")[:8])
        invited_by = _sanitize_text(inv.get("invited_by_username") or inv.get("invited_by", "")[:8])
        print(f"  {i+1}) {inv_name} (invited by {invited_by})")
    choice = await prompt("Select invitation (or empty to go back): ")
    if not choice:
        return
    try:
        idx = int(choice) - 1
        if not (0 <= idx < len(invitations)):
            print("[!] Invalid selection.")
            return
    except ValueError:
        print("[!] Invalid selection.")
        return
    inv = invitations[idx]
    action = await prompt("(a)ccept or (d)ecline? ")
    if action.lower().startswith("a"):
        ok, msg = await client.accept_invitation(inv["conversation_id"])
        print(f"[{'+'if ok else '!'}] {msg}")
    elif action.lower().startswith("d"):
        ok, msg = await client.decline_invitation(inv["conversation_id"])
        print(f"[{'+'if ok else '!'}] {msg}")


def _print_profile(profile):
    print(f"\n  Username:  {_sanitize_text(profile.get('username', '?'))}")
    print(f"  Email:     {_sanitize_text(profile.get('email', '?'))}")
    phone = profile.get("phone")
    if phone:
        print(f"  Phone:     {_sanitize_text(phone)}")
    location = profile.get("location")
    if location:
        print(f"  Location:  {_sanitize_text(location)}")
    has_avatar = profile.get("avatar_file")
    print(f"  Avatar:    {'Yes' if has_avatar else 'No'}")


async def _my_profile_menu(client):
    profile = await client.get_profile()
    if not profile:
        print("[!] Could not load profile.")
        return
    print("\n--- My Profile ---")
    _print_profile(profile)
    print(f"  Phone visible:    {profile.get('phone_visible', False)}")
    print(f"  Email visible:    {profile.get('email_visible', False)}")
    print(f"  Location visible: {profile.get('location_visible', False)}")

    action = await prompt("\n(e)dit, (a)vatar upload, or empty to go back: ")
    if not action:
        return
    if action.lower().startswith("e"):
        print("[*] Leave fields empty to keep current value.")
        phone = await prompt(f"Phone [{profile.get('phone', '')}]: ")
        location = await prompt(f"Location [{profile.get('location', '')}]: ")
        phone_vis = await prompt(f"Phone visible [{profile.get('phone_visible', False)}] (y/n): ")
        email_vis = await prompt(f"Email visible [{profile.get('email_visible', False)}] (y/n): ")
        loc_vis = await prompt(f"Location visible [{profile.get('location_visible', False)}] (y/n): ")

        fields = {}
        if phone:
            fields["phone"] = phone
        if location:
            fields["location"] = location
        if phone_vis.lower() in ("y", "n"):
            fields["phone_visible"] = phone_vis.lower() == "y"
        if email_vis.lower() in ("y", "n"):
            fields["email_visible"] = email_vis.lower() == "y"
        if loc_vis.lower() in ("y", "n"):
            fields["location_visible"] = loc_vis.lower() == "y"
        if fields:
            ok, msg = await client.update_profile(**fields)
            print(f"[{'+'if ok else '!'}] {msg}")
        else:
            print("[*] No changes.")
    elif action.lower().startswith("a"):
        path = await prompt("Avatar image path: ")
        if not path or not os.path.isfile(path):
            print("[!] File not found.")
            return
        data = open(path, "rb").read()
        ok, msg = await client.update_avatar(data)
        print(f"[{'+'if ok else '!'}] {msg}")


async def _devices_menu(client):
    resp = await client.send_and_recv("list_devices")
    if resp.get("status") != "ok":
        print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
        return
    devices = resp["data"].get("devices", [])
    if not devices:
        print("[*] No devices found.")
        return
    current_device_id = client.device_id
    print("\nYour devices:")
    for i, d in enumerate(devices):
        name = _sanitize_text(d.get("device_name") or "Unnamed")
        did = d.get("device_id", "?")
        last_seen = _sanitize_text(d.get("last_seen_at", "?"))
        current = " (this device)" if did == current_device_id else ""
        print(f"  {i+1}) {name} — {did[:8]}... — last seen: {last_seen}{current}")
    action = await prompt("\n(r)emove a device, or empty to go back: ")
    if not action or not action.lower().startswith("r"):
        return
    choice = await prompt("Remove device #: ")
    try:
        idx = int(choice) - 1
        if not (0 <= idx < len(devices)):
            print("[!] Invalid selection.")
            return
    except ValueError:
        print("[!] Invalid selection.")
        return
    d = devices[idx]
    if d.get("device_id") == current_device_id:
        print("[!] Cannot remove current device.")
        return
    resp = await client.send_and_recv("remove_device", device_id=d["device_id"])
    if resp.get("status") == "ok":
        print("[+] Device removed.")
    else:
        print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")


async def notification_printer(client: ChatClient):
    """Print real-time notifications with sender name."""
    while True:
        notif = await client._notification_queue.get()
        notif_type = notif.get("type", "")
        data = notif.get("data", {})
        if notif_type == "messages_read":
            continue  # Silent - read receipts shown when reading messages
        if notif_type == "session_reset":
            from_uid = data.get("from_user_id", "")[:8]
            client.handle_session_reset_notification(
                data.get("from_user_id", ""),
                data.get("from_device_id") or None,
            )
            print(f"\n[*] Session with {from_uid}... was reset. New session will be created on next message.")
            continue
        if notif_type == "group_invitation":
            inv_name = _sanitize_text(data.get("conversation_name", "?"))
            invited_by = _sanitize_text(data.get("invited_by_username", "?"))
            print(f"\n[*] New invitation to '{inv_name}' from {invited_by}. Use option 8 to accept/decline.")
            continue
        if notif_type in ("conversation_created", "member_added", "member_removed", "conversation_renamed"):
            print(f"\n[*] Conversation updated ({notif_type}).")
            continue
        if notif_type == "message_reacted":
            username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
            reaction = _sanitize_text(data.get("reaction", "?"))
            action = data.get("action", "add")
            print(f"\n[*] {username} {'added' if action == 'add' else 'removed'} reaction '{reaction}'")
            continue
        if notif_type in ("message_pinned", "message_unpinned"):
            username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
            act = "pinned" if notif_type == "message_pinned" else "unpinned"
            print(f"\n[*] {username} {act} a message")
            continue
        if notif_type in ("user_online", "user_offline", "online_users"):
            continue  # Silent for CLI
        payload = client.decrypt_notification(data)
        if payload:
            print(f"\n[*] New message from {_sanitize_text(payload['sender'])} in conversation {data.get('conversation_id', '?')[:8]}...")
        # None = control message (sender key distribution), skip silently


async def main():
    setup_logging()
    client = ChatClient()
    await client.connect()

    client._listener_task = asyncio.create_task(client._background_listener())
    notif_task = asyncio.create_task(notification_printer(client))

    print("=== Encrypted Chat Client ===")
    print("1) Register")
    print("2) Login")
    print("3) Link new device (this device)")
    print("4) Authorize new device (from this device)")
    print("5) Rotate keys (revoke other devices)")
    choice = await prompt("> ")

    if choice == "1":
        username = await prompt("Username (display): ")
        email = await prompt("Email: ")
        password = await prompt_password("Password (for private key): ")
        if not email or not password:
            print("[!] Email and password required.")
            await client.close()
            return
        ok, code_or_msg = await client.register(username, password, email=email)
        if not ok:
            print(f"[!] {code_or_msg}")
            await client.close()
            return
        print(f"[*] Registration code: {code_or_msg}")
        code = await prompt("Enter code: ")
        ok2, msg2 = await client.confirm_registration(email, username, code)
        print(f"[{'+'if ok2 else '!'}] {msg2}")
        if ok2:
            ok3, msg3 = await client.login(email, password)
            print(f"[{'+'if ok3 else '!'}] {msg3}")
    elif choice == "2":
        email = await prompt("Email: ")
        password = await prompt_password("Password (for private key): ")
        ok, msg = await client.login(email, password)
        print(f"[{'+'if ok else '!'}] {msg}")
    elif choice == "3":
        email = await prompt("Email: ")
        password = await prompt_password("Password (for private key): ")
        if not password:
            print("[!] Password required.")
            await client.close()
            return
        ok, code_or_msg = await client.pairing_start(email)
        if not ok:
            print(f"[!] {code_or_msg}")
            await client.close()
            return
        code = code_or_msg
        print(f"[*] Pairing code: {code}")
        print("[*] Approve this code on an already-logged-in device.")
        ok2, msg2 = await client.pairing_wait(code, email, password)
        if not ok2:
            print(f"[!] {msg2}")
            await client.close()
            return
        print(f"[+] {msg2}")
        ok3, msg3 = await client.login(email, password)
        print(f"[{'+'if ok3 else '!'}] {msg3}")
    elif choice == "4":
        email = await prompt("Email: ")
        password = await prompt_password("Password (for private key): ")
        ok, msg = await client.login(email, password)
        print(f"[{'+'if ok else '!'}] {msg}")
        if not ok:
            await client.close()
            return
        code = await prompt("Pairing code: ")
        ok2, msg2 = await client.authorize_device(code)
        print(f"[{'+'if ok2 else '!'}] {msg2}")
    elif choice == "5":
        email = await prompt("Email: ")
        password = await prompt_password("Password (for private key): ")
        ok, msg = await client.login(email, password)
        print(f"[{'+'if ok else '!'}] {msg}")
        if not ok:
            await client.close()
            return
        confirm = await prompt("This will revoke other devices. Type 'YES' to continue: ")
        if confirm != "YES":
            print("[*] Cancelled.")
            await client.close()
            return
        ok2, msg2 = await client.rotate_keys(client.username, password)
        print(f"[{'+'if ok2 else '!'}] {msg2}")
    else:
        print("[!] Invalid choice.")
        await client.close()
        return

    if client.session:
        await interactive_menu(client)

    notif_task.cancel()
    await client.close()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n[*] Bye.")
