"""Interactive CLI for the /api/v2/agent/ws endpoint."""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import signal
import subprocess
import sys
from importlib.metadata import PackageNotFoundError, version
from typing import Any, Optional
from uuid import uuid4

from dotenv import load_dotenv
from rich.console import Console
from rich.prompt import Prompt

from cli.file_api_client import LocalAttachment, upload_attachments
from cli.oauth import (
    clear_saved_tokens,
    get_valid_token,
    resolve_frontend_base_url,
    resolve_oauth_base_url,
    resolve_oauth_client_id,
    run_login_flow,
)
from cli.renderer import AskPromptRequest, StreamRenderer
from cli.ws_client import AgentWsV3Client, WsClientConfig


DEFAULT_WS_URL_STAGING = "wss://core-agent-loop-dev-950663559455.us-central1.run.app/api/v2/agent/ws"
DEFAULT_WS_URL_PROD = "wss://core-agent-loop-950663559455.us-central1.run.app/api/v2/agent/ws"
DEFAULT_WS_URL = DEFAULT_WS_URL_PROD
DEFAULT_FILE_API_URL_STAGING = "https://core-agent-loop-dev-950663559455.us-central1.run.app"
DEFAULT_FILE_API_URL_PROD = "https://file-api-main-950663559455.us-central1.run.app"
DEFAULT_FILE_API_URL = DEFAULT_FILE_API_URL_PROD
DEFAULT_WHEEL_SERVER_URL = "http://localhost:11113"
PACKAGE_NAME = "luckee_cli"
CLI_RELEASE_TAG = "cli-v20260317190704-133-e954fae"


def _resolve_cli_version() -> str:
    if CLI_RELEASE_TAG != "dev":
        return CLI_RELEASE_TAG
    try:
        return version(PACKAGE_NAME)
    except PackageNotFoundError:
        # Editable/dev runs may execute without installed package metadata.
        return CLI_RELEASE_TAG


def _is_truthy_env(value: Optional[str]) -> bool:
    if value is None:
        return False
    return value.strip().lower() in {"1", "true", "yes", "on"}


def _config_indicates_openclaw_feishu(config_payload: Any) -> bool:
    if config_payload is None:
        return False

    if isinstance(config_payload, str):
        lowered = config_payload.lower()
        return (
            "openclaw" in lowered
            and ("feishu" in lowered or "lark" in lowered or "channel" in lowered)
        ) or "channel:feishu" in lowered

    if isinstance(config_payload, dict):
        for key, value in config_payload.items():
            key_l = str(key).lower()
            if key_l in {"channel", "source_channel", "platform", "source_platform"}:
                value_l = str(value).lower()
                if value_l in {"feishu", "lark", "openclaw_feishu"}:
                    return True
            if key_l in {"source", "client", "platform_name"} and str(value).lower() == "openclaw":
                return True
            if _config_indicates_openclaw_feishu(value):
                return True
        return False

    if isinstance(config_payload, (list, tuple)):
        return any(_config_indicates_openclaw_feishu(item) for item in config_payload)

    return False


