"""Mindroom CLI - Simplified multi-agent Matrix bot system."""

from __future__ import annotations

import asyncio
import hashlib
import socket
import sys
from pathlib import Path  # noqa: TC003
from typing import TYPE_CHECKING

import typer

from .banner import make_banner
from .config import (
    activate_cli_runtime,
    check_env_keys,
    config_app,
    console,
    format_validation_errors,
    load_config_quiet,
    print_config_search_locations,
)
from .desktop import desktop_app
from .local_stack import local_stack_setup
from .migrate import config_migrate
from .plugins import plugins_app
from .service import service_app
from .trigger import trigger_app

if TYPE_CHECKING:
    from collections.abc import Mapping

    import httpx

    from mindroom.config.main import Config
    from mindroom.constants import RuntimePaths
    from mindroom.thread_export import ThreadExportStats

_HELP = """\
AI agents that live in Matrix and work everywhere via bridges.

[bold]Quick start:[/bold]
  [cyan]mindroom config init[/cyan]   Create a starter config
  [cyan]mindroom run[/cyan]           Start the system\
"""
_CONFIG_INIT_PROVIDER_CHOICES = (
    "{openrouter,ollama,openai,azure,bedrock_claude,codex,kimi,claude,llama.cpp,vertexai_claude}"
)

app = typer.Typer(
    help=_HELP,
    rich_markup_mode="rich",
    context_settings={"help_option_names": ["-h", "--help"]},
    no_args_is_help=True,
    pretty_exceptions_enable=True,
    # Disable showing locals which can be very large (also see `setup_logging`)
    pretty_exceptions_show_locals=False,
)
avatars_app = typer.Typer(help="Generate and sync managed avatar assets.")
threads_app = typer.Typer(help="Export Matrix threads to local files.")
config_app.command("migrate")(config_migrate)
app.add_typer(config_app, name="config")
app.add_typer(plugins_app, name="plugins")
app.add_typer(desktop_app, name="desktop")
app.add_typer(avatars_app, name="avatars")
app.add_typer(threads_app, name="threads")
app.add_typer(service_app, name="service")
app.add_typer(trigger_app, name="trigger")


def _httpx_post(
    url: str,
    *,
    json: Mapping[str, object],
    timeout: float,
    verify: bool,
) -> httpx.Response:
    """Call httpx.post without importing httpx during CLI help rendering."""
    import httpx  # noqa: PLC0415

    return httpx.post(url, json=json, timeout=timeout, verify=verify)


@app.command()
def version() -> None:
    """Show the current version of Mindroom."""
    from mindroom import __version__  # noqa: PLC0415

    console.print(f"Mindroom version: [bold]{__version__}[/bold]")
    console.print("AI agents that live in Matrix")


@app.command()
def run(
    log_level: str = typer.Option(
        "INFO",
        "--log-level",
        "-l",
        help="Set the logging level (DEBUG, INFO, WARNING, ERROR)",
        case_sensitive=False,
        envvar="LOG_LEVEL",
    ),
    config_path: Path | None = typer.Option(  # noqa: B008
        None,
        "--config",
        "-c",
        help="Use this config file path. Defaults the storage location to the selected config directory unless --storage-path is set.",
    ),
    storage_path: Path | None = typer.Option(  # noqa: B008
        None,
        "--storage-path",
        "-s",
        help="Base directory for persistent MindRoom data (state, sessions, tracking)",
    ),
    api: bool = typer.Option(
        True,
        "--api/--no-api",
        help="Start the bundled dashboard/API server alongside the bot",
    ),
    api_port: int = typer.Option(
        8765,
        "--api-port",
        help="Port for the bundled dashboard/API server",
    ),
    api_host: str = typer.Option(
        "0.0.0.0",  # noqa: S104
        "--api-host",
        help="Host for the bundled dashboard/API server",
    ),
) -> None:
    """Run the mindroom multi-agent system.

    This command starts the multi-agent bot system which automatically:
    - Creates all necessary user and agent accounts
    - Creates all rooms defined in config.yaml
    - Manages agent room memberships
    - Starts the bundled dashboard/API server (disable with --no-api)
    """
    asyncio.run(
        _run(
            log_level=log_level.upper(),
            config_path=config_path,
            storage_path=storage_path,
            api=api,
            api_port=api_port,
            api_host=api_host,
        ),
    )


