"""CLI commands for asedclaw."""

import asyncio
import os
import select
import signal
import sys
from pathlib import Path

# Force UTF-8 encoding for Windows console
if sys.platform == "win32":
    if sys.stdout.encoding != "utf-8":
        os.environ["PYTHONIOENCODING"] = "utf-8"
        # Re-open stdout/stderr with UTF-8 encoding
        try:
            sys.stdout.reconfigure(encoding="utf-8", errors="replace")
            sys.stderr.reconfigure(encoding="utf-8", errors="replace")
        except Exception:
            pass

import typer
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text

from asedclaw import __logo__, __version__
from asedclaw.config.paths import get_workspace_path
from asedclaw.config.schema import Config
from asedclaw.utils.helpers import sync_workspace_templates

app = typer.Typer(
    name="asedclaw",
    help=f"{__logo__} asedclaw - Personal AI Assistant",
    no_args_is_help=True,
)

console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}

# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
# ---------------------------------------------------------------------------

_PROMPT_SESSION: PromptSession | None = None
_SAVED_TERM_ATTRS = None  # original termios settings, restored on exit


def _flush_pending_tty_input() -> None:
    """Drop unread keypresses typed while the model was generating output."""
    try:
        fd = sys.stdin.fileno()
        if not os.isatty(fd):
            return
    except Exception:
        return

    try:
        import termios
        termios.tcflush(fd, termios.TCIFLUSH)
        return
    except Exception:
        pass

    try:
        while True:
            ready, _, _ = select.select([fd], [], [], 0)
            if not ready:
                break
            if not os.read(fd, 4096):
                break
    except Exception:
        return


def _restore_terminal() -> None:
    """Restore terminal to its original state (echo, line buffering, etc.)."""
    if _SAVED_TERM_ATTRS is None:
        return
    try:
        import termios
        termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
    except Exception:
        pass


def _init_prompt_session() -> None:
    """Create the prompt_toolkit session with persistent file history."""
    global _PROMPT_SESSION, _SAVED_TERM_ATTRS

    # Save terminal state so we can restore it on exit
    try:
        import termios
        _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
    except Exception:
        pass

    from asedclaw.config.paths import get_cli_history_path

    history_file = get_cli_history_path()
    history_file.parent.mkdir(parents=True, exist_ok=True)

    _PROMPT_SESSION = PromptSession(
        history=FileHistory(str(history_file)),
        enable_open_in_editor=False,
        multiline=False,   # Enter submits (single line mode)
    )


def _print_agent_response(response: str, render_markdown: bool) -> None:
    """Render assistant response with consistent terminal styling."""
    content = response or ""
    body = Markdown(content) if render_markdown else Text(content)
    console.print()
    console.print(f"[cyan]{__logo__} asedclaw[/cyan]")
    console.print(body)
    console.print()


def _is_exit_command(command: str) -> bool:
    """Return True when input should end interactive chat."""
    return command.lower() in EXIT_COMMANDS


async def _read_interactive_input_async() -> str:
    """Read user input using prompt_toolkit (handles paste, history, display).

    prompt_toolkit natively handles:
    - Multiline paste (bracketed paste mode)
    - History navigation (up/down arrows)
    - Clean display (no ghost characters or artifacts)
    """
    if _PROMPT_SESSION is None:
        raise RuntimeError("Call _init_prompt_session() first")
    try:
        with patch_stdout():
            return await _PROMPT_SESSION.prompt_async(
                HTML("<b fg='ansiblue'>You:</b> "),
            )
    except EOFError as exc:
        raise KeyboardInterrupt from exc



def version_callback(value: bool):
    if value:
        console.print(f"{__logo__} asedclaw v{__version__}")
        raise typer.Exit()


@app.callback()
def main(
    version: bool = typer.Option(
        None, "--version", "-v", callback=version_callback, is_eager=True
    ),
):
    """asedclaw - Personal AI Assistant."""
    pass


# ============================================================================
# Onboard / Setup
# ============================================================================