def _should_hide_session_details(args: argparse.Namespace, config_payload: Any) -> bool:
    if args.hide_session_details:
        return True

    # Manual env override keeps behavior explicit when needed.
    if _is_truthy_env(os.getenv("AGENT_CLI_HIDE_SESSION_DETAILS")):
        return True

    # Auto-hide for OpenClaw Feishu/Lark channel if passed via config payload.
    if _config_indicates_openclaw_feishu(config_payload):
        return True

    # Auto-hide in Feishu/Lark environments when related env vars exist.
    known_markers = (
        "RUNNING_IN_FEISHU",
        "RUNNING_IN_LARK",
        "FEISHU_APP_ID",
        "LARK_APP_ID",
        "FEISHU_WEBHOOK",
        "LARK_WEBHOOK",
    )
    if any(os.getenv(key) for key in known_markers):
        return True

    return any(
        key.startswith(("FEISHU_", "LARK_")) and bool(os.getenv(key))
        for key in os.environ
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Agent Loop CLI for websocket streaming messages.",
    )
    parser.add_argument(
        "-v",
        "--version",
        action="version",
        version=f"%(prog)s {_resolve_cli_version()}",
    )
    parser.add_argument("--url", default=None, help="WebSocket endpoint URL.")
    parser.add_argument(
        "--token",
        default=None,
        help="Auth token. If omitted, LUCKEE_AUTH_TOKEN is used.",
    )
    parser.add_argument("--user-id", default=None, help="User ID.")
    parser.add_argument(
        "--thread-id",
        default=None,
        help="Thread ID. If omitted, a new websocket session is created.",
    )
    parser.add_argument(
        "--language",
        default=None,
        help="Language code (e.g. en, zh-CN, ja).",
    )
    parser.add_argument(
        "--config",
        default=None,
        help='JSON string sent in query payload config, e.g. \'{"lingxing_account":"123"}\'.',
    )
    parser.add_argument(
        "--system-prompt",
        default=None,
        help="Optional system_prompt sent in query payload.",
    )
    parser.add_argument(
        "--query",
        default=None,
        help="One-shot mode: send one query and exit after complete.",
    )
    parser.add_argument(
        "--scenario",
        default=None,
        help='Optional scenario sent in query payload (defaults to "keyword").',
    )
    parser.add_argument(
        "--file-api-url",
        default=None,
        help="File API base URL used to upload local attachments.",
    )
    parser.add_argument(
        "--attachment",
        action="append",
        default=None,
        help="Local file path to upload and attach (repeat for multiple files).",
    )
    parser.add_argument(
        "--attachments",
        default=None,
        help="Comma-separated local file paths, e.g. --attachments file1,file2",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=180,
        help="One-shot mode timeout in seconds (default: 180).",
    )
    parser.add_argument(
        "--debug",
        action="store_true",
        help="Print raw websocket messages for both send and receive.",
    )
    parser.add_argument(
        "--hide-session-details",
        action="store_true",
        help="Hide endpoint/user_id/thread_id in the session banner.",
    )
    parser.add_argument(
        "--wheel-server-url",
        default=None,
        help="Base URL for wheel server used by self-upgrade.",
    )
    parser.add_argument(
        "--upgrade",
        action="store_true",
        help="Upgrade CLI from wheel server and exit.",
    )
    subparsers = parser.add_subparsers(dest="command")
    login_parser = subparsers.add_parser("login", help="Authenticate via OAuth 2.0 (PKCE).")
    login_parser.add_argument(
        "--port",
        type=int,
        default=0,
        help="Local OAuth callback port (default: 0 means auto-select a free port).",
    )
    login_parser.add_argument(
        "--oauth-base-url",
        default=None,
        help="OAuth API base URL for token/refresh (default from LUCKEE_OAUTH_BASE_URL).",
    )
    login_parser.add_argument(
        "--frontend-base-url",
        default=None,
        help="Frontend base URL for auth page (default from LUCKEE_FRONTEND).",
    )
    login_parser.add_argument(
        "--client-id",
        default=None,
        help="OAuth client_id (default from LUCKEE_OAUTH_CLIENT_ID or luckee-cli).",
    )
    login_parser.add_argument(
        "--login-timeout",
        type=int,
        default=180,
        help="OAuth login timeout in seconds (default: 180).",
    )
    login_parser.add_argument(
        "--no-browser",
        action="store_true",
        help="Do not automatically open the authorize URL in a browser.",
    )
    subparsers.add_parser("logout", help="Clear local OAuth token cache.")
    return parser.parse_args()


