# Copyright (c) 2026 Pyry Haulos
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT

"""Airut CLI — multi-command entry point.

Provides ``airut <command>`` with subcommands for running the gateway,
initializing configuration, checking system readiness, managing systemd
services, and updating the installation.  Running ``airut`` with no arguments
prints version and usage information.

Subcommands:

* ``init``              — create a stub server config file
* ``check``             — verify config and system dependencies
* ``update``            — update airut to the latest version
* ``install-service``   — install systemd user services
* ``uninstall-service`` — uninstall systemd user services
* ``run-gateway``       — start the email gateway service
"""

from __future__ import annotations

import json
import logging
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

from airut.gateway.config import (
    ConfigError,
    ServerConfig,
    get_config_path,
    get_dotenv_path,
)


logger = logging.getLogger(__name__)

# Known subcommand names.
_SUBCOMMANDS = frozenset(
    {
        "run-gateway",
        "init",
        "check",
        "update",
        "install-service",
        "uninstall-service",
    }
)

# Minimum required versions for system dependencies.
# Podman 4.x introduced the netavark backend with --route and --disable-dns
# flags required for the network sandbox.
_MIN_VERSIONS: dict[str, tuple[int, ...]] = {
    "git": (2, 25),
    "podman": (4, 0),
}

_USAGE = """\
usage: airut <command> [args]

commands:
  init              Create a stub server config file
  check             Verify config and system dependencies
  update            Update airut to the latest version
  install-service   Install systemd user services
  uninstall-service Uninstall systemd user services
  run-gateway       Start the email gateway service

Run 'airut <command> --help' for command-specific help.\
"""


def _parse_version(output: str) -> tuple[int, ...]:
    """Extract a numeric version tuple from command output.

    Looks for the first token that starts with a digit and parses it as a
    dotted version string.  For example::

        git version 2.43.0  -> (2, 43, 0)
        podman version 5.3.1 -> (5, 3, 1)

    Args:
        output: Raw stdout from ``<tool> --version`` or ``<tool> version``.

    Returns:
        Numeric version tuple.

    Raises:
        ValueError: If no version number is found.
    """
    for token in output.split():
        if token and token[0].isdigit():
            parts: list[int] = []
            for segment in token.split("."):
                # Strip non-numeric suffixes (e.g. "1.2.3-rc1")
                digits = ""
                for ch in segment:
                    if ch.isdigit():
                        digits += ch
                    else:
                        break
                if digits:
                    parts.append(int(digits))
            if parts:
                return tuple(parts)
    raise ValueError(f"Cannot parse version from: {output!r}")


def _fmt_version(v: tuple[int, ...]) -> str:
    """Format a version tuple as a dotted string."""
    return ".".join(str(p) for p in v)


# ── Terminal colors ─────────────────────────────────────────────────


def _use_color() -> bool:
    """Determine whether to use ANSI color codes in output.

    Returns True when stdout is a TTY and the ``NO_COLOR`` environment
    variable is not set.  ``TERM=dumb`` also disables color.
    """
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("TERM") == "dumb":
        return False
    return sys.stdout.isatty()


class _Style:
    """ANSI escape helpers.  All methods return plain text when color is off."""

    def __init__(self, color: bool) -> None:
        self._on = color

    def _wrap(self, code: str, text: str) -> str:
        if not self._on:
            return text
        return f"\033[{code}m{text}\033[0m"

    def bold(self, text: str) -> str:
        return self._wrap("1", text)

    def green(self, text: str) -> str:
        return self._wrap("32", text)

    def red(self, text: str) -> str:
        return self._wrap("31", text)

    def yellow(self, text: str) -> str:
        return self._wrap("33", text)

    def dim(self, text: str) -> str:
        return self._wrap("2", text)

    def cyan(self, text: str) -> str:
        return self._wrap("36", text)


# ── Dependency checking ─────────────────────────────────────────────