@app.command()
def onboard(
    overwrite: bool | None = typer.Option(
        None,
        "--overwrite/--no-overwrite",
        help="Whether to overwrite config directly when it already exists.",
    ),
):
    """Initialize asedclaw configuration and workspace."""
    from asedclaw.config.loader import get_config_path, load_config, save_config
    from asedclaw.config.schema import Config

    config_path = get_config_path()

    if config_path.exists():
        if overwrite is None:
            console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
            console.print("  [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
            console.print("  [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
            overwrite_now = typer.confirm("Overwrite?")
        else:
            overwrite_now = overwrite

        if overwrite_now:
            config = Config()
            save_config(config)
            console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
        else:
            config = load_config()
            save_config(config)
            console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
    else:
        save_config(Config())
        console.print(f"[green]✓[/green] Created config at {config_path}")

    # Create workspace
    workspace = get_workspace_path()

    if not workspace.exists():
        workspace.mkdir(parents=True, exist_ok=True)
        console.print(f"[green]✓[/green] Created workspace at {workspace}")

    sync_workspace_templates(workspace)

    console.print(f"\n{__logo__} asedclaw is ready!")
    console.print("\nNext steps:")
    console.print("  1. Add your API key to [cyan]~/.asedclaw/config.json[/cyan]")
    console.print("     Get one at: https://openrouter.ai/keys")
    console.print("  2. Chat: [cyan]asedclaw agent -m \"Hello!\"[/cyan]")
    console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/asedclaw#-chat-apps[/dim]")


# ============================================================================
# Upgrade
# ============================================================================


@app.command()
def upgrade(
    whl_path: str = typer.Argument(
        None,
        help="Path to a local .whl file. If omitted, upgrades from PyPI.",
    ),
    check: bool = typer.Option(
        False,
        "--check", "-c",
        help="Only check if a newer version is available on PyPI, don't install.",
    ),
    version_pin: str = typer.Option(
        None,
        "--version", "-V",
        help="Install a specific version (e.g. --version 0.0.5).",
    ),
    skip_onboard: bool = typer.Option(
        False,
        "--skip-onboard",
        help="Skip the onboard --overwrite step after upgrade.",
    ),
    no_restart: bool = typer.Option(
        False,
        "--no-restart",
        help="Don't restart the gateway after upgrade.",
    ),
    pre: bool = typer.Option(
        False,
        "--pre",
        help="Include pre-release versions when checking PyPI.",
    ),
):
    """Upgrade asedclaw to a new version.

    \b
    Usage:
      asedclaw upgrade                     # upgrade to latest from PyPI
      asedclaw upgrade --check             # check available version (no install)
      asedclaw upgrade --version 0.0.5     # install specific version
      asedclaw upgrade ./asedclaw-0.0.3.whl  # from local .whl file
      asedclaw upgrade --pre               # include pre-release versions
    """
    import json
    import subprocess

    from asedclaw.config.loader import get_config_path

    old_version = __version__

    # --- Check mode: query PyPI and report ---
    if check:
        _check_pypi_version(old_version, pre=pre)
        return

    # --- Determine install source ---
    if whl_path:
        if not Path(whl_path).exists():
            console.print(f"[red]Error:[/red] File not found: {whl_path}")
            raise typer.Exit(1)
        if not whl_path.endswith(".whl"):
            console.print("[red]Error:[/red] File must be a .whl package")
            raise typer.Exit(1)
        install_args = ["pip", "install", "--force-reinstall", "--no-deps", whl_path]
        deps_args = ["pip", "install", whl_path]
        source_desc = f"local file: {whl_path}"
    else:
        # PyPI upgrade (default)
        target = "asedclaw"
        if version_pin:
            target = f"asedclaw=={version_pin}"
        install_args = ["pip", "install", "--upgrade", target]
        if pre:
            install_args.append("--pre")
        deps_args = None
        source_desc = f"PyPI ({target})"

    console.print(f"Current version: [cyan]v{old_version}[/cyan]")
    console.print(f"Upgrading from: [cyan]{source_desc}[/cyan]")

    # 1. Save existing config snapshot
    config_path = get_config_path()
    saved_config = None
    if config_path.exists():
        try:
            saved_config = json.loads(config_path.read_text(encoding="utf-8"))
            console.print("[green]✓[/green] Config snapshot saved")
        except Exception as e:
            console.print(f"[yellow]Warning:[/yellow] Failed to read config: {e}")

    # 2. Stop gateway
    console.print("Stopping gateway...")
    _kill_gateway()

    # 3. pip install
    console.print("Installing package...")
    result = subprocess.run(install_args, capture_output=True, text=True, timeout=300)
    if result.returncode != 0:
        console.print(f"[red]Error:[/red] pip install failed:\n{result.stderr}")
        # Try to restart gateway even on failure
        if not no_restart:
            _try_start_gateway()
        raise typer.Exit(1)
    console.print("[green]✓[/green] Package installed")

    # Install dependencies if needed (local .whl mode)
    if deps_args:
        deps_result = subprocess.run(deps_args, capture_output=True, text=True, timeout=300)
        if deps_result.returncode != 0:
            console.print(f"[yellow]Warning:[/yellow] Dependency install had errors:\n{deps_result.stderr[:500]}")

    # 4. onboard --overwrite (regenerate config with new schema)
    if not skip_onboard:
        console.print("Regenerating config schema...")
        ob_result = subprocess.run(
            ["asedclaw", "onboard", "--overwrite"],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if ob_result.returncode != 0:
            console.print(f"[yellow]Warning:[/yellow] onboard failed: {ob_result.stderr}")

        # 5. Merge saved config back into new template
        if saved_config and config_path.exists():
            try:
                new_config = json.loads(config_path.read_text(encoding="utf-8"))
                _deep_merge(new_config, saved_config)
                config_path.write_text(
                    json.dumps(new_config, indent=2, ensure_ascii=False),
                    encoding="utf-8",
                )
                console.print("[green]✓[/green] User config restored into new schema")
            except Exception as e:
                console.print(f"[yellow]Warning:[/yellow] Failed to merge config: {e}")
                if saved_config:
                    try:
                        config_path.write_text(
                            json.dumps(saved_config, indent=2, ensure_ascii=False),
                            encoding="utf-8",
                        )
                    except Exception:
                        pass
    else:
        console.print("[dim]Skipping onboard (--skip-onboard)[/dim]")

    # 6. Get new version
    new_ver_result = subprocess.run(
        ["asedclaw", "--version"],
        capture_output=True,
        text=True,
    )
    new_version = new_ver_result.stdout.strip() if new_ver_result.returncode == 0 else "unknown"

    # 7. Restart gateway
    if no_restart:
        console.print("[dim]Skipping gateway restart (--no-restart)[/dim]")
    else:
        _try_start_gateway()

    if new_version == f"{__logo__} asedclaw v{old_version}" or new_version.endswith(old_version):
        console.print(f"\n[yellow]ℹ[/yellow] Already at latest version: [cyan]{old_version}[/cyan]")
    else:
        console.print(f"\n[green]✓[/green] Upgrade complete: [cyan]v{old_version}[/cyan] → [cyan]{new_version}[/cyan]")


def _check_pypi_version(current: str, *, pre: bool = False) -> None:
    """Query PyPI for the latest version and compare with current."""
    import urllib.request
    import json as _json

    console.print(f"Current version: [cyan]v{current}[/cyan]")
    console.print("Checking PyPI for updates...")

    try:
        req = urllib.request.Request(
            "https://pypi.org/pypi/asedclaw/json",
            headers={"Accept": "application/json", "User-Agent": "asedclaw-cli"},
        )
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = _json.loads(resp.read())
    except Exception as e:
        console.print(f"[red]Error:[/red] Failed to query PyPI: {e}")
        raise typer.Exit(1)

    info = data.get("info", {})
    latest = info.get("version", "")

    if not latest:
        console.print("[red]Error:[/red] Could not determine latest version from PyPI")
        raise typer.Exit(1)

    # If pre-release requested, check all versions for a newer pre-release
    if pre and "releases" in data:
        all_versions = list(data["releases"].keys())
        if all_versions:
            # Sort by upload order (last key is latest)
            latest = all_versions[-1]

    has_update = _is_newer_semver(latest, current)

    if has_update:
        summary = info.get("summary", "")
        console.print(f"\n[yellow]⬆[/yellow]  New version available: [green]v{latest}[/green]")
        if summary:
            console.print(f"   {summary}")
        console.print(f"\n   Run [cyan]asedclaw upgrade[/cyan] to install")
        # Show available versions context
        if "releases" in data:
            versions = [v for v in data["releases"] if data["releases"][v]]
            recent = versions[-5:] if len(versions) > 5 else versions
            console.print(f"   Recent versions: {', '.join(recent)}")
    else:
        console.print(f"\n[green]✓[/green] Already at latest version: [cyan]v{current}[/cyan]")


def _is_newer_semver(latest: str, current: str) -> bool:
    """Compare two version strings using semver (major.minor.patch)."""
    def _parse(v: str) -> tuple[int, ...]:
        v = v.lstrip("v")
        # Strip pre-release suffix
        base = v.split("-")[0].split("+")[0]
        parts = base.split(".")
        return tuple(int(p) for p in parts if p.isdigit())

    try:
        lv = _parse(latest)
        cv = _parse(current)
        return lv > cv
    except (ValueError, IndexError):
        return latest != current


def _kill_gateway() -> None:
    """Best-effort kill of any running asedclaw gateway process."""
    import subprocess
    import sys

    try:
        if sys.platform == "win32":
            # PowerShell: find asedclaw.exe processes with 'gateway' in command line
            script = (
                'Get-WmiObject Win32_Process -Filter "name=\'asedclaw.exe\'" '
                "| Where-Object { $_.CommandLine -like '*gateway*' } "
                "| ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
            )
            subprocess.run(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
                capture_output=True,
                timeout=10,
            )
        else:
            subprocess.run(
                ["pkill", "-f", "asedclaw.*gateway"],
                capture_output=True,
                timeout=10,
            )
    except Exception:
        pass  # gateway may not be running


def _try_start_gateway() -> None:
    """Best-effort attempt to start the gateway."""
    import subprocess

    console.print("Starting gateway...")
    try:
        subprocess.Popen(
            ["asedclaw", "gateway"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        console.print("[green]✓[/green] Gateway started")
    except Exception as e:
        console.print(f"[yellow]Warning:[/yellow] Failed to start gateway: {e}")
        console.print("[dim]Run 'asedclaw gateway' manually to start.[/dim]")


def _deep_merge(dst: dict, src: dict) -> None:
    """Recursively merge src into dst. Values in src overwrite dst."""
    for k, v in src.items():
        if isinstance(v, dict) and isinstance(dst.get(k), dict):
            _deep_merge(dst[k], v)
        else:
            dst[k] = v



def _make_provider(config: Config):
    """Create the appropriate LLM provider from config."""
    from asedclaw.providers.azure_openai_provider import AzureOpenAIProvider

    model = config.agents.defaults.model
    provider_name = config.get_provider_name(model)
    p = config.get_provider(model)

    # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
    from asedclaw.providers.custom_provider import CustomProvider
    if provider_name == "custom":
        return CustomProvider(
            api_key=p.api_key if p else "no-key",
            api_base=config.get_api_base(model),
            default_model=model,
        )

    # Azure OpenAI: direct Azure OpenAI endpoint with deployment name
    if provider_name == "azure_openai":
        if not p or not p.api_key or not p.api_base:
            console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]")
            console.print("Set them in ~/.asedclaw/config.json under providers.azure_openai section")
            console.print("Use the model field to specify the deployment name.")
            raise typer.Exit(1)
        
        return AzureOpenAIProvider(
            api_key=p.api_key,
            api_base=p.api_base,
            default_model=model,
        )

    from asedclaw.providers.litellm_provider import LiteLLMProvider
    from asedclaw.providers.registry import find_by_name
    spec = find_by_name(provider_name)
    if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth):
        console.print("[red]Error: No API key configured.[/red]")
        console.print("Set one in ~/.asedclaw/config.json under providers section")
        raise typer.Exit(1)

    return LiteLLMProvider(
        api_key=p.api_key if p else None,
        api_base=config.get_api_base(model),
        default_model=model,
        extra_headers=p.extra_headers if p else None,
        provider_name=provider_name,
    )


def _ensure_first_run_setup() -> None:
    """Silent onboard: create config, workspace, and templates if missing.

    Called automatically before gateway starts so that double-click-to-run
    works without requiring a manual ``asedclaw onboard`` step first.
    """
    from asedclaw.config.loader import get_config_path, load_config, save_config
    from asedclaw.config.paths import get_workspace_path
    from asedclaw.config.schema import Config as ConfigSchema
    from asedclaw.utils.helpers import sync_workspace_templates

    config_path = get_config_path()

    # 1. Config file
    if not config_path.exists():
        console.print(f"[yellow]First run detected, initializing...[/yellow]")
        save_config(ConfigSchema())
        console.print(f"[green]✓[/green] Created config at {config_path}")
    else:
        # Refresh config: preserve existing values, add new fields from schema
        cfg = load_config()
        save_config(cfg)

    # 2. Workspace directory
    workspace = get_workspace_path()
    if not workspace.exists():
        workspace.mkdir(parents=True, exist_ok=True)
        console.print(f"[green]✓[/green] Created workspace at {workspace}")

    # 3. Templates & directory structure (only creates missing files)
    sync_workspace_templates(workspace, silent=True)


def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
    """Load config and optionally override the active workspace.

    Automatically runs first-run setup (config + workspace + templates)
    when no custom config path is specified.
    """
    from asedclaw.config.loader import load_config, set_config_path

    config_path = None
    if config:
        config_path = Path(config).expanduser().resolve()
        if not config_path.exists():
            console.print(f"[red]Error: Config file not found: {config_path}[/red]")
            raise typer.Exit(1)
        set_config_path(config_path)
        console.print(f"[dim]Using config: {config_path}[/dim]")
    else:
        # Auto-setup on first run (silent onboard)
        _ensure_first_run_setup()

    loaded = load_config(config_path)
    if workspace:
        loaded.agents.defaults.workspace = workspace
    return loaded


# ============================================================================
# Gateway / Server
# ============================================================================


@app.command()
def gateway(
    port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
    workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
    verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
    config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
    """Start the asedclaw gateway."""
    from asedclaw.agent.loop import AgentLoop
    from asedclaw.bus.queue import MessageBus
    from asedclaw.channels.manager import ChannelManager
    from asedclaw.config.paths import get_cron_dir
    from asedclaw.cron.service import CronService
    from asedclaw.cron.types import CronJob
    from asedclaw.heartbeat.service import HeartbeatService
    from asedclaw.session.manager import SessionManager

    if verbose:
        import logging
        logging.basicConfig(level=logging.DEBUG)

    config = _load_runtime_config(config, workspace)

    console.print(f"{__logo__} Starting asedclaw gateway on port {port}...")
    sync_workspace_templates(config.workspace_path)
    bus = MessageBus()
    provider = _make_provider(config)
    session_manager = SessionManager(config.workspace_path)

    # Create cron service first (callback set after agent creation)
    cron_store_path = get_cron_dir() / "jobs.json"
    cron = CronService(cron_store_path)

    # Create agent with cron service
    agent = AgentLoop(
        bus=bus,
        provider=provider,
        workspace=config.workspace_path,
        model=config.agents.defaults.model,
        temperature=config.agents.defaults.temperature,
        max_tokens=config.agents.defaults.max_tokens,
        max_iterations=config.agents.defaults.max_tool_iterations,
        memory_window=config.agents.defaults.memory_window,
        reasoning_effort=config.agents.defaults.reasoning_effort,
        brave_api_key=config.tools.web.search.api_key or None,
        web_proxy=config.tools.web.proxy or None,
        exec_config=config.tools.exec,
        cron_service=cron,
        restrict_to_workspace=config.tools.restrict_to_workspace,
        session_manager=session_manager,
        mcp_servers=config.tools.mcp_servers,
        channels_config=config.channels,
        memory_flush_config=config.agents.defaults.memory_flush,
        embedding_config=config.agents.defaults.embedding,
        guardrails_config=config.agents.defaults.guardrails,
        context_budget_config=config.agents.defaults.context_budget,
        tool_result_budget_config=config.agents.defaults.tool_result_budget,
        provider_type=config.get_provider_name(),
        message_queue_config=config.channels.message_queue,
        message_queue_by_channel=config.channels.message_queue_by_channel,
    )

    # Set cron callback (needs agent)
    async def on_cron_job(job: CronJob) -> str | None:
        """Execute a cron job through the agent."""
        from asedclaw.agent.tools.cron import CronTool
        from asedclaw.agent.tools.message import MessageTool
        reminder_note = (
            "[Scheduled Task] Timer finished.\n\n"
            f"Task '{job.name}' has been triggered.\n"
            f"Scheduled instruction: {job.payload.message}"
        )

        # Prevent the agent from scheduling new cron jobs during execution
        cron_tool = agent.tools.get("cron")
        cron_token = None
        if isinstance(cron_tool, CronTool):
            cron_token = cron_tool.set_cron_context(True)
        try:
            response = await agent.process_direct(
                reminder_note,
                session_key=f"cron:{job.id}",
                channel=job.payload.channel or "cli",
                chat_id=job.payload.to or "direct",
            )
        finally:
            if isinstance(cron_tool, CronTool) and cron_token is not None:
                cron_tool.reset_cron_context(cron_token)

        message_tool = agent.tools.get("message")
        if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
            return response

        if job.payload.deliver and job.payload.to and response:
            from asedclaw.bus.events import OutboundMessage
            await bus.publish_outbound(OutboundMessage(
                channel=job.payload.channel or "cli",
                chat_id=job.payload.to,
                content=response
            ))
        return response
    cron.on_job = on_cron_job

    # Create channel manager
    channels = ChannelManager(config, bus)

    def _pick_heartbeat_target() -> tuple[str, str]:
        """Pick a routable channel/chat target for heartbeat-triggered messages."""
        enabled = set(channels.enabled_channels)
        # Prefer the most recently updated non-internal session on an enabled channel.
        for item in session_manager.list_sessions():
            key = item.get("key") or ""
            if ":" not in key:
                continue
            channel, chat_id = key.split(":", 1)
            if channel in {"cli", "system"}:
                continue
            if channel in enabled and chat_id:
                return channel, chat_id
        # Fallback keeps prior behavior but remains explicit.
        return "cli", "direct"

    # Create heartbeat service
    async def on_heartbeat_execute(tasks: str) -> str:
        """Phase 2: execute heartbeat tasks through the full agent loop."""
        channel, chat_id = _pick_heartbeat_target()

        async def _silent(*_args, **_kwargs):
            pass

        return await agent.process_direct(
            tasks,
            session_key="heartbeat",
            channel=channel,
            chat_id=chat_id,
            on_progress=_silent,
        )

    async def on_heartbeat_notify(response: str) -> None:
        """Deliver a heartbeat response to the user's channel."""
        from asedclaw.bus.events import OutboundMessage
        channel, chat_id = _pick_heartbeat_target()
        if channel == "cli":
            return  # No external channel available to deliver to
        await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))

    hb_cfg = config.gateway.heartbeat
    heartbeat = HeartbeatService(
        workspace=config.workspace_path,
        provider=provider,
        model=agent.model,
        on_execute=on_heartbeat_execute,
        on_notify=on_heartbeat_notify,
        interval_s=hb_cfg.interval_s,
        enabled=hb_cfg.enabled,
    )

    if channels.enabled_channels:
        console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
    else:
        console.print("[yellow]Warning: No channels enabled[/yellow]")

    cron_status = cron.status()
    if cron_status["jobs"] > 0:
        console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")

    console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")

    async def run():
        try:
            await cron.start()
            await heartbeat.start()
            await asyncio.gather(
                agent.run(),
                channels.start_all(),
            )
        except KeyboardInterrupt:
            console.print("\nShutting down...")
        finally:
            heartbeat.stop()
            cron.stop()
            agent.stop()
            await channels.stop_all()

    asyncio.run(run())




# ============================================================================
# Agent Commands
# ============================================================================


@app.command()
def agent(
    message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
    session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
    workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
    config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
    markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
    logs: bool = typer.Option(False, "--logs/--no-logs", help="Show asedclaw runtime logs during chat"),
):
    """Interact with the agent directly."""
    from loguru import logger

    from asedclaw.agent.loop import AgentLoop
    from asedclaw.bus.queue import MessageBus
    from asedclaw.config.paths import get_cron_dir
    from asedclaw.cron.service import CronService

    config = _load_runtime_config(config, workspace)
    sync_workspace_templates(config.workspace_path)

    bus = MessageBus()
    provider = _make_provider(config)

    # Create cron service for tool usage (no callback needed for CLI unless running)
    cron_store_path = get_cron_dir() / "jobs.json"
    cron = CronService(cron_store_path)

    if logs:
        logger.enable("asedclaw")
    else:
        logger.disable("asedclaw")

    agent_loop = AgentLoop(
        bus=bus,
        provider=provider,
        workspace=config.workspace_path,
        model=config.agents.defaults.model,
        temperature=config.agents.defaults.temperature,
        max_tokens=config.agents.defaults.max_tokens,
        max_iterations=config.agents.defaults.max_tool_iterations,
        memory_window=config.agents.defaults.memory_window,
        reasoning_effort=config.agents.defaults.reasoning_effort,
        brave_api_key=config.tools.web.search.api_key or None,
        web_proxy=config.tools.web.proxy or None,
        exec_config=config.tools.exec,
        cron_service=cron,
        restrict_to_workspace=config.tools.restrict_to_workspace,
        mcp_servers=config.tools.mcp_servers,
        channels_config=config.channels,
        memory_flush_config=config.agents.defaults.memory_flush,
        embedding_config=config.agents.defaults.embedding,
        guardrails_config=config.agents.defaults.guardrails,
        context_budget_config=config.agents.defaults.context_budget,
        tool_result_budget_config=config.agents.defaults.tool_result_budget,
        provider_type=config.get_provider_name(),
        message_queue_config=config.channels.message_queue,
        message_queue_by_channel=config.channels.message_queue_by_channel,
    )

    # Show spinner when logs are off (no output to miss); skip when logs are on
    def _thinking_ctx():
        if logs:
            from contextlib import nullcontext
            return nullcontext()
        # Animated spinner is safe to use with prompt_toolkit input handling
        return console.status("[dim]asedclaw is thinking...[/dim]", spinner="dots")

    async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
        ch = agent_loop.channels_config
        if ch and tool_hint and not ch.send_tool_hints:
            return
        if ch and not tool_hint and not ch.send_progress:
            return
        console.print(f"  [dim]↳ {content}[/dim]")

    if message:
        # Single message mode — direct call, no bus needed
        async def run_once():
            with _thinking_ctx():
                response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
            _print_agent_response(response, render_markdown=markdown)

        asyncio.run(run_once())
    else:
        # Interactive mode — route through bus like other channels
        from asedclaw.bus.events import InboundMessage
        _init_prompt_session()
        console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")

        if ":" in session_id:
            cli_channel, cli_chat_id = session_id.split(":", 1)
        else:
            cli_channel, cli_chat_id = "cli", session_id

        def _handle_signal(signum, frame):
            sig_name = signal.Signals(signum).name
            _restore_terminal()
            console.print(f"\nReceived {sig_name}, goodbye!")
            sys.exit(0)

        signal.signal(signal.SIGINT, _handle_signal)
        signal.signal(signal.SIGTERM, _handle_signal)
        # SIGHUP is not available on Windows
        if hasattr(signal, 'SIGHUP'):
            signal.signal(signal.SIGHUP, _handle_signal)
        # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
        # SIGPIPE is not available on Windows
        if hasattr(signal, 'SIGPIPE'):
            signal.signal(signal.SIGPIPE, signal.SIG_IGN)

        async def run_interactive():
            bus_task = asyncio.create_task(agent_loop.run())
            turn_done = asyncio.Event()
            turn_done.set()
            turn_response: list[str] = []

            async def _consume_outbound():
                while True:
                    try:
                        msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
                        if msg.metadata.get("_progress"):
                            is_tool_hint = msg.metadata.get("_tool_hint", False)
                            ch = agent_loop.channels_config
                            if ch and is_tool_hint and not ch.send_tool_hints:
                                pass
                            elif ch and not is_tool_hint and not ch.send_progress:
                                pass
                            else:
                                console.print(f"  [dim]↳ {msg.content}[/dim]")
                        elif not turn_done.is_set():
                            if msg.content:
                                turn_response.append(msg.content)
                            turn_done.set()
                        elif msg.content:
                            console.print()
                            _print_agent_response(msg.content, render_markdown=markdown)
                    except asyncio.TimeoutError:
                        continue
                    except asyncio.CancelledError:
                        break

            outbound_task = asyncio.create_task(_consume_outbound())

            try:
                while True:
                    try:
                        _flush_pending_tty_input()
                        user_input = await _read_interactive_input_async()
                        command = user_input.strip()
                        if not command:
                            continue

                        if _is_exit_command(command):
                            _restore_terminal()
                            console.print("\nGoodbye!")
                            break

                        turn_done.clear()
                        turn_response.clear()

                        await bus.publish_inbound(InboundMessage(
                            channel=cli_channel,
                            sender_id="user",
                            chat_id=cli_chat_id,
                            content=user_input,
                        ))

                        with _thinking_ctx():
                            await turn_done.wait()

                        if turn_response:
                            _print_agent_response(turn_response[0], render_markdown=markdown)
                    except KeyboardInterrupt:
                        _restore_terminal()
                        console.print("\nGoodbye!")
                        break
                    except EOFError:
                        _restore_terminal()
                        console.print("\nGoodbye!")
                        break
            finally:
                agent_loop.stop()
                outbound_task.cancel()
                await asyncio.gather(bus_task, outbound_task, return_exceptions=True)

        asyncio.run(run_interactive())


# ============================================================================
# Channel Commands
# ============================================================================


channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels")


@channels_app.command("status")
def channels_status():
    """Show channel status."""
    from asedclaw.config.loader import load_config

    config = load_config()

    table = Table(title="Channel Status")
    table.add_column("Channel", style="cyan")
    table.add_column("Enabled", style="green")
    table.add_column("Configuration", style="yellow")

    # WhatsApp
    wa = config.channels.whatsapp
    table.add_row(
        "WhatsApp",
        "✓" if wa.enabled else "✗",
        wa.bridge_url
    )

    dc = config.channels.discord
    table.add_row(
        "Discord",
        "✓" if dc.enabled else "✗",
        dc.gateway_url
    )

    # Feishu
    fs = config.channels.feishu
    fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]"
    table.add_row(
        "Feishu",
        "✓" if fs.enabled else "✗",
        fs_config
    )

    # Mochat
    mc = config.channels.mochat
    mc_base = mc.base_url or "[dim]not configured[/dim]"
    table.add_row(
        "Mochat",
        "✓" if mc.enabled else "✗",
        mc_base
    )

    # Telegram
    tg = config.channels.telegram
    tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
    table.add_row(
        "Telegram",
        "✓" if tg.enabled else "✗",
        tg_config
    )

    # Slack
    slack = config.channels.slack
    slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]"
    table.add_row(
        "Slack",
        "✓" if slack.enabled else "✗",
        slack_config
    )

    # DingTalk
    dt = config.channels.dingtalk
    dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]"
    table.add_row(
        "DingTalk",
        "✓" if dt.enabled else "✗",
        dt_config
    )

    # QQ
    qq = config.channels.qq
    qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]"
    table.add_row(
        "QQ",
        "✓" if qq.enabled else "✗",
        qq_config
    )

    # Email
    em = config.channels.email
    em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]"
    table.add_row(
        "Email",
        "✓" if em.enabled else "✗",
        em_config
    )

    console.print(table)