def _load_active_config_or_exit(runtime_paths: RuntimePaths) -> Config:
    """Load the active config file or exit with friendly validation errors."""
    from mindroom.config.main import CONFIG_LOAD_USER_ERROR_TYPES  # noqa: PLC0415
    from mindroom.constants import ensure_writable_config_path  # noqa: PLC0415

    ensure_writable_config_path(runtime_paths=runtime_paths)

    config_path = runtime_paths.config_path
    if not config_path.exists():
        _print_missing_config_error(runtime_paths.process_env)
        raise typer.Exit(1)

    try:
        config = load_config_quiet(
            runtime_paths=runtime_paths,
            tolerate_plugin_load_errors=True,
        )
    except CONFIG_LOAD_USER_ERROR_TYPES as exc:
        format_validation_errors(exc, config_path)
        raise typer.Exit(1) from None

    return config


async def _run(
    log_level: str,
    config_path: Path | None,
    storage_path: Path | None,
    *,
    api: bool,
    api_port: int,
    api_host: str,
) -> None:
    """Run the multi-agent system with friendly error handling."""
    from mindroom.startup_errors import PermanentStartupError  # noqa: PLC0415

    runtime_paths = activate_cli_runtime(path=config_path, storage_path=storage_path)
    config = _load_active_config_or_exit(runtime_paths)

    # Check for missing API keys
    check_env_keys(config, runtime_paths=runtime_paths)

    console.print(make_banner())
    console.print()
    console.print(f"Starting Mindroom (log level: {log_level})...")
    if api:
        from mindroom.frontend_assets import ensure_frontend_dist_dir  # noqa: PLC0415

        frontend_dir = ensure_frontend_dist_dir(runtime_paths)
        display_host = "localhost" if api_host == "0.0.0.0" else api_host  # noqa: S104
        if frontend_dir is None:
            console.print("Dashboard: unavailable (frontend assets missing)")
            console.print("  Install Bun or provide MINDROOM_FRONTEND_DIST when running from a source checkout.")
        else:
            console.print(f"Dashboard: http://{display_host}:{api_port}")
        console.print(f"API: http://{display_host}:{api_port}/api")
    console.print("Press Ctrl+C to stop\n")

    try:
        from mindroom.orchestrator import main as bot_main  # noqa: PLC0415  # lazy: heavy import

        await bot_main(
            log_level=log_level,
            runtime_paths=runtime_paths,
            api=api,
            api_port=api_port,
            api_host=api_host,
        )
    except KeyboardInterrupt:
        console.print("\nStopped")
    except ConnectionError as exc:
        _print_connection_error(exc, runtime_paths)
        raise typer.Exit(1) from None
    except PermanentStartupError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        raise typer.Exit(1) from None
    except OSError as exc:
        if "connect" in str(exc).lower() or "refused" in str(exc).lower():
            _print_connection_error(exc, runtime_paths)
            raise typer.Exit(1) from None
        raise


@app.command()
def doctor(
    config_path: Path | None = typer.Option(  # noqa: B008
        None,
        "--config",
        "-c",
        help="Use this config file path. Defaults the storage location to the selected config directory unless --storage-path is set.",
    ),
    storage_path: Path | None = typer.Option(  # noqa: B008
        None,
        "--storage-path",
        "-s",
        help="Base directory for persistent MindRoom data (state, sessions, tracking)",
    ),
) -> None:
    """Check your environment for common issues.

    Runs connectivity, configuration, and credential checks in a single pass
    so you can fix everything before running `mindroom run`.
    """
    from .doctor import doctor as doctor_command  # noqa: PLC0415

    doctor_command(config_path=config_path, storage_path=storage_path)


@avatars_app.command("generate")
def avatars_generate(
    force: bool = typer.Option(
        False,
        "--force",
        help="Overwrite existing managed workspace avatar files.",
    ),
) -> None:
    """Generate missing managed avatar files in the workspace."""
    from mindroom.error_handling import AvatarGenerationError  # noqa: PLC0415

    runtime_paths = activate_cli_runtime()
    _load_active_config_or_exit(runtime_paths)

    try:
        from mindroom.avatar_generation import run_avatar_generation  # noqa: PLC0415

        asyncio.run(run_avatar_generation(runtime_paths, force=force))
    except AvatarGenerationError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        raise typer.Exit(1) from None