def _check_dependency(
    name: str,
    version_cmd: list[str],
    min_version: tuple[int, ...] | None = None,
) -> tuple[bool, str]:
    """Check a single dependency is installed and meets version requirements.

    Args:
        name: Human-readable dependency name.
        version_cmd: Command to run to get version output.
        min_version: Minimum required version tuple, or None to skip
            version check.

    Returns:
        ``(ok, detail)`` — *ok* is True when the check passes, *detail*
        is a human-readable status string (no leading indent).
    """
    path = shutil.which(name)
    if path is None:
        return False, f"{name}: not found"

    try:
        result = subprocess.run(
            version_cmd,
            capture_output=True,
            text=True,
            timeout=10,
        )
        raw = result.stdout.strip() or result.stderr.strip()
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return False, f"{name}: found at {path} but failed to get version"

    try:
        version = _parse_version(raw)
    except ValueError:
        return False, f"{name}: cannot parse version from: {raw}"

    if min_version and version < min_version:
        got = _fmt_version(version)
        want = _fmt_version(min_version)
        return False, f"{name}: {got} (need >= {want})"

    version_str = _fmt_version(version)
    if min_version:
        return True, f"{name}: {version_str} (>= {_fmt_version(min_version)})"
    return True, f"{name}: {version_str}"


# Required cgroup v2 controllers for container resource limits.
_REQUIRED_CGROUP_CONTROLLERS = ("cpu", "memory", "pids")


def _check_cgroups_v2() -> tuple[bool, str]:
    """Check if cgroup v2 controllers are delegated to the current user.

    Container resource limits (``--memory``, ``--cpus``, ``--pids-limit``)
    require the ``cpu``, ``memory``, and ``pids`` controllers to be
    delegated in the user's cgroup slice.

    Returns:
        ``(ok, detail)`` — *ok* is True when all required controllers
        are available, *detail* is a human-readable status string.
    """
    uid = os.getuid()
    cgroup_path = Path(
        f"/sys/fs/cgroup/user.slice/user-{uid}.slice/cgroup.controllers"
    )

    if not cgroup_path.exists():
        return False, (
            "cgroups v2 controllers not found "
            "(resource limits require cgroup v2 delegation)"
        )

    try:
        controllers = cgroup_path.read_text().strip().split()
    except OSError:
        return False, "cgroups v2: unable to read controller list"

    missing = [c for c in _REQUIRED_CGROUP_CONTROLLERS if c not in controllers]
    if missing:
        return False, (
            f"cgroups v2: missing controllers: {', '.join(missing)} "
            f"(have: {', '.join(controllers)})"
        )

    present = " ".join(
        c for c in controllers if c in _REQUIRED_CGROUP_CONTROLLERS
    )
    return True, f"cgroups v2: delegated ({present})"


# ── init subcommand ─────────────────────────────────────────────────


_DOCS_BASE = "https://github.com/airutorg/airut/blob/main"


def cmd_init(argv: list[str]) -> int:
    """Create a stub server configuration file.

    Creates ``~/.config/airut/airut.yaml`` with a minimal commented
    template if the file does not already exist.  When a new file is
    created, prints links to the deployment and repo-onboarding guides
    to help the user complete configuration.

    Args:
        argv: Extra arguments (currently unused).

    Returns:
        Exit code (always 0).
    """
    config_path = get_config_path()

    if config_path.exists():
        print(f"Config already exists: {config_path}")
        return 0

    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(_STUB_CONFIG)
    print(f"Created stub config: {config_path}")
    print()
    print("Next steps — complete your configuration:")
    print(f"  Deployment guide:      {_DOCS_BASE}/doc/deployment.md")
    print(f"  Repo onboarding guide: {_DOCS_BASE}/doc/repo-onboarding.md")
    return 0


# ── Service status helpers ──────────────────────────────────────────


def _is_service_installed() -> bool:
    """Return True if the airut systemd user unit file exists."""
    from airut.install_services import get_systemd_user_dir

    unit_path = get_systemd_user_dir() / "airut.service"
    return unit_path.exists()