def _get_bridge_dir() -> Path:
    """Get the bridge directory, setting it up if needed."""
    import shutil
    import subprocess

    # User's bridge location
    from asedclaw.config.paths import get_bridge_install_dir

    user_bridge = get_bridge_install_dir()

    # Check if already built
    if (user_bridge / "dist" / "index.js").exists():
        return user_bridge

    # Check for npm
    if not shutil.which("npm"):
        console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
        raise typer.Exit(1)

    # Find source bridge: first check package data, then source dir
    pkg_bridge = Path(__file__).parent.parent / "bridge"  # asedclaw/bridge (installed)
    src_bridge = Path(__file__).parent.parent.parent / "bridge"  # repo root/bridge (dev)

    source = None
    if (pkg_bridge / "package.json").exists():
        source = pkg_bridge
    elif (src_bridge / "package.json").exists():
        source = src_bridge

    if not source:
        console.print("[red]Bridge source not found.[/red]")
        console.print("Try reinstalling: pip install --force-reinstall asedclaw")
        raise typer.Exit(1)

    console.print(f"{__logo__} Setting up bridge...")

    # Copy to user directory
    user_bridge.parent.mkdir(parents=True, exist_ok=True)
    if user_bridge.exists():
        shutil.rmtree(user_bridge)
    shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))

    # Install and build
    try:
        console.print("  Installing dependencies...")
        subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)

        console.print("  Building...")
        subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)

        console.print("[green]✓[/green] Bridge ready\n")
    except subprocess.CalledProcessError as e:
        console.print(f"[red]Build failed: {e}[/red]")
        if e.stderr:
            console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
        raise typer.Exit(1)

    return user_bridge