def load_runtime_defaults(args: argparse.Namespace) -> dict[str, Any]:
    load_dotenv()
    url = args.url or os.getenv("AGENT_CLI_URL") or DEFAULT_WS_URL
    token = (args.token or os.getenv("LUCKEE_AUTH_TOKEN") or "").strip() or None
    oauth_base_url = resolve_oauth_base_url(getattr(args, "oauth_base_url", None))
    frontend_base_url = resolve_frontend_base_url(getattr(args, "frontend_base_url", None))
    oauth_client_id = resolve_oauth_client_id(getattr(args, "client_id", None))
    if not token:
        token = get_valid_token(oauth_base_url=oauth_base_url, client_id=oauth_client_id)
    file_api_url = (
        args.file_api_url
        or os.getenv("AGENT_CLI_FILE_API_URL")
        or DEFAULT_FILE_API_URL
    )
    user_id = (args.user_id or os.getenv("AGENT_CLI_USER_ID") or "").strip() or None
    language = args.language or os.getenv("AGENT_CLI_LANGUAGE")
    explicit_thread_id = (args.thread_id or os.getenv("AGENT_CLI_THREAD_ID") or "").strip()
    # Some servers reject thread_id when user_id is absent.
    # Keep explicit thread_id if provided, otherwise only auto-generate when user_id exists.
    thread_id = explicit_thread_id or (str(uuid4()) if user_id else None)
    scenario = args.scenario or os.getenv("AGENT_CLI_SCENARIO") or "keyword"
    wheel_server_url = (
        args.wheel_server_url
        or os.getenv("AGENT_CLI_WHEEL_SERVER_URL")
        or DEFAULT_WHEEL_SERVER_URL
    )

    config_payload: Optional[dict[str, Any]] = None
    if args.config:
        config_payload = json.loads(args.config)

    return {
        "url": url,
        "token": token,
        "file_api_url": file_api_url,
        "user_id": user_id,
        "language": language,
        "thread_id": thread_id,
        "scenario": scenario,
        "config_payload": config_payload,
        "wheel_server_url": wheel_server_url.rstrip("/"),
        "oauth_base_url": oauth_base_url,
        "frontend_base_url": frontend_base_url,
        "oauth_client_id": oauth_client_id,
    }


def _print_purchase_credit_hint(console: Console) -> None:
    console.print("[yellow]Purchase credit at:[/yellow] https://motse.ai")


def _looks_like_auth_error(detail: str) -> bool:
    lowered = detail.lower()
    auth_markers = (
        "401",
        "invalidstatus",
        "invalid status",
        "usertoken",
        "token",
        "unauthorized",
        "未登录",
        "已失效",
    )
    return any(marker in lowered for marker in auth_markers)


def _self_upgrade(wheel_server_url: str, console: Console) -> None:
    cmd = [
        sys.executable,
        "-m",
        "pip",
        "install",
        "--upgrade",
        "--no-cache-dir",
        "--find-links",
        wheel_server_url,
        PACKAGE_NAME,
    ]
    console.print(
        f"[cyan]Upgrading {PACKAGE_NAME} from[/cyan] [bold]{wheel_server_url}[/bold]"
    )
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        stderr = (result.stderr or "").strip()
        stdout = (result.stdout or "").strip()
        detail = stderr or stdout or "pip install failed"
        raise RuntimeError(detail)
    console.print("[bold green]Upgrade complete.[/bold green]")
    console.print("[dim]Restart the CLI to use the newest version.[/dim]")


def _run_oauth_login_flow(
    console: Console,
    oauth_base_url: str,
    frontend_base_url: str,
    oauth_client_id: str,
    preferred_port: int,
    timeout_seconds: int,
    open_browser: bool,
) -> Optional[str]:
    def _on_authorize_url(authorize_url: str) -> None:
        console.print("[cyan]Open this URL to continue login:[/cyan]")
        # Keep a short clickable label for chat/Feishu renderers, and a raw URL for copy/paste.
        console.print(f"[bold][link={authorize_url}]Click here to authorize[/link][/bold]")
        console.print(authorize_url, no_wrap=True, overflow="ignore")

    try:
        result = run_login_flow(
            oauth_base_url=oauth_base_url,
            frontend_base_url=frontend_base_url,
            client_id=oauth_client_id,
            preferred_port=preferred_port,
            timeout_seconds=timeout_seconds,
            open_browser=open_browser,
            on_authorize_url=_on_authorize_url,
        )
    except Exception as exc:
        detail = str(exc) or exc.__class__.__name__
        console.print(f"[red]OAuth login failed:[/red] {detail}")
        return None

    console.print("[green]Login successful.[/green]")
    return result.tokens.access_token


def run_login_command(args: argparse.Namespace) -> None:
    console = Console()
    cfg = load_runtime_defaults(args)
    token = _run_oauth_login_flow(
        console=console,
        oauth_base_url=cfg["oauth_base_url"],
        frontend_base_url=cfg["frontend_base_url"],
        oauth_client_id=cfg["oauth_client_id"],
        preferred_port=getattr(args, "port", 0),
        timeout_seconds=getattr(args, "login_timeout", 180),
        open_browser=not bool(getattr(args, "no_browser", False)),
    )
    if not token:
        raise SystemExit(1)
    raise SystemExit(0)