def _is_service_running() -> bool:
    """Return True if airut.service is active (running)."""
    try:
        result = subprocess.run(
            ["systemctl", "--user", "is-active", "airut.service"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        return result.stdout.strip() == "active"
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return False


def _dashboard_url(config: ServerConfig) -> str:
    """Return the public dashboard URL from config.

    Uses ``dashboard_base_url`` if set, otherwise falls back to the
    bound address and port.  Suitable for display purposes and links.
    """
    gc = config.global_config
    if gc.dashboard_base_url:
        return gc.dashboard_base_url
    return _local_dashboard_url(config)


def _local_dashboard_url(config: ServerConfig) -> str:
    """Return the local dashboard URL from the bound address and port.

    Always uses ``dashboard_host`` and ``dashboard_port``, ignoring
    ``dashboard_base_url``.  Use this for internal requests that must
    bypass any authenticating reverse proxy.
    """
    gc = config.global_config
    return f"http://{gc.dashboard_host}:{gc.dashboard_port}"


def _fetch_running_version(
    base_url: str,
) -> dict[str, str] | None:
    """Fetch version info from the running dashboard server.

    Args:
        base_url: Dashboard base URL (e.g. ``http://127.0.0.1:5200``).

    Returns:
        Parsed JSON dict with ``version``, ``sha_short``, ``sha_full``
        keys, or None if the request fails.
    """
    url = f"{base_url.rstrip('/')}/api/version"
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=3) as resp:
            data: dict[str, str] = json.loads(resp.read().decode())
            return data
    except (
        urllib.error.URLError,
        OSError,
        json.JSONDecodeError,
        ValueError,
    ):
        return None


def _fetch_health(
    base_url: str,
) -> dict[str, Any] | None:
    """Fetch health status from the running dashboard server.

    Args:
        base_url: Dashboard base URL (e.g. ``http://127.0.0.1:5200``).

    Returns:
        Parsed JSON dict with ``status``, ``tasks``, ``repos`` keys,
        or None if the request fails.
    """
    url = f"{base_url.rstrip('/')}/api/health"
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=3) as resp:
            data: dict[str, Any] = json.loads(resp.read().decode())
            return data
    except (
        urllib.error.URLError,
        OSError,
        json.JSONDecodeError,
        ValueError,
    ):
        return None


#: Suggested command to restart the systemd user service.
_RESTART_CMD = "systemctl --user restart airut"


# ── check subcommand ────────────────────────────────────────────────