@avatars_app.command("sync")
def avatars_sync(
    force: bool = typer.Option(
        False,
        "--force",
        help="Replace existing Matrix room and root-space avatars.",
    ),
) -> None:
    """Sync configured room and root-space avatars to Matrix using the initialized router account."""
    from mindroom.error_handling import AvatarSyncError  # noqa: PLC0415

    runtime_paths = activate_cli_runtime()
    _load_active_config_or_exit(runtime_paths)

    try:
        from mindroom.avatar_generation import set_room_avatars_in_matrix  # noqa: PLC0415

        asyncio.run(set_room_avatars_in_matrix(runtime_paths, force=force))
    except AvatarSyncError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        raise typer.Exit(1) from None
    except ConnectionError as exc:
        _print_connection_error(exc, runtime_paths)
        raise typer.Exit(1) from None
    except OSError as exc:
        if "connect" in str(exc).lower() or "refused" in str(exc).lower():
            _print_connection_error(exc, runtime_paths)
            raise typer.Exit(1) from None
        raise


@threads_app.command("export")
def _threads_export_command(
    config_path: Path | None = typer.Option(  # noqa: B008
        None,
        "--config",
        "-c",
        help="Use this config file path.",
    ),
    storage_path: Path | None = typer.Option(  # noqa: B008
        None,
        "--storage-path",
        "-s",
        help="Base directory for persistent MindRoom data.",
    ),
    output: Path | None = typer.Option(  # noqa: B008
        None,
        "--output",
        "-o",
        help="Output directory. Defaults to <storage>/thread_exports.",
    ),
    room: str | None = typer.Option(
        None,
        "--room",
        "-r",
        help="Filter exported rooms by a substring of the room key, alias, name, or Matrix room ID.",
    ),
    watch: bool = typer.Option(
        False,
        "--watch",
        help="Repeat the export forever on a fixed interval.",
    ),
    interval: int = typer.Option(
        300,
        "--interval",
        help="Watch interval in seconds.",
    ),
    max_thread_roots: int = typer.Option(
        2000,
        "--max-thread-roots",
        help="Maximum thread roots to enumerate per room.",
    ),
    prefer_cache: bool = typer.Option(
        False,
        "--prefer-cache",
        help="Serve thread bodies from the durable event cache and only fetch from the homeserver "
        "on miss or invalidation. Use alongside a running MindRoom that keeps the cache fresh.",
    ),
    invited_rooms: bool = typer.Option(
        True,
        "--invited-rooms/--no-invited-rooms",
        help="Include rooms joined through authorized invites (user-created rooms).",
    ),
) -> None:
    """Export Matrix threads to YAML files for grep/ripgrep search."""
    asyncio.run(
        _threads_export(
            config_path=config_path,
            storage_path=storage_path,
            output=output,
            room=room,
            watch=watch,
            interval=interval,
            max_thread_roots=max_thread_roots,
            prefer_cache=prefer_cache,
            include_invited_rooms=invited_rooms,
        ),
    )


def _print_thread_export_stats(stats: ThreadExportStats) -> None:
    """Print one export-pass summary."""
    unchanged_note = f" ({stats.threads_unchanged} unchanged)" if stats.threads_unchanged else ""
    console.print(
        f"Exported {stats.threads_exported}/{stats.threads_seen} threads "
        f"from {stats.rooms_exported} room(s) to {stats.output_dir}{unchanged_note}",
    )
    if stats.truncated_rooms:
        console.print(f"[yellow]Warning:[/yellow] {stats.truncated_rooms} room(s) hit the thread enumeration limit")
    for failure in stats.failed_items:
        if failure.room_key is None:
            console.print(f"[red]Failed target:[/red] {failure.error}")
            continue
        target = failure.thread_id or failure.room_id
        console.print(f"[red]Failed:[/red] {failure.room_key} {target}: {failure.error}")


def _handle_thread_export_error(exc: RuntimeError | OSError, runtime_paths: RuntimePaths, *, watch: bool) -> None:
    """Print one top-level export error and exit unless watch mode can retry."""
    if isinstance(exc, ConnectionError) or _is_connection_os_error(exc):
        _print_connection_error(exc, runtime_paths)
    else:
        console.print(f"[red]Error:[/red] {exc}")
    if not watch:
        raise typer.Exit(1) from None