def run_logout_command() -> None:
    console = Console()
    removed = clear_saved_tokens()
    if removed:
        console.print("[green]Logged out. Local token cache cleared.[/green]")
    else:
        console.print("[yellow]Already logged out. No local token cache found.[/yellow]")
    raise SystemExit(0)


async def prompt_and_answer(
    console: Console,
    ask_request: AskPromptRequest,
    client: AgentWsV3Client,
) -> None:
    answers: dict[str, str] = {}
    console.print(f"[bold cyan]Agent asked for input[/bold cyan] id={ask_request.message_id}")
    for q in ask_request.questions:
        question_text = str(q.get("question") or "")
        options = q.get("options") or []
        labels = [str(opt.get("label")) for opt in options if opt.get("label")]
        if labels:
            console.print(f"\n[bold]{question_text}[/bold]")
            for idx, label in enumerate(labels, 1):
                console.print(f"  {idx}. {label}")

            user_input = Prompt.ask("Your answer", default="1").strip()
            if not user_input:
                user_input = "1"

            # Keep input flexible: raw text is allowed; valid option numbers map to labels.
            if user_input.isdigit():
                selected_idx = int(user_input)
                if 1 <= selected_idx <= len(labels):
                    answers[question_text] = labels[selected_idx - 1]
                else:
                    answers[question_text] = user_input
            else:
                answers[question_text] = user_input
        else:
            answers[question_text] = Prompt.ask(question_text)

    await client.send_answer(message_id=ask_request.message_id, answers=answers)