def cmd_check(argv: list[str]) -> int:
    """Run readiness checks and print installation status.

    Displays version information, configuration status, system
    dependencies, and service health.  Dependency and configuration
    failures affect the exit code; service status is informational only.

    Args:
        argv: Extra arguments (currently unused).

    Returns:
        0 if all checks pass, 1 if any check fails.
    """
    s = _Style(_use_color())
    all_ok = True

    # ── Version ─────────────────────────────────────────────────
    from airut.version import check_upstream_version, get_git_version_info

    vi = get_git_version_info()
    version_label = vi.version or vi.sha_short
    print(s.bold(f"Airut {version_label}"))

    upstream = check_upstream_version(vi)
    if upstream and upstream.update_available:
        if upstream.source == "pypi":
            print(
                f"  {s.yellow('Update available:')} "
                f"{upstream.current} → {upstream.latest}"
            )
            print(f"  Run {s.cyan('airut update')} to update.")
        else:
            short_current = upstream.current[:7]
            short_latest = upstream.latest[:7]
            print(
                f"  {s.yellow('Update available:')} "
                f"{short_current} → {short_latest}"
            )
            print(f"  Run {s.cyan('airut update')} to update.")
    print()

    # ── Configuration ───────────────────────────────────────────
    print(s.bold("Configuration"))
    config_path = get_config_path()
    print(f"  Config file: {s.dim(str(config_path))}")

    config: ServerConfig | None = None
    if not config_path.exists():
        print(f"  Status:      {s.red('not found')}")
        print(f"  Run {s.cyan('airut init')} to create a stub config.")
        all_ok = False
    else:
        try:
            config = ServerConfig.from_yaml(config_path)
            repo_ids = ", ".join(sorted(config.repos))
            print(
                f"  Status:      {s.green('ok')} — "
                f"{len(config.repos)} repo(s) ({repo_ids})"
            )
        except (ConfigError, ValueError, Exception) as e:
            print(f"  Status:      {s.red('error')} — {e}")
            all_ok = False

    dotenv_path = get_dotenv_path()
    if dotenv_path.exists():
        print(f"  Env file:    {s.dim(str(dotenv_path))}")

    print()

    # ── System dependencies ─────────────────────────────────────
    print(s.bold("Dependencies"))
    for name, version_cmd, min_ver in [
        ("git", ["git", "--version"], _MIN_VERSIONS["git"]),
        ("podman", ["podman", "--version"], _MIN_VERSIONS["podman"]),
    ]:
        ok, detail = _check_dependency(name, version_cmd, min_ver)
        if ok:
            print(f"  {s.green('✓')} {detail}")
        else:
            print(f"  {s.red('✗')} {detail}")
            all_ok = False
    print()

    # ── Cgroups (informational) ─────────────────────────────────
    print(s.bold("Cgroups"))
    cgroup_ok, cgroup_detail = _check_cgroups_v2()
    if cgroup_ok:
        print(f"  {s.green('✓')} {cgroup_detail}")
    else:
        print(f"  {s.yellow('!')} {cgroup_detail}")
    print()

    # ── Service status (informational) ──────────────────────────
    print(s.bold("Service"))
    if _is_service_installed():
        if _is_service_running():
            label = s.green("running")
            if config:
                url = _dashboard_url(config)
                label += f"  {s.dim(url)}"
            print(f"  airut.service: {label}")

            # Check running version against local version
            if config:
                local_url = _local_dashboard_url(config)
                rv = _fetch_running_version(local_url)
                if rv:
                    running_sha = rv.get("sha_short", "")
                    if running_sha and running_sha != vi.sha_short:
                        rl = rv.get("version") or running_sha
                        print(
                            f"  {s.yellow('Version mismatch:')} "
                            f"running {rl}, "
                            f"installed {version_label}"
                        )
                        print(f"  Run {s.cyan(_RESTART_CMD)} to apply.")
        else:
            print(f"  airut.service: {s.yellow('stopped')}")
    else:
        print(f"  airut.service: {s.yellow('not installed')}")
        print(f"  Run {s.cyan('airut install-service')} to install.")
    print()

    # ── Summary ─────────────────────────────────────────────────
    if all_ok:
        print(s.green("All checks passed."))
    else:
        print(s.red("Some checks failed."))

    return 0 if all_ok else 1


# ── update subcommand ───────────────────────────────────────────────


def _get_active_task_counts() -> tuple[int, int] | None:
    """Query the running gateway for active task counts.

    Loads the server config to determine the local dashboard address,
    then fetches ``/api/health`` to get task counts.

    Returns:
        ``(running, waiting)`` tuple, or None if the service is not
        reachable or not configured.  ``running`` counts executing +
        authenticating tasks; ``waiting`` counts queued + pending.
    """
    config_path = get_config_path()
    if not config_path.exists():
        return None

    try:
        config = ServerConfig.from_yaml(config_path)
    except (ConfigError, ValueError, Exception):
        return None

    local_url = _local_dashboard_url(config)
    health = _fetch_health(local_url)
    if health is None:
        return None

    tasks = health.get("tasks")
    if not isinstance(tasks, dict):
        return None

    executing = tasks.get("executing", 0)
    queued = tasks.get("queued", 0)
    # authenticating and pending may be absent on older servers that
    # only report executing/queued/completed — default to 0.
    authenticating = tasks.get("authenticating", 0)
    pending = tasks.get("pending", 0)

    # Validate all four fields uniformly.
    for value in (executing, queued, authenticating, pending):
        if not isinstance(value, int):
            return None

    # Count all non-completed statuses as active.
    running = executing + authenticating
    waiting = queued + pending
    return running, waiting