def _is_connection_os_error(exc: BaseException) -> bool:
    """Return whether an OS error looks like a Matrix connection failure."""
    return isinstance(exc, OSError) and ("connect" in str(exc).lower() or "refused" in str(exc).lower())


async def _threads_export(
    *,
    config_path: Path | None,
    storage_path: Path | None,
    output: Path | None,
    room: str | None,
    watch: bool,
    interval: int,
    max_thread_roots: int,
    prefer_cache: bool,
    include_invited_rooms: bool,
) -> None:
    """Run one thread export command."""
    from mindroom.thread_export import export_threads_once  # noqa: PLC0415

    runtime_paths = activate_cli_runtime(path=config_path, storage_path=storage_path)
    config = _load_active_config_or_exit(runtime_paths)
    if interval < 1:
        console.print("[red]Error:[/red] --interval must be at least 1 second")
        raise typer.Exit(1)
    if max_thread_roots < 1:
        console.print("[red]Error:[/red] --max-thread-roots must be at least 1")
        raise typer.Exit(1)

    while True:
        try:
            stats = await export_threads_once(
                config=config,
                runtime_paths=runtime_paths,
                output_dir=output,
                room_filter=room,
                max_thread_roots=max_thread_roots,
                prefer_cache=prefer_cache,
                include_invited_rooms=include_invited_rooms,
            )
        except (OSError, RuntimeError) as exc:
            _handle_thread_export_error(exc, runtime_paths, watch=watch)
        else:
            _print_thread_export_stats(stats)
            if not watch:
                if stats.failures:
                    raise typer.Exit(1)
                return

        await asyncio.sleep(interval)


@app.command()
def connect(
    pair_code: str = typer.Option(
        ...,
        "--pair-code",
        help="Pair code shown in chat UI (format: ABCD-EFGH).",
    ),
    provisioning_url: str | None = typer.Option(
        None,
        "--provisioning-url",
        help="Base URL for the MindRoom provisioning API.",
    ),
    client_name: str = typer.Option(
        socket.gethostname(),
        "--client-name",
        help="Human-readable name for this local machine.",
    ),
    persist_env: bool = typer.Option(
        True,
        "--persist-env/--no-persist-env",
        help="Persist local provisioning credentials to .env next to config.yaml.",
    ),
    path: Path | None = typer.Option(  # noqa: B008
        None,
        "--path",
        "-p",
        help="Override auto-detection and use this config file path for .env persistence.",
    ),
) -> None:
    """Pair this local MindRoom install with the hosted provisioning service."""
    import mindroom.cli.connect as cli_connect  # noqa: PLC0415
    from mindroom import constants  # noqa: PLC0415

    normalized_pair_code = pair_code.strip().upper()
    if not cli_connect.is_valid_pair_code(normalized_pair_code):
        console.print("[red]Error:[/red] Invalid pair code format. Expected ABCD-EFGH.")
        raise typer.Exit(1)

    runtime_paths = activate_cli_runtime(path)
    resolved_provisioning_url = (
        provisioning_url or runtime_paths.env_value("MINDROOM_PROVISIONING_URL") or "https://mindroom.chat"
    ).strip()
    if not resolved_provisioning_url:
        console.print("[red]Error:[/red] Invalid provisioning URL.")
        raise typer.Exit(1)

    resolved_config_path = runtime_paths.config_path
    normalized_client_name = client_name.strip() or socket.gethostname()
    try:
        credentials = cli_connect.complete_local_pairing(
            provisioning_url=resolved_provisioning_url,
            pair_code=normalized_pair_code,
            client_name=normalized_client_name,
            client_fingerprint=_local_client_fingerprint(config_path=resolved_config_path),
            matrix_ssl_verify=constants.runtime_matrix_ssl_verify(runtime_paths=runtime_paths),
            post_request=_httpx_post,
        )
    except (TypeError, ValueError) as exc:
        console.print(f"[red]Error:[/red] {exc}")
        raise typer.Exit(1) from None

    if credentials.owner_user_id_invalid:
        console.print(
            "[yellow]Warning:[/yellow] Pairing response included malformed owner_user_id; skipping config owner autofill.",
        )
    if credentials.namespace_invalid:
        console.print(
            "[yellow]Warning:[/yellow] Pairing response included malformed namespace; leaving MINDROOM_NAMESPACE empty.",
        )

    if persist_env:
        env_path = cli_connect.persist_local_provisioning_env(
            provisioning_url=resolved_provisioning_url,
            client_id=credentials.client_id,
            client_secret=credentials.client_secret,
            namespace=credentials.namespace,
            owner_user_id=credentials.owner_user_id,
            config_path=resolved_config_path,
        )
        console.print("[green]Paired successfully.[/green]")
        console.print(f"  Saved credentials to: {env_path}")
        if credentials.owner_user_id and cli_connect.replace_owner_placeholders_in_config(
            config_path=resolved_config_path,
            owner_user_id=credentials.owner_user_id,
        ):
            console.print(f"  Updated owner placeholder(s) in: {resolved_config_path}")
        console.print("\nNext step:")
        console.print("  uv run mindroom run")
        return

    _print_pairing_success_with_exports(
        provisioning_url=resolved_provisioning_url,
        client_id=credentials.client_id,
        client_secret=credentials.client_secret,
        namespace=credentials.namespace,
        owner_user_id=credentials.owner_user_id,
    )