async def run_cli(args: argparse.Namespace) -> None:
    cfg = load_runtime_defaults(args)
    console = Console()
    if args.upgrade:
        try:
            _self_upgrade(cfg["wheel_server_url"], console)
        except Exception as exc:
            detail = str(exc) or exc.__class__.__name__
            console.print(f"[red]upgrade failed:[/red] {detail}")
            raise SystemExit(1) from exc
        raise SystemExit(0)

    if not cfg["token"]:
        console.print(
            "[yellow]No valid token provided or found in local cache. Starting OAuth login...[/yellow]"
        )
        login_token = _run_oauth_login_flow(
            console=console,
            oauth_base_url=cfg["oauth_base_url"],
            frontend_base_url=cfg["frontend_base_url"],
            oauth_client_id=cfg["oauth_client_id"],
            preferred_port=0,
            timeout_seconds=180,
            open_browser=True,
        )
        if not login_token:
            raise SystemExit(1)
        cfg["token"] = login_token

    hide_session_details = _should_hide_session_details(args, cfg.get("config_payload"))
    parsed_csv_attachments: list[str] = []
    if args.attachments:
        parsed_csv_attachments = [
            item.strip() for item in args.attachments.split(",") if item.strip()
        ]
    all_attachment_paths = [*(args.attachment or []), *parsed_csv_attachments]
    renderer = StreamRenderer(console=console, debug=args.debug)
    stop_event = asyncio.Event()
    query_complete_event = asyncio.Event()
    query_in_flight = asyncio.Event()
    interrupt_requested = asyncio.Event()
    fast_exit_requested = asyncio.Event()
    pending_attachments: list[LocalAttachment] = [
        LocalAttachment(path=p) for p in all_attachment_paths
    ]

    def _build_client() -> AgentWsV3Client:
        return AgentWsV3Client(
            WsClientConfig(
                base_url=cfg["url"],
                user_id=cfg["user_id"],
                user_token=cfg["token"],
                thread_id=cfg["thread_id"],
                user_language=cfg["language"],
                debug=args.debug,
            )
        )

    client = _build_client()

    async def _interrupt_or_exit() -> None:
        if stop_event.is_set():
            return
        if query_in_flight.is_set():
            if interrupt_requested.is_set():
                console.print("\n[yellow]Exiting...[/yellow]")
                fast_exit_requested.set()
                stop_event.set()
                return
            try:
                await client.send_interrupt()
                interrupt_requested.set()
                console.print("\n[yellow]Sent interrupt.[/yellow] Press Ctrl+C again to exit.")
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"\n[red]Failed to send interrupt:[/red] {detail}")
        else:
            console.print("\n[yellow]Exiting...[/yellow]")
            fast_exit_requested.set()
            stop_event.set()

    def _handle_sigint() -> None:
        if stop_event.is_set():
            return
        asyncio.create_task(_interrupt_or_exit())

    loop = asyncio.get_running_loop()
    for sig in (signal.SIGINT,):
        try:
            loop.add_signal_handler(sig, _handle_sigint)
        except NotImplementedError:  # pragma: no cover - platform dependent
            pass

    try:
        await client.connect()
    except Exception as exc:
        detail = str(exc) or exc.__class__.__name__
        if _looks_like_auth_error(detail):
            console.print(
                "[yellow]Authentication failed: token is invalid or expired. Starting OAuth login...[/yellow]"
            )
            login_token = _run_oauth_login_flow(
                console=console,
                oauth_base_url=cfg["oauth_base_url"],
                frontend_base_url=cfg["frontend_base_url"],
                oauth_client_id=cfg["oauth_client_id"],
                preferred_port=0,
                timeout_seconds=180,
                open_browser=True,
            )
            if not login_token:
                raise SystemExit(1) from exc
            cfg["token"] = login_token
            client = _build_client()
            try:
                await client.connect()
            except Exception as reconnect_exc:
                reconnect_detail = str(reconnect_exc) or reconnect_exc.__class__.__name__
                console.print(f"[red]Failed to connect after login:[/red] {reconnect_detail}")
                raise SystemExit(1) from reconnect_exc
        else:
            console.print(f"[red]Failed to connect:[/red] {detail}")
            raise SystemExit(1) from exc
    renderer.render_banner(
        endpoint=cfg["url"],
        user_id=client.user_id,
        thread_id=client.thread_id,
        hide_session_details=hide_session_details,
    )

    async def build_query_attachments() -> list[dict[str, Any]]:
        if not pending_attachments:
            return []
        thread_id = await client.wait_for_thread_id(timeout_seconds=10.0)
        user_id = await client.wait_for_user_id(timeout_seconds=10.0)
        console.print(
            f"[dim]uploading {len(pending_attachments)} attachment(s) "
            f"(session_id={thread_id}) via {cfg['file_api_url']}[/dim]"
        )
        uploaded = await upload_attachments(
            base_url=cfg["file_api_url"],
            user_id=user_id,
            thread_id=thread_id,
            attachments=pending_attachments,
            debug=args.debug,
        )
        console.print(
            f"[green]uploaded {len(uploaded)} attachment(s) (session_id={thread_id})[/green]"
        )
        return uploaded

    async def receive_loop() -> None:
        while not stop_event.is_set():
            msg = await client.incoming_queue.get()
            msg_type = msg.get("type")
            if msg_type == "metadata":
                thread_id = str(msg.get("thread_id") or "")
                if thread_id:
                    client.set_thread_id(thread_id)
                metadata_user_id = str(msg.get("user_id") or "").strip()
                if metadata_user_id:
                    client.set_user_id(metadata_user_id)
                    cfg["user_id"] = metadata_user_id

            ask_request = renderer.render_message(msg)
            if ask_request is not None:
                await prompt_and_answer(console, ask_request, client)

            if msg_type in {"complete", "interrupted", "terminated", "_connection_error"}:
                query_in_flight.clear()
                interrupt_requested.clear()
                query_complete_event.set()

            if msg_type == "_connection_error":
                detail = str(msg.get("error_message") or "connection error")
                if _looks_like_auth_error(detail):
                    console.print(
                        "[red]Authentication failed: token is missing, invalid, or expired.[/red]"
                    )
                    _print_purchase_credit_hint(console)
                else:
                    console.print(f"[red]Connection closed unexpectedly:[/red] {detail}")
                stop_event.set()
                return

    async def input_loop() -> None:
        while not stop_event.is_set():
            raw = (await asyncio.to_thread(input, "\n> ")).strip()
            if not raw:
                continue

            if raw in {"/quit", "/exit"}:
                stop_event.set()
                break

            if raw == "/help":
                renderer.render_help()
                continue

            if raw == "/upgrade":
                try:
                    await asyncio.to_thread(_self_upgrade, cfg["wheel_server_url"], console)
                except Exception as exc:
                    detail = str(exc) or exc.__class__.__name__
                    console.print(f"[red]upgrade failed:[/red] {detail}")
                continue

            if raw == "/thread":
                value = client.thread_id or "(not yet assigned)"
                console.print(f"[dim]thread_id={value}[/dim]")
                continue

            if raw == "/thinking":
                renderer.render_thinking_history()
                continue

            if raw.startswith("/thinking "):
                thinking_id = raw[len("/thinking ") :].strip()
                if not thinking_id:
                    console.print("[red]Usage: /thinking <message_id|prefix>[/red]")
                    continue
                renderer.render_thinking_history(thinking_id)
                continue

            if raw == "/clear":
                console.clear()
                renderer.render_banner(
                    endpoint=cfg["url"],
                    user_id=client.user_id,
                    thread_id=client.thread_id,
                    hide_session_details=hide_session_details,
                )
                continue

            if raw == "/interrupt":
                await client.send_interrupt()
                continue

            if raw == "/new":
                await client.reconnect(new_thread=True)
                renderer.render_banner(
                    endpoint=cfg["url"],
                    user_id=client.user_id,
                    thread_id=client.thread_id,
                    hide_session_details=hide_session_details,
                )
                continue

            if raw.startswith("/attach "):
                path = raw[len("/attach ") :].strip()
                if not path:
                    console.print("[red]Usage: /attach <local_file_path>[/red]")
                    continue
                pending_attachments.append(LocalAttachment(path=path))
                console.print(
                    f"[green]queued attachment[/green] #{len(pending_attachments)}: {path}"
                )
                continue

            if raw == "/attachments":
                if not pending_attachments:
                    console.print("[dim]no queued attachments[/dim]")
                    continue
                console.print("[bold cyan]Queued attachments[/bold cyan]")
                for idx, item in enumerate(pending_attachments, 1):
                    console.print(f"  {idx}. {item.path}")
                continue

            if raw.startswith("/detach "):
                token = raw[len("/detach ") :].strip().lower()
                if token == "all":
                    pending_attachments.clear()
                    console.print("[green]cleared queued attachments[/green]")
                    continue
                try:
                    remove_idx = int(token)
                except ValueError:
                    console.print("[red]Usage: /detach <index|all>[/red]")
                    continue
                if remove_idx < 1 or remove_idx > len(pending_attachments):
                    console.print("[red]attachment index out of range[/red]")
                    continue
                removed = pending_attachments.pop(remove_idx - 1)
                console.print(f"[green]removed attachment[/green] {removed.path}")
                continue

            query_complete_event.clear()
            try:
                attachments = await build_query_attachments()
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"[red]failed to upload attachments:[/red] {detail}")
                continue
            await client.send_query(
                raw,
                attachments=attachments,
                scenario=cfg["scenario"],
                config=cfg["config_payload"],
                system_prompt=args.system_prompt,
                language=cfg["language"],
            )
            query_in_flight.set()
            pending_attachments.clear()

    recv_task = asyncio.create_task(receive_loop())

    try:
        if args.query:
            query_complete_event.clear()
            try:
                attachments = await build_query_attachments()
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"[red]failed to upload attachments:[/red] {detail}")
                stop_event.set()
                return
            await client.send_query(
                args.query,
                attachments=attachments,
                scenario=cfg["scenario"],
                config=cfg["config_payload"],
                system_prompt=args.system_prompt,
                language=cfg["language"],
            )
            query_in_flight.set()
            try:
                await asyncio.wait_for(query_complete_event.wait(), timeout=args.timeout)
            except asyncio.TimeoutError:
                console.print(f"[red]Timed out waiting for complete ({args.timeout}s)[/red]")
            stop_event.set()
        else:
            input_task = asyncio.create_task(input_loop())
            await stop_event.wait()
            if not input_task.done():
                input_task.cancel()
                try:
                    await input_task
                except asyncio.CancelledError:
                    pass
    finally:
        if not recv_task.done():
            recv_task.cancel()
            try:
                await recv_task
            except asyncio.CancelledError:
                pass
        await client.close(send_terminate=not fast_exit_requested.is_set())


def main() -> None:
    args = parse_args()
    if args.command == "login":
        run_login_command(args)
        return
    if args.command == "logout":
        run_logout_command()
        return
    try:
        asyncio.run(run_cli(args))
    except KeyboardInterrupt:
        # Fallback for platforms where signal handlers aren't installed.
        pass


if __name__ == "__main__":
    main()