def _has_version_mismatch() -> bool:
    """Check if the running service version differs from the installed one.

    Loads the server config, fetches the running version from the
    dashboard ``/api/version`` endpoint, and compares SHA hashes.

    Returns:
        True if the running service has a different SHA than the
        installed binary, False otherwise (including when the check
        cannot be performed).
    """
    from airut.version import get_git_version_info

    config_path = get_config_path()
    if not config_path.exists():
        return False

    try:
        config = ServerConfig.from_yaml(config_path)
    except (ConfigError, ValueError, Exception):
        return False

    local_url = _local_dashboard_url(config)
    rv = _fetch_running_version(local_url)
    if rv is None:
        return False

    vi = get_git_version_info()
    running_sha = rv.get("sha_short", "")
    return bool(running_sha and running_sha != vi.sha_short)


_WAIT_POLL_SECONDS = 10


def cmd_update(argv: list[str]) -> int:
    """Update airut to the latest version.

    Runs ``uv tool upgrade airut``.  When the binary is actually updated
    and the service was installed, stops the service immediately after
    upgrade to prevent a stale process from running old code.  Then runs
    ``airut check`` to verify configuration and dependencies.  If the
    check passes, reinstalls the service.  If it fails, leaves the
    service stopped and instructs the user to fix the issues and run
    ``airut install-service`` manually.

    If the upgrade is a no-op (already up to date) but the running
    service has a different version than the installed binary, restarts
    the service to apply the change.

    Refuses to proceed when the running gateway has queued or in-progress
    tasks.  Use ``--force`` to override, or ``--wait`` to poll until the
    gateway is idle.

    Args:
        argv: Extra arguments (``--force``, ``--wait``).

    Returns:
        Exit code (0 on success, 1 on error).
    """
    s = _Style(_use_color())
    force = "--force" in argv
    wait = "--wait" in argv
    service_was_installed = _is_service_installed()

    # ── Pre-flight: check for tasks in flight ──────────────────
    if service_was_installed and _is_service_running() and not force:
        counts = _get_active_task_counts()
        if counts is not None:
            running, waiting = counts
            active = running + waiting

            if active > 0 and wait:
                print(
                    f"  {s.yellow('Tasks in flight:')} "
                    f"{running} running, {waiting} queued."
                )
                print(f"  {s.dim('Waiting for tasks to complete...')}")

                while active > 0:
                    time.sleep(_WAIT_POLL_SECONDS)
                    counts = _get_active_task_counts()
                    if counts is None:
                        # Service went away — safe to proceed.
                        break
                    running, waiting = counts
                    active = running + waiting
                    if active > 0:
                        print(
                            f"  {
                                s.dim(
                                    f'  Still waiting: '
                                    f'{running} running, '
                                    f'{waiting} queued...'
                                )
                            }"
                        )

                print(f"  {s.green('All tasks completed.')}")

            elif active > 0:
                print(
                    f"  {s.red('Cannot update:')} "
                    f"{running} running, {waiting} queued task(s)."
                )
                print(
                    f"  Use {s.cyan('airut update --wait')} to wait, "
                    f"or {s.cyan('airut update --force')} to update anyway."
                )
                return 1

    # ── Run uv tool upgrade ───────────────────────────────────
    print(f"  {s.dim('Upgrading airut...')}")
    try:
        result = subprocess.run(
            ["uv", "tool", "upgrade", "airut"],
            capture_output=True,
            text=True,
            timeout=120,
        )
    except FileNotFoundError:
        print(f"  {s.red('Error:')} uv not found on PATH.")
        return 1
    except subprocess.TimeoutExpired:
        print(f"  {s.red('Error:')} uv tool upgrade timed out.")
        return 1

    if result.returncode != 0:
        detail = result.stderr.strip() or result.stdout.strip()
        print(f"  {s.red('Error:')} uv tool upgrade failed.")
        if detail:
            print(f"  {detail}")
        return 1

    upgrade_output = result.stdout.strip() or result.stderr.strip()
    if upgrade_output:
        print(f"  {upgrade_output}")

    # Detect no-op upgrades to avoid unnecessary service restart.
    # uv outputs "Nothing to upgrade" when the installed version is current.
    nothing_changed = "nothing to upgrade" in (upgrade_output or "").lower()

    if nothing_changed:
        # Binary is current, but the running service may still be stale.
        if service_was_installed and _is_service_running():
            if _has_version_mismatch():
                print(
                    f"  {
                        s.dim(
                            'Binary is current, '
                            'but running service needs a restart.'
                        )
                    }"
                )
            else:
                print(f"  {s.green('Already up to date.')}")
                return 0
        else:
            print(f"  {s.green('Already up to date.')}")
            return 0

    # ── Stop service before readiness check ──────────────────
    #
    # Once the binary has been replaced, the running service is stale.
    # Stop it now so there is no window where old code keeps running.
    # If the readiness check fails, the service stays stopped (no stale
    # process) and the user is told to run ``airut install-service``
    # after fixing the issue.
    if service_was_installed:
        print(f"  {s.dim('Stopping service...')}")
        from airut.install_services import uninstall_services

        try:
            uninstall_services()
        except RuntimeError as e:
            print(f"  {s.red('Error stopping service:')} {e}")
            return 1

    # ── Run readiness check with the updated binary ────────────
    from airut.install_services import get_airut_path

    airut_path = get_airut_path()
    print(f"  {s.dim('Running readiness check...')}")
    try:
        check_result = subprocess.run(
            [airut_path, "check"],
            capture_output=True,
            text=True,
            timeout=30,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as e:
        print(f"  {s.red('Readiness check failed:')} {e}")
        if service_was_installed:
            print(
                f"  {s.yellow('Service is stopped.')} "
                f"Fix the issues above, then run "
                f"{s.cyan('airut install-service')} to start the service."
            )
        else:
            print(
                f"  Fix the issues above, then run "
                f"{s.cyan('airut install-service')} to start the service."
            )
        return 1

    if check_result.returncode != 0:
        check_output = (
            check_result.stdout.strip() or check_result.stderr.strip()
        )
        print(f"  {s.red('Readiness check failed:')}")
        if check_output:
            for line in check_output.splitlines():
                print(f"    {line}")
        print()
        if service_was_installed:
            print(
                f"  {s.yellow('Service is stopped.')} "
                f"Fix the issues above, then run "
                f"{s.cyan('airut install-service')} to start the service."
            )
        else:
            print(
                f"  Fix the issues above, then run "
                f"{s.cyan('airut install-service')} to start the service."
            )
        return 1

    # ── Reinstall service with the updated binary ─────────────
    if service_was_installed:
        print(f"  {s.dim('Reinstalling service...')}")
        try:
            result = subprocess.run(
                [airut_path, "install-service"],
                capture_output=True,
                text=True,
                timeout=30,
            )
        except (FileNotFoundError, subprocess.TimeoutExpired) as e:
            print(f"  {s.red('Error reinstalling service:')} {e}")
            return 1

        if result.returncode != 0:
            detail = result.stderr.strip() or result.stdout.strip()
            print(f"  {s.red('Error reinstalling service.')}")
            if detail:
                print(f"  {detail}")
            return 1

    print(f"  {s.green('Update complete.')}")
    return 0


# ── run-gateway subcommand ──────────────────────────────────────────


def cmd_run_gateway(argv: list[str]) -> int:
    """Start the email gateway service.

    Args:
        argv: Arguments forwarded to the gateway (e.g. --resilient --debug).
    """
    from airut.gateway.service import main as gateway_main

    return gateway_main(argv)


# ── install-service subcommand ──────────────────────────────────────


def cmd_install_service(argv: list[str]) -> int:
    """Install systemd user services for Airut.

    Installs ``airut.service`` (email gateway).

    Args:
        argv: Extra arguments (currently unused).

    Returns:
        Exit code (0 on success, 1 on error).
    """
    from airut.install_services import install_services

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    try:
        install_services()
        return 0
    except RuntimeError as e:
        logger.error("%s", e)
        return 1


# ── uninstall-service subcommand ────────────────────────────────────


def cmd_uninstall_service(argv: list[str]) -> int:
    """Uninstall systemd user services for Airut.

    Args:
        argv: Extra arguments (currently unused).

    Returns:
        Exit code (0 on success, 1 on error).
    """
    from airut.install_services import uninstall_services

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    try:
        uninstall_services()
        return 0
    except RuntimeError as e:
        logger.error("%s", e)
        return 1


# ── CLI plumbing ────────────────────────────────────────────────────


_DISPATCH: dict[str, str] = {
    "run-gateway": "cmd_run_gateway",
    "init": "cmd_init",
    "check": "cmd_check",
    "update": "cmd_update",
    "install-service": "cmd_install_service",
    "uninstall-service": "cmd_uninstall_service",
}

_SUBCOMMAND_HELP: dict[str, str] = {
    "init": (
        "usage: airut init\n\n"
        "Create a stub server config at ~/.config/airut/airut.yaml.\n"
        "Does nothing if the config already exists."
    ),
    "check": (
        "usage: airut check\n\n"
        "Verify config, system dependencies, and service status.\n"
        "Exit code 0 if all checks pass, 1 otherwise."
    ),
    "update": (
        "usage: airut update [--wait] [--force]\n\n"
        "Update airut to the latest version via uv tool upgrade.\n"
        "If already up to date, exits without restarting the service.\n\n"
        "options:\n"
        "  --wait   Wait for running tasks to complete before updating\n"
        "  --force  Update even if tasks are in flight"
    ),
    "install-service": (
        "usage: airut install-service\n\n"
        "Install and start the airut systemd user service."
    ),
    "uninstall-service": (
        "usage: airut uninstall-service\n\n"
        "Stop and remove the airut systemd user service."
    ),
    "run-gateway": (
        "usage: airut run-gateway [--config PATH] [--resilient]"
        " [--debug]\n\n"
        "Start the email gateway service"
        " (normally managed by systemd).\n\n"
        "options:\n"
        "  --config PATH  Path to airut.yaml config file\n"
        "                 (default: ~/.config/airut/airut.yaml)\n"
        "  --resilient    Stay alive on boot failure"
        " (error on dashboard)\n"
        "  --debug        Enable debug logging"
    ),
}


def _print_info() -> None:
    """Print version information and available commands."""
    from airut.version import get_git_version_info

    s = _Style(_use_color())
    vi = get_git_version_info()
    version_label = vi.version or vi.sha_short
    print(s.bold(f"Airut {version_label}"))
    print()
    print(_USAGE)


def cli() -> None:
    """Entry point for ``airut`` (via ``uv tool install`` or ``uv run``).

    When no arguments are given, prints version and usage information.
    Requires an explicit subcommand for all operations.
    """
    argv = sys.argv[1:]

    if not argv or argv[0] == "--help":
        _print_info()
        sys.exit(0)

    if argv[0] not in _SUBCOMMANDS:
        print(f"airut: unknown command '{argv[0]}'", file=sys.stderr)
        print(_USAGE, file=sys.stderr)
        sys.exit(2)

    command = argv[0]
    rest = argv[1:]

    if "--help" in rest and command in _SUBCOMMAND_HELP:
        print(_SUBCOMMAND_HELP[command])
        sys.exit(0)

    # Look up handler by name so tests can mock individual commands.
    import airut.cli as _self

    handler = getattr(_self, _DISPATCH[command])
    sys.exit(handler(rest))


#: Stub configuration template written by ``airut init``.
_STUB_CONFIG = """\
# Airut Server Configuration
#
# For all available options, see the documented example:
# https://github.com/airutorg/airut/blob/main/config/airut.example.yaml

# execution:
#   max_concurrent: 3
#   shutdown_timeout: 60

# dashboard:
#   enabled: true
#   host: 127.0.0.1
#   port: 5200

# container_command: podman

repos:
  my-project:
    email:
      imap_server: mail.example.com
      smtp_server: mail.example.com
      username: airut@example.com
      password: changeme
      from: "Airut <airut@example.com>"
    authorized_senders:
      - you@example.com
    trusted_authserv_id: mail.example.com
    git:
      repo_url: https://github.com/you/my-project.git
    secrets:
      ANTHROPIC_API_KEY: changeme
"""