@channels_app.command("login")
def channels_login():
    """Link device via QR code."""
    import subprocess

    from asedclaw.config.loader import load_config
    from asedclaw.config.paths import get_runtime_subdir

    config = load_config()
    bridge_dir = _get_bridge_dir()

    console.print(f"{__logo__} Starting bridge...")
    console.print("Scan the QR code to connect.\n")

    env = {**os.environ}
    if config.channels.whatsapp.bridge_token:
        env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
    env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))

    try:
        subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
    except subprocess.CalledProcessError as e:
        console.print(f"[red]Bridge failed: {e}[/red]")
    except FileNotFoundError:
        console.print("[red]npm not found. Please install Node.js.[/red]")


# ============================================================================
# Status Commands
# ============================================================================


@app.command()
def status():
    """Show asedclaw status."""
    from asedclaw.config.loader import get_config_path, load_config

    config_path = get_config_path()
    config = load_config()
    workspace = config.workspace_path

    console.print(f"{__logo__} asedclaw Status\n")

    console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
    console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")

    if config_path.exists():
        from asedclaw.providers.registry import PROVIDERS

        console.print(f"Model: {config.agents.defaults.model}")

        # Check API keys from registry
        for spec in PROVIDERS:
            p = getattr(config.providers, spec.name, None)
            if p is None:
                continue
            if spec.is_oauth:
                console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
            elif spec.is_local:
                # Local deployments show api_base instead of api_key
                if p.api_base:
                    console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
                else:
                    console.print(f"{spec.label}: [dim]not set[/dim]")
            else:
                has_key = bool(p.api_key)
                console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")


# ============================================================================
# OAuth Login
# ============================================================================

provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider")


_LOGIN_HANDLERS: dict[str, callable] = {}


def _register_login(name: str):
    def decorator(fn):
        _LOGIN_HANDLERS[name] = fn
        return fn
    return decorator


@provider_app.command("login")
def provider_login(
    provider: str = typer.Argument(..., help="OAuth provider"),
):
    """Authenticate with an OAuth provider."""
    from asedclaw.providers.registry import PROVIDERS

    key = provider.replace("-", "_")
    spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
    if not spec:
        names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
        console.print(f"[red]Unknown OAuth provider: {provider}[/red]  Supported: {names}")
        raise typer.Exit(1)

    handler = _LOGIN_HANDLERS.get(spec.name)
    if not handler:
        console.print(f"[red]Login not implemented for {spec.label}[/red]")
        raise typer.Exit(1)

    console.print(f"{__logo__} OAuth Login - {spec.label}\n")
    handler()


if __name__ == "__main__":
    app()