app.command("local-stack-setup")(local_stack_setup)


def _print_pairing_success_with_exports(
    *,
    provisioning_url: str,
    client_id: str,
    client_secret: str,
    namespace: str,
    owner_user_id: str | None,
) -> None:
    """Print non-persisted exports for local provisioning credentials."""
    console.print("[green]Paired successfully.[/green]")
    console.print("\nExport these variables before running MindRoom:")
    console.print(f"  export MINDROOM_PROVISIONING_URL={provisioning_url}")
    console.print(f"  export MINDROOM_LOCAL_CLIENT_ID={client_id}")
    console.print(f"  export MINDROOM_LOCAL_CLIENT_SECRET={client_secret}")
    console.print(f"  export MINDROOM_NAMESPACE={namespace}")
    if owner_user_id:
        console.print(f"  export MINDROOM_OWNER_USER_ID={owner_user_id}")
        console.print(
            f"\nOwner user ID from pairing: {owner_user_id} (not persisted in --no-persist-env mode).",
        )
        console.print(
            "Update your config.yaml owner placeholder(s) manually if you rely on authorization defaults.",
        )
    console.print("\nThen run:")
    console.print("  uv run mindroom run")


def _local_client_fingerprint(*, config_path: Path) -> str:
    """Return a stable, non-secret local fingerprint."""
    resolved_config_path = config_path.expanduser().resolve()
    raw = f"{socket.gethostname()}:{resolved_config_path}"
    digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
    return f"sha256:{digest}"


# ---------------------------------------------------------------------------
# Friendly error output helpers
# ---------------------------------------------------------------------------


def _print_missing_config_error(process_env: Mapping[str, str]) -> None:
    console.print("[red]Error:[/red] No config found.\n")
    console.print("MindRoom needs a configuration file to know which agents to run.\n")
    console.print("Quick start:")
    console.print("  [cyan]mindroom config init[/cyan]    Create a hosted starter config")
    console.print(
        f"  [cyan]mindroom config init --provider {_CONFIG_INIT_PROVIDER_CHOICES}[/cyan]    Choose a model provider",
        soft_wrap=True,
    )
    console.print("  [cyan]mindroom run[/cyan]            Start MindRoom after setup\n")
    print_config_search_locations(process_env, title="Config search locations (first match wins):")
    console.print("\nLearn more: https://github.com/mindroom-ai/mindroom")


def _print_connection_error(exc: BaseException, runtime_paths: RuntimePaths) -> None:
    from mindroom import constants  # noqa: PLC0415

    console.print("[red]Error:[/red] Could not connect to the Matrix homeserver.\n")
    console.print(f"  Details: {exc}\n")
    console.print("Check that:")
    console.print("  1. Your Matrix homeserver is running")
    console.print(
        "  2. MATRIX_HOMESERVER is set correctly "
        f"(current: {constants.runtime_matrix_homeserver(runtime_paths=runtime_paths)})",
    )
    console.print("  3. The server is reachable from this machine")


def main() -> None:
    """Main entry point that shows help by default."""
    # Print banner for top-level help (no subcommand given)
    if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help")):
        console.print(
            make_banner(tagline=("💊 What if I told you... ", "AI agents live in Matrix.")),
        )

    app()


if __name__ == "__main__":
    main()
