"""LLMHosts CLI -- command-line interface entrypoint.

Uses Click for the command group. ``serve`` starts the full proxy server,
``doctor`` runs diagnostic checks, and ``keys`` manages BYOK API keys.
"""

from __future__ import annotations

import asyncio
import os
import sys

# Force UTF-8 output on Windows to prevent UnicodeEncodeError with Rich's
# Unicode symbols (✓, ✗, ⚠, etc.) when running through SSH or legacy consoles
# that default to cp1252.
if sys.platform == "win32":
    os.environ.setdefault("PYTHONIOENCODING", "utf-8")
    if hasattr(sys.stdout, "reconfigure"):
        try:
            sys.stdout.reconfigure(encoding="utf-8", errors="replace")
            sys.stderr.reconfigure(encoding="utf-8", errors="replace")
        except (AttributeError, OSError):
            pass
from datetime import datetime  # noqa: F401 -- needed for Pydantic model_rebuild
from typing import TYPE_CHECKING, Any

import click

if TYPE_CHECKING:
    from collections.abc import Coroutine
    from pathlib import Path

    from alembic.config import Config as AlembicConfig
from rich.console import Console
from rich.table import Table

from llmhosts import __version__
from llmhosts.discovery.models import HardwareProfile, OllamaHealth
from llmhosts.keys.manager import KeyManager
from llmhosts.keys.models import SUPPORTED_PROVIDERS

# Fix Pydantic forward-reference resolution for models with datetime in TYPE_CHECKING
HardwareProfile.model_rebuild()
OllamaHealth.model_rebuild()

console = Console()


def _run_async(coro: Coroutine[Any, Any, Any]) -> Any:
    """Run an async coroutine from synchronous Click context."""
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        loop = None

    if loop and loop.is_running():
        # Nested event loop -- fall back to new loop in thread
        import concurrent.futures

        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
            return pool.submit(asyncio.run, coro).result()
    return asyncio.run(coro)


# ---- Root group ----


@click.group(invoke_without_command=True)
@click.version_option(version=__version__, prog_name="llmhosts")
@click.option("--debug", is_flag=True, default=False, hidden=True, help="Show full tracebacks on error")
@click.pass_context
def main(ctx: click.Context, debug: bool) -> None:
    """LLMHosts -- Your Personal AI Cloud.

    Intelligent proxy, router, and cache for LLMs.
    Run 'llmhosts serve' to start, or 'llmhosts --help' for all commands.
    """
    ctx.ensure_object(dict)
    ctx.obj["debug"] = debug
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())


# ---- Serve command ----


@main.command()
@click.option("--host", default=None, help="Bind address (default: 127.0.0.1)")
@click.option("--port", default=None, type=int, help="Port number (default: 4000)")
@click.option("--config", "config_path", default=None, type=click.Path(exists=True), help="Config file path")
@click.option("--no-tui", is_flag=True, help="Disable TUI dashboard")
@click.option("--interactive", "-i", is_flag=True, help="Interactively select models before serving")
@click.option(
    "--log-level",
    default=None,
    type=click.Choice(["debug", "info", "warning", "error"], case_sensitive=False),
    help="Log level",
)
@click.option("--dry-run", is_flag=True, help="Show what would be discovered without starting the server")
@click.pass_context
def serve(
    ctx: click.Context,
    host: str | None,
    port: int | None,
    config_path: str | None,
    no_tui: bool,
    interactive: bool,
    log_level: str | None,
    dry_run: bool,
) -> None:
    """Start the LLMHosts proxy server.

    Discovers inference engines, loads BYOK keys, starts the proxy + dashboard.

    \b
    Examples:
        llmhosts serve
        llmhosts serve --interactive
        llmhosts serve --port 8080
        llmhosts serve --config ./my-config.toml --no-tui
    """
    import logging
    from pathlib import Path

    from llmhosts.banner import print_banner
    from llmhosts.config import load_config
    from llmhosts.errors import print_error
    from llmhosts.server import LLMHostsServer

    debug = (ctx.obj or {}).get("debug", False)

    # 1. Load config (file + defaults + env overrides)
    cfg_path = Path(config_path) if config_path else None
    config = load_config(cfg_path)

    # 2. Apply CLI overrides on top of config
    if host is not None:
        config.server.host = host
    if port is not None:
        config.server.port = port
    if log_level is not None:
        config.server.log_level = log_level
    if no_tui:
        config.dashboard.tui = False

    # 3. Interactive model selection (before starting server)
    if interactive:
        from llmhosts.interactive import discover_and_select, is_interactive

        if not is_interactive():
            console.print("[dim]Non-interactive terminal detected, skipping model picker.[/dim]")
        else:
            selected = _run_async(discover_and_select(console, ollama_host=config.ollama.host))
            if selected:
                # Store selection in config for this session
                config.selected_models = [m.name for m in selected]
            else:
                console.print("[dim]No models selected, serving all available models.[/dim]")

    # 4. Configure logging
    logging.basicConfig(
        level=getattr(logging, config.server.log_level.upper(), logging.INFO),
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%H:%M:%S",
    )

    # 5. Pre-flight: adaptive port selection (auto-finds a free port)
    from llmhosts.ports import find_available_port

    actual_port = find_available_port(config.server.port)
    if actual_port != config.server.port:
        console.print(
            f"  [yellow]Port {config.server.port} is in use[/yellow] — auto-selected port [bold]{actual_port}[/bold]"
        )
        config.server.port = actual_port

    # 6. Run async startup
    server = LLMHostsServer(config)
    try:
        report = _run_async(server.startup())
    except Exception as exc:
        print_error(console, "STARTUP_FAILED", debug=debug, exc=exc, detail=str(exc))

    # 7. Dry-run: show discovery results and exit
    if dry_run:
        from llmhosts.server import LLMHostsServer as _LLMHostsServer

        console.print("\n[bold green]Dry-run complete.[/bold green] The following was discovered:")
        console.print(f"  Inference: {'Available' if report.ollama_available else 'Not found'}")
        if report.ollama_models:
            console.print(f"  Models: {', '.join(report.ollama_models)}")
        console.print(f"  Hardware: {report.hardware_summary or 'Unknown'}")
        console.print(f"  Router tier: {report.router_tier}")
        console.print(f"  Cache tier: {report.cache_tier}")
        console.print(f"\n[dim]{_LLMHostsServer.read_only_notice()}[/dim]")
        return

    # 8. Print the startup banner
    print_banner(report, config)

    # 9. Start uvicorn (blocking)
    try:
        server.run()
    except KeyboardInterrupt:
        console.print("\n[dim]Shutting down…[/dim]")
    finally:
        _run_async(server.shutdown())


# ---- Pull command ----


@main.command()
@click.argument("model_name", required=False)
@click.option("--auto", is_flag=True, help="Pull the recommended model for your hardware")
@click.option("--list", "list_models", is_flag=True, help="List available models")
@click.pass_context
def pull(ctx: click.Context, model_name: str | None, auto: bool, list_models: bool) -> None:
    """Download a GGUF model for local inference.

    Downloads models directly from HuggingFace. Use --list to see available
    models, --auto to pick the best model for your hardware, or specify a
    model name directly.

    \b
    Examples:
        llmhosts pull --list
        llmhosts pull --auto
        llmhosts pull qwen2.5:7b
        llmhosts pull llama3.1:8b
    """
    from llmhosts.models.download import download_model, list_local_models
    from llmhosts.models.registry import MODELS, recommend_for_vram
    from llmhosts.models.registry import list_models as _list_models

    debug = (ctx.obj or {}).get("debug", False)

    # --list: show all available models with local status
    if list_models:
        local = set(list_local_models())
        console.print("[bold]Available models:[/bold]\n")
        for name in _list_models():
            info = MODELS[name]
            marker = "[green]\u2713[/green]" if name in local else " "
            console.print(f"  [{marker}] {name}  [dim]({info['size_gb']}GB)[/dim]")
        console.print("\n[dim]\u2713 = downloaded locally[/dim]")
        return

    # --auto: detect hardware and recommend a model
    if auto:
        vram_mb = 0
        try:
            from llmhosts.inference.gpu import detect_gpus

            info = detect_gpus()
            vram_mb = info.get("total_free_vram_mb", 0)
        except Exception:
            pass
        rec = recommend_for_vram(vram_mb)
        if rec is None:
            console.print("[red]Error:[/red] No models available in registry.")
            raise SystemExit(1)
        model_name = rec["name"]
        console.print(f"[bold]Recommended:[/bold] {model_name} ({rec['size_gb']}GB)")

    # No model specified and no flags
    if not model_name:
        console.print("Usage: llmhosts pull <model> or llmhosts pull --auto")
        console.print("[dim]Run 'llmhosts pull --list' to see available models.[/dim]")
        raise SystemExit(1)

    # VRAM compatibility warning
    model_info = MODELS.get(model_name)
    if model_info:
        try:
            from llmhosts.discovery.hardware import HardwareDetector

            hardware = _run_async(HardwareDetector.detect())
            if hardware.has_gpu and hardware.largest_gpu_vram_mb > 0:
                effective_gb = hardware.largest_gpu_vram_mb / 1024.0
                if model_info["min_vram_gb"] > effective_gb:
                    console.print(
                        f"[yellow]Warning:[/yellow] {model_name} needs ~{model_info['min_vram_gb']}GB VRAM "
                        f"but your GPU has {effective_gb:.0f}GB. Performance may be degraded."
                    )
            else:
                ram_gb = hardware.ram_total_gb
                if model_info["size_gb"] > ram_gb / 2:
                    console.print(
                        f"[yellow]Warning:[/yellow] {model_name} ({model_info['size_gb']}GB) is large "
                        f"for CPU-only inference ({ram_gb:.0f}GB RAM). Performance may be slow."
                    )
        except Exception:
            pass  # Hardware detection is best-effort

    # Download
    try:
        console.print(f"\n[bold]Downloading {model_name}...[/bold]")
        path = download_model(model_name)
        console.print(f"\n[green]Model ready:[/green] {model_name}")
        console.print(f"[dim]Saved to {path}[/dim]")
        console.print(
            "[dim]Test it: curl http://localhost:4000/v1/chat/completions "
            '-d \'{"model": "' + model_name + '", "messages": [{"role": "user", "content": "hello"}]}\'[/dim]'
        )
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        console.print("[dim]Run 'llmhosts pull --list' to see available models.[/dim]")
        raise SystemExit(1) from exc
    except RuntimeError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        raise SystemExit(1) from exc
    except KeyboardInterrupt:
        console.print("\n[dim]Download cancelled.[/dim]")
    except Exception as exc:
        if debug:
            console.print_exception()
        else:
            console.print(f"[red]Error:[/red] {exc}")
        raise SystemExit(1) from exc


# ---- Up command ----


@main.command()
@click.option("--port", default=11400, type=int, help="Local proxy port (default: 11400)")
@click.option(
    "--server",
    default="https://llmhosts.com",
    envvar="LLMHOSTS_SERVER",
    help="LLMHosts server URL",
)
@click.option("--name", default=None, help="Device name (default: hostname)")
@click.option("--dry-run", is_flag=True, help="Show what would be discovered and provisioned without starting")
@click.pass_context
def up(ctx: click.Context, port: int, server: str, name: str | None, dry_run: bool) -> None:
    """Turn your GPU into a cloud API in 30 seconds.

    Starts the proxy, opens a tunnel, authenticates with LLMHosts.com,
    registers this device, and makes your GPU accessible as an API.

    \b
    Examples:
        llmhosts up
        llmhosts up --port 8080
        llmhosts up --server http://localhost:3000
    """
    import logging

    from llmhosts.up import LLMHostUp

    debug = (ctx.obj or {}).get("debug", False)

    logging.basicConfig(
        level=logging.DEBUG if debug else logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%H:%M:%S",
    )

    orchestrator = LLMHostUp(
        console=console,
        server_url=server,
        port=port,
        dry_run=dry_run,
    )

    if name:
        orchestrator._device_name = name

    try:
        _run_async(orchestrator.run())
    except KeyboardInterrupt:
        console.print("\n[dim]Goodbye.[/dim]")
    except Exception as exc:
        if debug:
            console.print_exception()
        else:
            console.print(f"[red]Error:[/red] {exc}")
        raise SystemExit(1) from exc


# ---- Doctor command ----


@main.command()
@click.option("--fix", is_flag=True, help="Attempt to auto-fix detected issues")
def doctor(fix: bool) -> None:
    """Verify LLMHosts setup and dependencies.

    Checks Python, inference engine, GPU, keys, proxy port, installed tier,
    disk space, hardware capabilities, database, and cache.

    Use --fix to attempt automatic remediation of detected issues.
    """
    import rich.console as rich_console

    from llmhosts.accessibility.diagnostics import DiagnosticRunner, Severity
    from llmhosts.banner import print_doctor_report
    from llmhosts.doctor import Doctor

    # Run the existing comprehensive doctor checks
    doc = Doctor()
    results = _run_async(doc.run_all())
    print_doctor_report(results)

    # Also run the plain-English accessibility diagnostics
    console = rich_console.Console()
    runner = DiagnosticRunner()

    console.print()
    console.print("[bold]Plain-English System Check[/bold]\n")
    diag_results = runner.run_all()

    icons = {
        Severity.OK: "✅",
        Severity.WARNING: "⚠️ ",
        Severity.ERROR: "❌",
        Severity.INFO: "i  ",
    }
    colors = {
        Severity.OK: "green",
        Severity.WARNING: "yellow",
        Severity.ERROR: "red",
        Severity.INFO: "blue",
    }

    for r in diag_results:
        icon = icons[r.severity]
        color = colors[r.severity]
        console.print(f"  {icon} [{color}]{r.name}[/{color}]: {r.message}")
        if r.fix_hint and r.severity in (Severity.WARNING, Severity.ERROR):
            console.print(f"     [dim]→ {r.fix_hint}[/dim]")

    errors = [r for r in diag_results if r.severity == Severity.ERROR]
    warnings = [r for r in diag_results if r.severity == Severity.WARNING]

    console.print()
    if not errors and not warnings:
        console.print("[bold green]All checks passed! You're good to go.[/bold green]")
    elif errors:
        console.print(f"[bold red]{len(errors)} issue(s) need attention before LLMHosts will work.[/bold red]")
    else:
        console.print(f"[bold yellow]{len(warnings)} warning(s) — LLMHosts will work but may be limited.[/bold yellow]")

    if fix:
        console.print()
        fixable = [r for r in diag_results if r.auto_fixable and r.fix_fn is not None]
        if fixable:
            console.print(f"[bold]Attempting to fix {len(fixable)} issue(s)...[/bold]")
            for r in fixable:
                try:
                    if r.fix_fn is not None:
                        r.fix_fn()
                    console.print(f"  ✅ Fixed: {r.name}")
                except Exception as exc:
                    console.print(f"  ❌ Could not fix {r.name}: {exc}")
        else:
            console.print("[dim]No auto-fixable issues found. Follow the hints above to resolve manually.[/dim]")


# ---- Uninstall command ----


@main.command()
@click.option("--keep-data", is_flag=True, default=False, help="Keep config and data files (only remove the CLI)")
@click.option("--remove-config", is_flag=True, default=False, help="Also remove config directory (~/.llmhosts)")
@click.option("--remove-engine", is_flag=True, default=False, help="Also remove the inference engine and its models")
@click.option(
    "--remove-ollama", is_flag=True, default=False, hidden=True, help="Alias for --remove-engine (deprecated)"
)
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt")
def uninstall(keep_data: bool, remove_config: bool, remove_engine: bool, remove_ollama: bool, yes: bool) -> None:
    """Cleanly uninstall LLMHosts and optionally the inference engine.

    By default, removes the llmhosts CLI tool and its data directory.
    Your inference engine and AI models are NEVER removed unless you
    explicitly pass --remove-engine. We respect your hardware.

    \b
    What's removed (default):
        - LLMHosts CLI tool (pip/uv package)
        - LLMHosts data directory (~/.llmhosts): config, API keys, cache, audit logs

    What's KEPT (always, unless --remove-engine):
        - Your inference engine (installed separately)
        - Your AI models (your data -- we never delete them)
        - Your GPU drivers and system configuration

    \b
    Options:
        --keep-data       Remove CLI only, keep config and data files
        --remove-config   Explicitly remove config (same as default, for clarity)
        --remove-engine   Remove the inference engine too (CAUTION: deletes models)

    \b
    Examples:
        llmhosts uninstall                   # Remove CLI + data, keep engine + models
        llmhosts uninstall --keep-data       # Remove CLI only, keep everything
        llmhosts uninstall --remove-engine   # Remove everything (including models)
        llmhosts uninstall -y                # Skip confirmation
    """
    import os
    import platform
    import shutil
    from pathlib import Path

    from rich.panel import Panel

    from llmhosts.constants import DATA_DIR_NAME

    # Support deprecated --remove-ollama as alias for --remove-engine
    remove_engine = remove_engine or remove_ollama

    # If --remove-config is passed, it's the same as not --keep-data
    if remove_config:
        keep_data = False

    system = platform.system().lower()
    data_dir = Path.home() / DATA_DIR_NAME

    # Show uninstall plan overview panel
    kept_lines = [
        "[bold green]KEPT[/bold green] (your data is safe):",
        "  [green]>[/green] Inference engine -- installed separately, not ours to remove",
        "  [green]>[/green] AI models -- your data, we never delete them",
        "  [green]>[/green] GPU drivers and system configuration",
    ]
    if keep_data:
        kept_lines.append(f"  [green]>[/green] Config and data ({data_dir})")

    removed_lines = [
        "[bold red]REMOVED[/bold red]:",
        "  [red]>[/red] LLMHosts CLI tool (pip/uv package)",
    ]
    if not keep_data:
        removed_lines.append(f"  [red]>[/red] LLMHosts data directory ({data_dir})")
        removed_lines.append("      Config, API keys, cache, audit logs")
    if remove_engine:
        removed_lines.append("  [red]>[/red] Inference engine and ALL AI models (CAUTION)")
        kept_lines = [
            "[bold yellow]KEPT[/bold yellow]:",
            "  [green]>[/green] GPU drivers and system configuration",
        ]

    console.print()
    console.print(
        Panel(
            "\n".join(removed_lines) + "\n\n" + "\n".join(kept_lines),
            title="[bold]Uninstall Plan[/bold]",
            border_style="cyan",
            padding=(1, 2),
        )
    )

    # UV tool dir is platform-dependent
    if system == "windows":
        uv_tool_dir = (
            Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "uv" / "tools" / "llmhosts"
        )
    else:
        uv_tool_dir = Path.home() / ".local" / "share" / "uv" / "tools" / "llmhosts"

    # ---- Build removal plan ----
    plan: list[tuple[str, str, str]] = []  # (label, path_or_cmd, type: "dir"|"cmd")

    # LLMHosts CLI (installed via uv tool)
    if shutil.which("uv"):
        plan.append(("LLMHosts CLI (via UV)", "uv tool uninstall llmhosts", "cmd"))
    elif uv_tool_dir.exists():
        plan.append(("LLMHosts tool directory", str(uv_tool_dir), "dir"))

    # Data directory
    if not keep_data and data_dir.exists():
        # Calculate size
        total_size = sum(f.stat().st_size for f in data_dir.rglob("*") if f.is_file())
        size_mb = total_size / (1024 * 1024)
        plan.append((f"Data directory ({size_mb:.1f} MB)", str(data_dir), "dir"))

    # Inference engine
    if remove_engine and shutil.which("ollama"):
        ollama_data = Path.home() / ".ollama"
        if ollama_data.exists():
            total_size = sum(f.stat().st_size for f in ollama_data.rglob("*") if f.is_file())
            size_gb = total_size / (1024 * 1024 * 1024)
            plan.append((f"Inference engine models ({size_gb:.1f} GB)", str(ollama_data), "dir"))

        if system == "linux":
            plan.append(("Inference engine binary", "sudo rm -f /usr/local/bin/ollama", "cmd"))
            plan.append(("Inference engine service", "sudo systemctl disable --now ollama 2>/dev/null || true", "cmd"))
        elif system == "darwin":
            plan.append(("Inference engine binary", "sudo rm -f /usr/local/bin/ollama", "cmd"))
            # macOS desktop app
            ollama_app = Path("/Applications/Ollama.app")
            if ollama_app.exists():
                plan.append(("Inference engine desktop app", str(ollama_app), "dir"))
            plan.append(("Inference engine service", "launchctl remove com.ollama.ollama 2>/dev/null || true", "cmd"))
        elif system == "windows":
            plan.append(("Inference engine (via winget)", "winget uninstall Ollama.Ollama", "cmd"))

    if not plan:
        console.print("[green]Nothing to remove.[/green] LLMHosts is already uninstalled.")
        return

    # ---- Show plan ----
    console.print()
    console.print("[bold]The following will be removed:[/bold]")
    console.print()

    removal_table = Table(show_header=False, box=None, padding=(0, 2))
    removal_table.add_column("Component", style="white")
    removal_table.add_column("Action", style="dim")
    for label, action, action_type in plan:
        if action_type == "dir":
            removal_table.add_row(label, f"rm -rf {action}")
        else:
            removal_table.add_row(label, action)
    console.print(removal_table)
    console.print()

    if not keep_data and data_dir.exists():
        console.print("[yellow]Warning:[/yellow] This will delete your config, API keys, cache, and audit logs.")

    if not yes and not click.confirm("Proceed with uninstall?"):
        console.print("[dim]Cancelled.[/dim]")
        return

    # ---- Execute ----
    console.print()
    errors: list[str] = []

    for label, action, action_type in plan:
        try:
            if action_type == "dir":
                path = Path(action)
                if path.exists():
                    shutil.rmtree(path)
                    console.print(f"  [green]\u2713[/green] Removed {label}")
                else:
                    console.print(f"  [dim]\u2713 Already gone: {label}[/dim]")
            elif action_type == "cmd":
                import subprocess

                result = subprocess.run(  # nosec B602 -- action is a hardcoded string, not user input
                    action,
                    shell=True,
                    capture_output=True,
                    text=True,
                    timeout=30,
                )
                if result.returncode == 0:
                    console.print(f"  [green]\u2713[/green] {label}")
                else:
                    err = result.stderr.strip()[:200]
                    console.print(f"  [red]\u2717[/red] {label}: {err}")
                    errors.append(f"{label}: {err}")
        except Exception as exc:
            console.print(f"  [red]\u2717[/red] {label}: {exc}")
            errors.append(f"{label}: {exc}")

    console.print()
    if errors:
        console.print(f"[yellow]Completed with {len(errors)} error(s).[/yellow] Manual cleanup may be needed.")
    else:
        console.print("[green]LLMHosts has been uninstalled.[/green]")
        if shutil.which("uv"):
            console.print("[dim]To also remove UV: uv self uninstall[/dim]")


# ---- Key management commands ----


@main.group()
def keys() -> None:
    """Manage BYOK (Bring Your Own Key) API keys.

    Store, list, remove, and validate API keys for cloud LLM providers.
    Keys are encrypted at rest using Fernet symmetric encryption.
    """


@keys.command("add")
@click.argument("provider", type=click.Choice(SUPPORTED_PROVIDERS, case_sensitive=False))
@click.argument("api_key")
@click.option("--validate", "do_validate", is_flag=True, default=False, help="Validate the key after storing it.")
@click.pass_context
def keys_add(ctx: click.Context, provider: str, api_key: str, do_validate: bool) -> None:
    """Add or update an API key for PROVIDER.

    PROVIDER must be one of: openai, anthropic, openrouter, google, mistral, groq, together, deepseek.

    Example: llmhosts keys add openai sk-proj-abc123...
    """
    from llmhosts.errors import print_error

    debug = (ctx.obj or {}).get("debug", False)
    mgr = KeyManager()

    try:
        mgr.add_key(provider, api_key)
    except ValueError as exc:
        print_error(console, "INVALID_API_KEY", debug=debug, exc=exc, detail=str(exc), provider=provider)

    from llmhosts.keys.models import _mask_key

    masked = _mask_key(api_key.strip())
    console.print(f"[green]✓[/green] Stored key for [bold]{provider}[/bold]: {masked}")

    if do_validate:
        console.print(f"  Validating [bold]{provider}[/bold] key...", end="")
        try:
            result = _run_async(mgr.validate_key(provider))
            if result.is_valid:
                models_info = f" ({result.models_available} models)" if result.models_available else ""
                console.print(f" [green]✓ valid{models_info}[/green]")
            else:
                print_error(
                    console,
                    "KEY_VALIDATION_FAILED",
                    debug=debug,
                    detail=result.error or "unknown",
                    provider=provider,
                )
        except Exception as exc:
            print_error(console, "KEY_VALIDATION_FAILED", debug=debug, exc=exc, detail=str(exc), provider=provider)


@keys.command("list")
def keys_list() -> None:
    """List all configured API key providers.

    Shows provider name, masked key, added date, and validation status.
    """
    mgr = KeyManager()
    providers = mgr.list_providers()

    if not providers:
        console.print("[dim]No API keys configured.[/dim]")
        console.print("  Add one with: [bold]llmhosts keys add <provider> <key>[/bold]")
        return

    table = Table(title="BYOK API Keys", show_lines=False)
    table.add_column("Provider", style="bold cyan", no_wrap=True)
    table.add_column("Key", style="dim")
    table.add_column("Added", style="dim")
    table.add_column("Last Used", style="dim")
    table.add_column("Valid", justify="center")

    for info in providers:
        added = info.added_at.strftime("%Y-%m-%d %H:%M") if info.added_at else "-"
        last_used = info.last_used.strftime("%Y-%m-%d %H:%M") if info.last_used else "-"

        if info.is_valid is True:
            valid_str = "[green]✓[/green]"
        elif info.is_valid is False:
            valid_str = "[red]✗[/red]"
        else:
            valid_str = "[dim]?[/dim]"

        table.add_row(info.provider, info.masked_key, added, last_used, valid_str)

    console.print(table)


@keys.command("remove")
@click.argument("provider")
def keys_remove(provider: str) -> None:
    """Remove the stored API key for PROVIDER.

    Example: llmhosts keys remove openai
    """
    mgr = KeyManager()
    provider = provider.strip().lower()

    if not mgr.has_key(provider):
        console.print(f"[red]Error:[/red] No key found for provider [bold]{provider}[/bold]")
        sys.exit(1)

    removed = mgr.remove_key(provider)
    if removed:
        console.print(f"[green]✓[/green] Removed key for [bold]{provider}[/bold]")
    else:
        console.print(f"[red]Error:[/red] Failed to remove key for [bold]{provider}[/bold]")
        sys.exit(1)


@keys.command("validate")
@click.argument("provider", required=False, default=None)
def keys_validate(provider: str | None) -> None:
    """Validate stored API key(s) by making test API calls.

    If PROVIDER is given, validate only that provider's key.
    If omitted, validate ALL stored keys.

    Example:
        llmhosts keys validate openai
        llmhosts keys validate
    """
    mgr = KeyManager()

    if provider is not None:
        # Validate a single provider
        provider = provider.strip().lower()
        if not mgr.has_key(provider):
            console.print(f"[red]Error:[/red] No key stored for [bold]{provider}[/bold]")
            sys.exit(1)

        console.print(f"Validating [bold]{provider}[/bold]...", end=" ")
        try:
            result = _run_async(mgr.validate_key(provider))
        except ValueError as exc:
            console.print(f"[red]✗ {exc}[/red]")
            sys.exit(1)

        if result.is_valid:
            models_info = f" ({result.models_available} models available)" if result.models_available else ""
            console.print(f"[green]✓ valid{models_info}[/green]")
        else:
            console.print(f"[red]✗ invalid: {result.error}[/red]")
            sys.exit(1)
    else:
        # Validate all stored keys
        all_providers = mgr.list_providers()
        if not all_providers:
            console.print("[dim]No API keys configured to validate.[/dim]")
            return

        console.print(f"Validating {len(all_providers)} key(s)...\n")
        any_invalid = False

        for info in all_providers:
            console.print(f"  [bold]{info.provider:12s}[/bold] ", end="")
            try:
                result = _run_async(mgr.validate_key(info.provider))
                if result.is_valid:
                    models_info = f" ({result.models_available} models)" if result.models_available else ""
                    console.print(f"[green]✓ valid{models_info}[/green]")
                else:
                    console.print(f"[red]✗ invalid: {result.error}[/red]")
                    any_invalid = True
            except Exception as exc:
                console.print(f"[red]✗ error: {exc}[/red]")
                any_invalid = True

        if any_invalid:
            console.print("\n[yellow]Some keys failed validation.[/yellow]")


# ---- Setup / Onboarding command ----


@main.command()
def setup() -> None:
    """Interactive first-run setup wizard.

    Walks you through:
    1. Current AI spending baseline
    2. Hardware detection
    3. Inference engine discovery
    4. Model recommendations
    5. Cloud API key configuration
    6. Personalised next steps

    \b
    Example:
        llmhosts setup
    """

    from rich.panel import Panel
    from rich.prompt import Confirm, Prompt

    from llmhosts.config import llmhosts_dir
    from llmhosts.onboarding.manager import _VALID_RANGES, OnboardingManager, SpendingBaseline

    data_dir = llmhosts_dir()
    mgr = OnboardingManager(data_dir)

    # Check if already complete
    if mgr.is_complete() and not Confirm.ask("[dim]Onboarding already completed. Run again?[/dim]", default=False):
        return

    # ---- Welcome ----
    console.print()
    console.print(
        Panel(
            "[bold cyan]Welcome to LLMHosts![/bold cyan]\n\n"
            "Let's set up your personal AI cloud.\n"
            "This takes about 2 minutes.",
            title="Setup Wizard",
            border_style="cyan",
        )
    )
    console.print()

    # ---- Step 1: Spending baseline ----
    console.print("[bold]Step 1:[/bold] Current AI spending\n")
    console.print("  How much do you currently spend per month on AI APIs?")
    for i, rng in enumerate(_VALID_RANGES, 1):
        console.print(f"    [{i}] {rng}")

    range_choice = Prompt.ask(
        "  Choose",
        choices=["1", "2", "3", "4"],
        default="1",
    )
    monthly_range = _VALID_RANGES[int(range_choice) - 1]
    midpoints = {"$0-50": 25.0, "$50-100": 75.0, "$100-200": 150.0, "$200+": 300.0}

    providers_input = Prompt.ask(
        "\n  Which providers do you use? (comma-separated, e.g. openai,anthropic)",
        default="openai",
    )
    providers_used = [p.strip().lower() for p in providers_input.split(",") if p.strip()]

    use_cases_input = Prompt.ask(
        "\n  Primary use cases? (comma-separated: coding, writing, analysis, chat, data-science)",
        default="coding",
    )
    primary_use_cases = [u.strip().lower() for u in use_cases_input.split(",") if u.strip()]

    baseline = SpendingBaseline(
        monthly_range=monthly_range,
        monthly_estimate=midpoints[monthly_range],
        providers_used=providers_used,
        primary_use_cases=primary_use_cases,
    )
    mgr.set_baseline(baseline)
    console.print(f"\n  [green]✓[/green] Baseline captured: {monthly_range}/month\n")

    # ---- Step 2: Hardware detection ----
    console.print("[bold]Step 2:[/bold] Detecting hardware...\n")
    hardware = None
    try:
        from llmhosts.discovery.hardware import HardwareDetector

        hardware = _run_async(HardwareDetector.detect())
        parts: list[str] = []
        if hardware.gpus:
            for gpu in hardware.gpus:
                vram_gb = round(gpu.vram_total_mb / 1024, 1)
                parts.append(f"{gpu.name} ({vram_gb}GB VRAM)")
            console.print(f"  [green]✓[/green] GPU: {', '.join(parts)}")
        else:
            console.print("  [yellow]![/yellow] No GPU detected (CPU-only mode)")
        console.print(f"  [green]✓[/green] RAM: {hardware.ram_total_gb:.0f}GB")
        console.print(f"  [green]✓[/green] CPU: {hardware.cpu_cores} cores")
    except Exception as exc:
        console.print(f"  [yellow]![/yellow] Hardware detection failed: {exc}")
    console.print()

    # ---- Step 3: Inference engine discovery ----
    console.print("[bold]Step 3:[/bold] Checking inference engine...\n")
    ollama_models: list[str] = []
    try:
        from llmhosts.config import load_config
        from llmhosts.discovery.ollama import OllamaDiscovery

        config = load_config()

        async def _discover() -> list[str]:
            async with OllamaDiscovery(host=config.ollama.host, timeout=5.0) as ollama:
                if await ollama.is_available():
                    models = await ollama.list_models()
                    return [m.name for m in models]
            return []

        ollama_models = _run_async(_discover())
        if ollama_models:
            console.print(f"  [green]✓[/green] Inference engine running with {len(ollama_models)} model(s):")
            for model in ollama_models[:8]:
                console.print(f"      {model}")
            if len(ollama_models) > 8:
                console.print(f"      ... and {len(ollama_models) - 8} more")
        else:
            console.print("  [yellow]![/yellow] No inference engine found or no models installed")
            console.print("      Run: [bold]llmhosts serve[/bold] (auto-provisions engine and models)")
    except Exception as exc:
        console.print(f"  [yellow]![/yellow] Inference engine check failed: {exc}")
    console.print()

    # ---- Step 4: API Keys ----
    console.print("[bold]Step 4:[/bold] Cloud API keys\n")
    key_mgr = KeyManager()
    existing_keys = [p.provider for p in key_mgr.list_providers()]

    if existing_keys:
        console.print(f"  [green]✓[/green] Existing keys: {', '.join(existing_keys)}")
    else:
        console.print("  [dim]No cloud API keys configured.[/dim]")

    if Confirm.ask("  Would you like to add a cloud API key now?", default=not bool(existing_keys)):
        from llmhosts.keys.models import SUPPORTED_PROVIDERS

        provider = Prompt.ask(
            "  Provider",
            choices=list(SUPPORTED_PROVIDERS),
            default="openai",
        )
        api_key = Prompt.ask(f"  {provider} API key", password=True)
        if api_key.strip():
            try:
                key_mgr.add_key(provider, api_key)
                existing_keys.append(provider)
                console.print(f"  [green]✓[/green] Key stored for {provider}")
            except ValueError as exc:
                console.print(f"  [red]✗[/red] {exc}")
    console.print()

    # ---- Step 5: Recommendations ----
    console.print("[bold]Step 5:[/bold] Personalised recommendations\n")
    recs = mgr.get_recommendations(
        hardware=hardware,
        ollama_models=ollama_models,
        keys=existing_keys,
    )

    accepted: list[str] = []
    if recs:
        for rec in recs[:5]:  # Show top 5
            console.print(f"  [{rec.priority}] [bold]{rec.title}[/bold]")
            console.print(f"      {rec.description}")
            console.print(f"      [dim]→ {rec.action}[/dim]")
            console.print()
        accepted = [r.id for r in recs]
    else:
        console.print("  [green]✓[/green] You're all set! No additional recommendations.\n")

    # ---- Complete ----
    mgr.complete(accepted)

    # ---- Summary ----
    console.print(
        Panel(
            "[bold green]Setup complete![/bold green]\n\n"
            + (f"[bold]Spending baseline:[/bold] {monthly_range}/month\n" if baseline else "")
            + (
                f"[bold]Local models:[/bold] {len(ollama_models)}\n"
                if ollama_models
                else "[bold]Local models:[/bold] none\n"
            )
            + (
                f"[bold]Cloud keys:[/bold] {', '.join(existing_keys)}\n"
                if existing_keys
                else "[bold]Cloud keys:[/bold] none\n"
            )
            + f"[bold]Recommendations:[/bold] {len(recs)}\n"
            + "\n[bold cyan]Next step:[/bold cyan] Run [bold]llmhosts serve[/bold] to start your personal AI cloud!",
            title="Summary",
            border_style="green",
        )
    )


# ---- Cache management stubs ----


@main.group()
def cache() -> None:
    """Manage the vCache semantic cache.

    Implementation: Step 18 (blueprint).
    """


@cache.command("stats")
def cache_stats() -> None:
    """Show cache statistics (per-tier hit rates, size, eviction counts)."""
    from llmhosts.cache.exact import ExactHashCache
    from llmhosts.cache.models import CacheStats
    from llmhosts.config import llmhosts_dir, load_config

    CacheStats.model_rebuild()

    config = load_config()
    data_dir = llmhosts_dir()
    cache_dir = data_dir / "cache"

    async def _stats() -> None:
        # Tier 0: Exact hash (always available)
        exact = ExactHashCache(
            db_path=cache_dir / "exact_cache.db",
            max_size_mb=config.cache.max_size_mb,
            ttl_seconds=config.cache.ttl_seconds,
            max_entries=config.cache.exact_max_entries,
        )
        exact_db = cache_dir / "exact_cache.db"

        table = Table(title="Cache Statistics", show_header=True, header_style="bold cyan")
        table.add_column("Tier", style="bold")
        table.add_column("Entries", justify="right")
        table.add_column("Hit Rate", justify="right")
        table.add_column("Hits", justify="right")
        table.add_column("Misses", justify="right")
        table.add_column("Evictions", justify="right")

        if exact_db.exists():
            await exact.initialize()
            try:
                stats = await exact.stats()
                table.add_row(
                    "0: Exact Hash",
                    str(stats.total_entries),
                    f"{stats.hit_rate:.1%}",
                    str(stats.hit_count),
                    str(stats.miss_count),
                    str(stats.eviction_count),
                )
            finally:
                await exact.close()
        else:
            table.add_row("0: Exact Hash", "—", "—", "—", "—", "—")

        # Tier 1: Namespace (optional)
        ns_db = cache_dir / "namespace_cache.db"
        if ns_db.exists():
            try:
                from llmhosts.cache.namespace import NamespaceFilter

                ns = NamespaceFilter(
                    db_path=ns_db,
                    ttl_seconds=config.cache.ttl_seconds,
                    max_namespaces=config.cache.namespace_max_namespaces,
                )
                await ns.initialize()
                try:
                    ns_stats = await ns.stats()
                    table.add_row(
                        "1: Namespace",
                        str(ns_stats.total_entries),
                        f"({ns_stats.total_namespaces} ns)",
                        "—",
                        "—",
                        str(ns_stats.eviction_count),
                    )
                finally:
                    await ns.close()
            except ImportError:
                table.add_row("1: Namespace", "[dim]requires [smart][/dim]", "—", "—", "—", "—")
        else:
            table.add_row("1: Namespace", "[dim]inactive[/dim]", "—", "—", "—", "—")

        # Tier 2: vCache (optional)
        vc_db = cache_dir / "vcache.db"
        if vc_db.exists():
            try:
                from llmhosts.cache.vcache import VCache

                vc = VCache(
                    db_path=vc_db,
                    ttl_seconds=config.cache.ttl_seconds,
                    max_bytes=config.cache.vcache_max_bytes,
                )
                await vc.initialize()
                try:
                    vc_stats = await vc.stats()
                    table.add_row(
                        "2: vCache",
                        str(vc_stats.total_entries),
                        f"{vc_stats.hit_rate:.1%}",
                        str(vc_stats.total_hits),
                        str(vc_stats.total_misses),
                        str(vc_stats.eviction_count),
                    )
                finally:
                    await vc.close()
            except ImportError:
                table.add_row("2: vCache", "[dim]requires [smart][/dim]", "—", "—", "—", "—")
        else:
            table.add_row("2: vCache", "[dim]inactive[/dim]", "—", "—", "—", "—")

        console.print(table)

    _run_async(_stats())


@cache.command("clear")
@click.option("--namespace", default=None, help="Clear only a specific namespace")
@click.option("--tier", type=click.Choice(["exact", "namespace", "vcache", "all"]), default="all", help="Which tier")
def cache_clear(namespace: str | None, tier: str) -> None:
    """Clear the cache (all tiers or a specific tier)."""
    from llmhosts.config import llmhosts_dir, load_config

    config = load_config()
    data_dir = llmhosts_dir()
    cache_dir = data_dir / "cache"

    async def _clear() -> None:
        cleared = 0

        if tier in ("exact", "all"):
            exact_db = cache_dir / "exact_cache.db"
            if exact_db.exists():
                from llmhosts.cache.exact import ExactHashCache

                exact = ExactHashCache(
                    db_path=exact_db,
                    max_size_mb=config.cache.max_size_mb,
                    ttl_seconds=config.cache.ttl_seconds,
                    max_entries=config.cache.exact_max_entries,
                )
                await exact.initialize()
                try:
                    count = await exact.clear()
                    cleared += count
                    console.print(f"  Exact hash: cleared {count} entries")
                finally:
                    await exact.close()

        if tier in ("namespace", "all"):
            ns_db = cache_dir / "namespace_cache.db"
            if ns_db.exists():
                try:
                    from llmhosts.cache.namespace import NamespaceFilter

                    ns = NamespaceFilter(
                        db_path=ns_db,
                        ttl_seconds=config.cache.ttl_seconds,
                        max_namespaces=config.cache.namespace_max_namespaces,
                    )
                    await ns.initialize()
                    try:
                        db = ns._ensure_db()
                        async with db.execute("SELECT COUNT(*) FROM namespace_cache") as cur:
                            row = await cur.fetchone()
                            count = row[0] if row else 0
                        await db.execute("DELETE FROM namespace_cache")
                        await db.execute("DELETE FROM namespace_stats")
                        await db.commit()
                        cleared += count
                        console.print(f"  Namespace: cleared {count} entries")
                    finally:
                        await ns.close()
                except ImportError:
                    pass

        if tier in ("vcache", "all"):
            vc_db = cache_dir / "vcache.db"
            if vc_db.exists():
                try:
                    from llmhosts.cache.vcache import VCache

                    vc = VCache(
                        db_path=vc_db,
                        ttl_seconds=config.cache.ttl_seconds,
                        max_bytes=config.cache.vcache_max_bytes,
                    )
                    await vc.initialize()
                    try:
                        db = vc._ensure_db()
                        async with db.execute("SELECT COUNT(*) FROM vcache_entries") as cur:
                            row = await cur.fetchone()
                            count = row[0] if row else 0
                        await db.execute("DELETE FROM vcache_entries")
                        await db.commit()
                        vc._thresholds.clear()
                        cleared += count
                        console.print(f"  vCache: cleared {count} entries")
                    finally:
                        await vc.close()
                except ImportError:
                    pass

        console.print(f"[green]Cleared {cleared} total cache entries[/green]")

    _run_async(_clear())


# ---- Database migration commands ----


@main.group()
def db() -> None:
    """Database migration commands (powered by Alembic)."""


def _alembic_config() -> AlembicConfig:
    """Build an Alembic ``Config`` pointing to the project's migration env."""
    from pathlib import Path

    from alembic.config import Config as AlembicConfig

    ini_path = Path(__file__).resolve().parent.parent / "alembic.ini"
    cfg = AlembicConfig(str(ini_path))
    cfg.set_main_option("script_location", str(Path(__file__).resolve().parent / "migrations"))
    return cfg


@db.command()
@click.pass_context
def upgrade(ctx: click.Context) -> None:
    """Apply pending database migrations.

    Runs all unapplied migrations to bring the database to the latest
    schema revision.

    \b
    Example:
        llmhosts db upgrade
    """
    from alembic import command as alembic_command

    from llmhosts.errors import print_error

    debug = (ctx.obj or {}).get("debug", False)
    cfg = _alembic_config()
    try:
        alembic_command.upgrade(cfg, "head")
        console.print("[green]\\u2713[/green] Database upgraded to latest revision.")
    except Exception as exc:
        print_error(console, "DB_MIGRATION_ERROR", debug=debug, exc=exc, detail=str(exc))


@db.command()
@click.pass_context
def downgrade(ctx: click.Context) -> None:
    """Revert the last database migration.

    Rolls back exactly one migration revision.

    \b
    Example:
        llmhosts db downgrade
    """
    from alembic import command as alembic_command

    from llmhosts.errors import print_error

    debug = (ctx.obj or {}).get("debug", False)
    cfg = _alembic_config()
    try:
        alembic_command.downgrade(cfg, "-1")
        console.print("[green]\\u2713[/green] Database downgraded by one revision.")
    except Exception as exc:
        print_error(console, "DB_MIGRATION_ERROR", debug=debug, exc=exc, detail=str(exc))


@db.command()
def current() -> None:
    """Show current database migration revision.

    \b
    Example:
        llmhosts db current
    """
    from alembic import command as alembic_command

    cfg = _alembic_config()
    try:
        alembic_command.current(cfg, verbose=True)
    except Exception as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)


@db.command()
def history() -> None:
    """Show migration revision history.

    \b
    Example:
        llmhosts db history
    """
    from alembic import command as alembic_command

    cfg = _alembic_config()
    try:
        alembic_command.history(cfg, verbose=True)
    except Exception as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)


# ---- Config commands ----


@main.group(name="config")
def config_cmd() -> None:
    """View and modify LLMHosts configuration."""


@config_cmd.command("set")
@click.argument("key", type=click.Choice(["engine"]))
@click.argument("value", type=click.Choice(["local", "vllm", "auto"]))
@click.pass_context
def config_set(ctx: click.Context, key: str, value: str) -> None:
    """Set a configuration value.

    \b
    Examples:
        llmhosts config set engine local
        llmhosts config set engine vllm
        llmhosts config set engine auto
    """
    import toml  # type: ignore[import-untyped]

    from llmhosts.config import _config_path
    from llmhosts.constants import VLLM_DEFAULT_HOST
    from llmhosts.errors import print_error

    debug = (ctx.obj or {}).get("debug", False)
    path = _config_path()
    path.parent.mkdir(parents=True, exist_ok=True)

    data: dict = {}
    if path.is_file():
        try:
            data = toml.load(path)
        except Exception as exc:
            print_error(console, "CONFIG_PARSE_ERROR", debug=debug, exc=exc, path=str(path), detail=str(exc))

    # Map user-facing "local" to internal "ollama" engine type
    internal_value = "ollama" if value == "local" else value

    data.setdefault("engine", {})
    data["engine"]["type"] = internal_value
    data["engine"]["vllm_host"] = data["engine"].get("vllm_host", VLLM_DEFAULT_HOST)

    try:
        path.write_text(toml.dumps(data), encoding="utf-8")
        console.print(f"[green]✓[/green] Set engine to [bold]{value}[/bold]")
    except OSError as exc:
        print_error(console, "CONFIG_WRITE_ERROR", debug=debug, exc=exc, path=str(path), detail=str(exc))


# ---- Engine commands ----


@main.group()
def engine() -> None:
    """Manage inference engines (local, vLLM)."""


@engine.command("list")
def engine_list() -> None:
    """List available inference engines and their status."""
    from llmhosts.discovery.engine import EngineManager

    async def _list() -> None:
        mgr = EngineManager()
        engines = await mgr.discover_all()
        active = await mgr.get_active_engine()

        table = Table(title="Inference Engines", show_lines=False)
        table.add_column("Engine", style="bold cyan", no_wrap=True)
        table.add_column("Host", style="dim")
        table.add_column("Status", justify="center")
        table.add_column("Models", justify="right")
        table.add_column("Active", justify="center")

        for eng in engines:
            status = "[green]●[/green]" if eng.available else "[red]●[/red]"
            model_count = str(len(eng.models))
            is_active = "[green]✓[/green]" if (active and active.engine_type == eng.engine_type) else ""
            table.add_row(eng.engine_type.value, eng.host, status, model_count, is_active)

        console.print(table)

        if engines:
            if active:
                console.print(f"\n[dim]Active engine: {active.engine_type.value} ({active.host})[/dim]")
            else:
                console.print("\n[yellow]No engine currently available.[/yellow]")

    _run_async(_list())


@engine.command("set")
@click.argument("engine_type", type=click.Choice(["local", "vllm", "auto"]))
@click.pass_context
def engine_set(ctx: click.Context, engine_type: str) -> None:
    """Set the preferred inference engine."""
    import toml  # type: ignore[import-untyped]

    from llmhosts.config import _config_path
    from llmhosts.constants import VLLM_DEFAULT_HOST
    from llmhosts.errors import print_error

    debug = (ctx.obj or {}).get("debug", False)
    path = _config_path()
    path.parent.mkdir(parents=True, exist_ok=True)

    # Map user-facing "local" to internal "ollama" engine type
    internal_type = "ollama" if engine_type == "local" else engine_type

    data: dict = {}
    if path.is_file():
        try:
            data = toml.load(path)
        except Exception as exc:
            print_error(console, "CONFIG_PARSE_ERROR", debug=debug, exc=exc, path=str(path), detail=str(exc))

    data.setdefault("engine", {})
    data["engine"]["type"] = internal_type
    data["engine"]["vllm_host"] = data["engine"].get("vllm_host", VLLM_DEFAULT_HOST)

    try:
        path.write_text(toml.dumps(data), encoding="utf-8")
        console.print(f"[green]✓[/green] Set preferred engine to [bold]{engine_type}[/bold]")
    except OSError as exc:
        print_error(console, "CONFIG_WRITE_ERROR", debug=debug, exc=exc, path=str(path), detail=str(exc))


@engine.command("compare")
@click.argument("model")
def engine_compare(model: str) -> None:
    """Compare throughput between engines for a model."""
    from llmhosts.discovery.engine import EngineManager

    async def _compare() -> None:
        mgr = EngineManager()
        console.print(f"[dim]Benchmarking model: {model}[/dim]")
        console.print("[dim]Sending short chat request to each engine...[/dim]\n")

        result = await mgr.compare_throughput(model)

        table = Table(title=f"Throughput: {model}", show_lines=False)
        table.add_column("Engine", style="bold cyan", no_wrap=True)
        table.add_column("Throughput (tok/s)", justify="right")

        table.add_row("Local Engine", f"{result['ollama']:.1f}")
        table.add_row("vLLM", f"{result['vllm']:.1f}")

        console.print(table)

        if result["ollama"] > 0 or result["vllm"] > 0:
            faster = "vLLM" if result["vllm"] > result["ollama"] else "Local Engine"
            console.print(f"\n[dim]Faster for this model: {faster}[/dim]")
        else:
            console.print("\n[yellow]No engines were available for benchmarking.[/yellow]")

    _run_async(_compare())


# ---- Tunnel commands ----


@main.group(invoke_without_command=True)
@click.option("--port", default=4000, type=int, help="Local port to tunnel (default: 4000)")
@click.option("--no-auth", is_flag=True, help="Disable tunnel authentication")
@click.pass_context
def tunnel(ctx: click.Context, port: int, no_auth: bool) -> None:
    """Make your LLMHosts accessible from anywhere.

    Connects to the LLMHosts relay server via WebSocket. Your home GPU,
    accessible from your laptop — no external software needed.

    \b
    Examples:
        llmhosts tunnel
        llmhosts tunnel --port 8080
        llmhosts tunnel status
        llmhosts tunnel stop
    """
    # Store config on context for subcommands
    ctx.ensure_object(dict)
    ctx.obj["port"] = port
    ctx.obj["no_auth"] = no_auth

    if ctx.invoked_subcommand is not None:
        return

    # Default action: start the tunnel
    _tunnel_start(port, no_auth)


@tunnel.command("start")
@click.pass_context
def tunnel_start_cmd(ctx: click.Context) -> None:
    """Start the tunnel (same as `llmhosts tunnel` with no subcommand)."""
    obj = ctx.obj or {}
    _tunnel_start(
        obj.get("port", 4000),
        obj.get("no_auth", False),
    )


@tunnel.command("status")
def tunnel_status_cmd() -> None:
    """Show current tunnel status."""

    from llmhosts.tunnel.detector import TunnelDetector
    from llmhosts.tunnel.relay import RELAY_AVAILABLE, RELAY_VERSION

    async def _check() -> None:
        console.print()
        console.print("[bold]Tunnel Status[/bold]")
        console.print()

        providers = await TunnelDetector.detect()

        if not providers:
            console.print("  [dim]Relay extension not available.[/dim]")
            console.print("  Build with: cd relay_core && maturin develop --release")
            console.print()
            return

        console.print("  [bold]LLMHosts Relay:[/bold]")
        if RELAY_AVAILABLE:
            console.print(f"    Status:  [green]available[/green] (v{RELAY_VERSION})")
        else:
            console.print("    Status:  [dim]not built[/dim]")
        console.print()

    _run_async(_check())


@tunnel.command("stop")
def tunnel_stop_cmd() -> None:
    """Stop any active tunnel."""

    from llmhosts.tunnel.manager import TunnelManager

    async def _stop() -> None:
        mgr = TunnelManager()
        await mgr.stop()
        console.print("[green]✓[/green] Relay tunnel stopped")

    _run_async(_stop())


def _tunnel_start(port: int, no_auth: bool) -> None:
    """Core tunnel start logic, shared by ``tunnel`` and ``tunnel start``."""
    import signal

    from rich.panel import Panel

    from llmhosts.tunnel.manager import TunnelManager
    from llmhosts.tunnel.models import TunnelConfig

    config = TunnelConfig(
        port=port,
        auth_required=not no_auth,
    )

    mgr = TunnelManager()

    # -- Detection banner ----------------------------------------------------
    console.print()
    console.print("[bold cyan]LLMHosts Tunnel[/bold cyan]")
    console.print("[dim]Connecting to LLMHosts relay...[/dim]")
    console.print()

    # -- Start tunnel --------------------------------------------------------
    try:
        status = _run_async(mgr.start(config))
    except Exception as exc:
        from llmhosts.errors import print_error_generic

        print_error_generic(console, f"Tunnel startup failed: {exc}", exc=exc)

    if not status.active:
        if "not available" in (status.error or "").lower():
            from llmhosts.errors import print_error

            print_error(console, "TUNNEL_NOT_FOUND")
        else:
            from llmhosts.errors import print_error_generic

            print_error_generic(console, status.error or "Tunnel failed to start")

    # -- Success banner ------------------------------------------------------
    # Auth info
    auth_line = ""
    if mgr.auth and not no_auth:
        key = mgr.auth.get_key()
        auth_line = f"\n[bold]API Key:[/bold]  {key}\n[dim]Set this on your remote client to authenticate.[/dim]"

    panel_content = (
        f"[bold green]Tunnel active![/bold green]\n"
        f"\n"
        f"[bold]Provider:[/bold] LLMHosts Relay\n"
        f"[bold]URL:[/bold]      [bold cyan]{status.url}[/bold cyan]\n"
        f"[bold]Local:[/bold]    http://localhost:{port}"
        f"{auth_line}"
    )

    console.print(
        Panel(
            panel_content,
            title="[bold]LLMHosts Tunnel[/bold]",
            border_style="green",
            padding=(1, 2),
        )
    )

    # -- Client configuration instructions -----------------------------------
    console.print()
    console.print("[bold]Configure your remote client:[/bold]")
    console.print()
    if mgr.auth and not no_auth:
        key = mgr.auth.get_key()
        console.print(f"  [bold]export OPENAI_BASE_URL=[/bold]{status.url}/v1")
        console.print(f"  [bold]export OPENAI_API_KEY=[/bold]{key}")
    else:
        console.print(f"  [bold]export OPENAI_BASE_URL=[/bold]{status.url}/v1")
    console.print()
    console.print("[dim]Press Ctrl+C to stop the tunnel.[/dim]")
    console.print()

    # -- Wait for Ctrl+C -----------------------------------------------------
    shutdown_event = asyncio.Event()

    def _on_signal(signum: int, frame: object) -> None:
        shutdown_event.set()

    # Register signal handlers
    prev_sigint = signal.signal(signal.SIGINT, _on_signal)
    prev_sigterm = signal.signal(signal.SIGTERM, _on_signal)

    try:

        async def _wait_and_cleanup() -> None:
            # Wait for shutdown signal
            await shutdown_event.wait()
            console.print("\n[dim]Shutting down tunnel...[/dim]")
            await mgr.stop()
            console.print("[green]✓[/green] Tunnel stopped. Goodbye!")

        _run_async(_wait_and_cleanup())
    except KeyboardInterrupt:
        # Fallback if signal handler didn't catch it
        console.print("\n[dim]Shutting down tunnel...[/dim]")
        _run_async(mgr.stop())
        console.print("[green]✓[/green] Tunnel stopped. Goodbye!")
    finally:
        signal.signal(signal.SIGINT, prev_sigint)
        signal.signal(signal.SIGTERM, prev_sigterm)


# ---- LAN Discovery command ----


@main.command("discover")
@click.option("--timeout", default=5.0, type=float, help="Scan duration in seconds (default: 5)")
@click.option("--json-output", "json_out", is_flag=True, default=False, help="Output as JSON instead of a table")
def discover(timeout: float, json_out: bool) -> None:
    """Discover other LLMHosts instances on the local network.

    Uses mDNS (multicast DNS) to find LLMHosts nodes on your LAN.
    Shows hostname, IP, GPU type, available models, and latency.

    Requires: pip install llmhosts[lan]

    \b
    Examples:
        llmhosts discover
        llmhosts discover --timeout 10
        llmhosts discover --json-output
    """
    from llmhosts.discovery.mdns import MDNSDiscovery, scan_lan

    mdns = MDNSDiscovery()
    if not mdns.is_available:
        console.print()
        console.print("[red bold]E014 — Missing dependency[/red bold]")
        console.print()
        console.print("[dim]Cause:[/dim]  zeroconf is not installed.")
        console.print()
        console.print("[dim]Fix:[/dim]")
        console.print("  pip install llmhosts[lan]")
        console.print()
        sys.exit(1)

    console.print(f"[dim]Scanning LAN for LLMHosts instances ({timeout}s)...[/dim]")

    nodes = _run_async(scan_lan(timeout=timeout))

    if not nodes:
        console.print("\n[dim]No LLMHosts instances found on the local network.[/dim]")
        console.print("  Make sure other nodes are running [bold]llmhosts serve[/bold]")
        return

    # Ping nodes for latency
    console.print(f"[dim]Found {len(nodes)} node(s), measuring latency...[/dim]")

    async def _ping_all() -> Any:
        import httpx

        async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
            import time as _time

            for node in nodes:
                try:
                    start = _time.perf_counter()
                    resp = await client.get(f"{node.base_url}/health")
                    elapsed = (_time.perf_counter() - start) * 1000
                    if resp.status_code == 200:
                        node.latency_ms = round(elapsed, 1)
                except Exception:
                    node.latency_ms = None
        return nodes

    nodes = _run_async(_ping_all())

    if json_out:
        import json as json_mod

        output = [n.model_dump() for n in nodes]
        console.print(json_mod.dumps(output, indent=2))
        return

    # Rich table output
    table = Table(title="LAN LLMHosts Nodes", show_lines=False)
    table.add_column("Hostname", style="bold cyan", no_wrap=True)
    table.add_column("IP", style="dim", no_wrap=True)
    table.add_column("Port", justify="right", no_wrap=True)
    table.add_column("GPU", max_width=30)
    table.add_column("Models", max_width=40)
    table.add_column("Latency", justify="right", no_wrap=True)
    table.add_column("Version", style="dim", no_wrap=True)

    for node in nodes:
        latency_str = f"{node.latency_ms:.0f}ms" if node.latency_ms is not None else "[red]timeout[/red]"
        models_str = ", ".join(node.models[:5])
        if len(node.models) > 5:
            models_str += f" +{len(node.models) - 5}"
        gpu = node.gpu_summary or "[dim]-[/dim]"

        table.add_row(
            node.hostname,
            node.ip,
            str(node.port),
            gpu,
            models_str or "[dim]none[/dim]",
            latency_str,
            node.version or "[dim]-[/dim]",
        )

    console.print()
    console.print(table)
    console.print()
    console.print(
        f"[dim]{len(nodes)} node(s) discovered. Models available across LAN: {sum(len(n.models) for n in nodes)}[/dim]"
    )


# ---- Team commands (RBAC) ----


@main.group()
def team() -> None:
    """Manage your LLMHosts team.

    Create team, add members, set budgets, and view spending alerts.
    Team plan ($49/seat/mo): RBAC + shared configs.
    """


@team.command("create")
@click.argument("name")
@click.option("--email", required=True, help="Owner email address")
def team_create(name: str, email: str) -> None:
    """Create a new team.

    You become the team admin. Run from the directory where you want the team data.

    \b
    Example:
        llmhosts team create "Acme Corp" --email admin@acme.com
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.rbac.manager import TeamManager

    data_dir = llmhosts_dir()
    mgr = TeamManager(data_dir)

    async def _create() -> Any:
        await mgr.initialize()
        return await mgr.create_team(name, email)

    try:
        t = _run_async(_create())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    console.print(f"[green]✓[/green] Created team [bold]{t.name}[/bold]")
    console.print(f"  ID:     {t.id}")
    console.print(f"  Owner:  {email}")
    console.print(f"  Plan:   {t.plan}")
    console.print(f"  Seats:  {len(t.members)}/{t.max_seats}")


@team.command("add")
@click.argument("email")
@click.option("--name", required=True, help="Display name for the member")
@click.option(
    "--role",
    type=click.Choice(["admin", "developer", "viewer"], case_sensitive=False),
    default="developer",
    help="Role (default: developer)",
)
def team_add(email: str, name: str, role: str) -> None:
    """Add a member to your team.

    \b
    Example:
        llmhosts team add dev@acme.com --name "Jane Dev" --role developer
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.rbac.manager import TeamManager
    from llmhosts.rbac.models import Role as RbacRole

    data_dir = llmhosts_dir()
    mgr = TeamManager(data_dir)
    role_enum = RbacRole(role.lower())

    async def _add() -> Any:
        await mgr.initialize()
        return await mgr.add_member(email, name, role_enum)

    try:
        member = _run_async(_add())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    console.print(f"[green]✓[/green] Added [bold]{member.name}[/bold] ({member.email}) as {member.role.value}")


@team.command("list")
def team_list() -> None:
    """List all team members."""
    from llmhosts.config import llmhosts_dir
    from llmhosts.rbac.manager import TeamManager

    data_dir = llmhosts_dir()
    mgr = TeamManager(data_dir)

    async def _list() -> Any:
        await mgr.initialize()
        return await mgr.list_members()

    try:
        members = _run_async(_list())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if not members:
        console.print("[dim]No team found. Create one with: llmhosts team create <name> --email <email>[/dim]")
        return

    table = Table(title="Team Members", show_lines=False)
    table.add_column("Email", style="bold cyan", no_wrap=True)
    table.add_column("Name", style="dim")
    table.add_column("Role", no_wrap=True)
    table.add_column("Budget", justify="right")
    table.add_column("Spend", justify="right")
    table.add_column("Models", max_width=30)

    for m in members:
        budget = f"${m.monthly_budget:.0f}" if m.monthly_budget else "-"
        spend = f"${m.current_spend:.2f}" if m.current_spend else "-"
        models = ", ".join(m.model_allowlist[:3]) if m.model_allowlist else "all"
        if m.model_allowlist and len(m.model_allowlist) > 3:
            models += f" +{len(m.model_allowlist) - 3}"
        table.add_row(m.email, m.name, m.role.value, budget, spend, models)

    console.print(table)


@team.command("remove")
@click.argument("email_or_id")
def team_remove(email_or_id: str) -> None:
    """Remove a team member.

    EMAIL_OR_ID can be the member's email or ID.
    Cannot remove the team owner.

    \b
    Example:
        llmhosts team remove dev@acme.com
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.rbac.manager import TeamManager

    data_dir = llmhosts_dir()
    mgr = TeamManager(data_dir)

    async def _remove() -> Any:
        await mgr.initialize()
        return await mgr.remove_member(email_or_id)

    try:
        removed = _run_async(_remove())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if removed:
        console.print(f"[green]✓[/green] Removed member [bold]{email_or_id}[/bold]")
    else:
        console.print(f"[red]Error:[/red] No member found matching [bold]{email_or_id}[/bold]")
        sys.exit(1)


@team.command("set-budget")
@click.argument("email_or_id")
@click.argument("budget", type=float)
def team_set_budget(email_or_id: str, budget: float) -> None:
    """Set monthly spending budget for a member.

    BUDGET is in USD. Set to 0 to remove budget limit.

    \b
    Example:
        llmhosts team set-budget dev@acme.com 100
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.rbac.manager import TeamManager

    data_dir = llmhosts_dir()
    mgr = TeamManager(data_dir)

    # 0 or negative means remove budget limit
    monthly_budget: float | None = None if budget <= 0 else budget

    async def _set() -> Any:
        await mgr.initialize()
        return await mgr.set_budget(email_or_id, monthly_budget)

    try:
        member = _run_async(_set())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if member is None:
        console.print(f"[red]Error:[/red] No member found matching [bold]{email_or_id}[/bold]")
        sys.exit(1)

    if monthly_budget is None:
        console.print(f"[green]✓[/green] Removed budget limit for [bold]{member.email}[/bold]")
    else:
        console.print(f"[green]✓[/green] Set monthly budget for [bold]{member.email}[/bold] to ${monthly_budget:.2f}")


@team.command("alerts")
def team_alerts() -> None:
    """Show spending alerts.

    Displays triggered alerts and active alert thresholds.
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.rbac.manager import TeamManager

    data_dir = llmhosts_dir()
    mgr = TeamManager(data_dir)

    async def _alerts() -> Any:
        await mgr.initialize()
        triggered = await mgr.get_alerts()
        # Get all alerts (we need to access _alerts - manager doesn't expose list all)
        return triggered

    try:
        triggered = _run_async(_alerts())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if not triggered:
        console.print("[dim]No spending alerts triggered.[/dim]")
        return

    table = Table(title="Spending Alerts", show_lines=False)
    table.add_column("Scope", style="bold")
    table.add_column("Threshold", justify="right")
    table.add_column("Period", no_wrap=True)
    table.add_column("Triggered At", style="dim")

    for a in triggered:
        scope = "team" if a.member_id is None else a.member_id[:8] + "..."
        triggered_at = a.triggered_at.strftime("%Y-%m-%d %H:%M") if a.triggered_at else "-"
        table.add_row(scope, f"${a.threshold:.2f}", a.period, triggered_at)

    console.print(table)


# ---- Device / Mesh commands ----


@main.group()
def device() -> None:
    """Manage devices in your LLMHosts mesh.

    Register, list, remove, and monitor devices for multi-device inference routing.
    """


@device.command("add")
@click.argument("name")
@click.option(
    "--role",
    type=click.Choice(["compute", "client", "hybrid"], case_sensitive=False),
    default="hybrid",
    help="Device role (default: hybrid)",
)
@click.option("--url", "tunnel_url", default=None, help="Tunnel URL of remote device")
def device_add(name: str, role: str, tunnel_url: str | None) -> None:
    """Register a device in your mesh.

    NAME is a friendly identifier for the device (e.g. "home-desktop").

    \b
    Examples:
        llmhosts device add my-desktop
        llmhosts device add work-laptop --role client
        llmhosts device add home-server --url https://homeserver.tail1234.ts.net
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.mesh.manager import DeviceManager
    from llmhosts.mesh.models import DeviceRegistration, DeviceRole

    data_dir = llmhosts_dir()
    mgr = DeviceManager(data_dir)

    role_enum = DeviceRole(role.lower())
    registration = DeviceRegistration(name=name, role=role_enum, tunnel_url=tunnel_url)

    async def _register() -> Any:
        await mgr.initialize()
        return await mgr.register(registration)

    try:
        dev = _run_async(_register())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    console.print(f"[green]✓[/green] Registered device [bold]{dev.name}[/bold]")
    console.print(f"  ID:   {dev.id}")
    console.print(f"  Role: {dev.role.value}")
    if dev.tunnel_url:
        console.print(f"  URL:  {dev.tunnel_url}")
    if dev.hardware_summary:
        console.print(f"  HW:   {dev.hardware_summary}")
    if dev.models:
        console.print(f"  Models: {len(dev.models)} available")


@device.command("list")
def device_list() -> None:
    """List all devices in your mesh with status."""
    from llmhosts.config import llmhosts_dir
    from llmhosts.mesh.manager import DeviceManager

    data_dir = llmhosts_dir()
    mgr = DeviceManager(data_dir)

    async def _list() -> Any:
        await mgr.initialize()
        return await mgr.list_devices()

    devices = _run_async(_list())

    if not devices:
        console.print("[dim]No devices registered.[/dim]")
        console.print("  Add one with: [bold]llmhosts device add <name>[/bold]")
        return

    table = Table(title="Mesh Devices", show_lines=False)
    table.add_column("Name", style="bold cyan", no_wrap=True)
    table.add_column("Role", style="dim")
    table.add_column("Location", no_wrap=True)
    table.add_column("Hardware", max_width=40)
    table.add_column("Models", justify="right")
    table.add_column("Status", justify="center")

    for dev in devices:
        location = "[green]local[/green]" if dev.is_local else (dev.tunnel_url or "[dim]no URL[/dim]")
        model_count = str(len(dev.models)) if dev.models else "0"
        status = "[green]●[/green]" if dev.healthy else "[red]●[/red]"
        hw = dev.hardware_summary or "[dim]-[/dim]"

        table.add_row(dev.name, dev.role.value, location, hw, model_count, status)

    console.print(table)


@device.command("remove")
@click.argument("name_or_id")
def device_remove(name_or_id: str) -> None:
    """Remove a device from your mesh.

    NAME_OR_ID can be the device name or its UUID.

    Example: llmhosts device remove work-laptop
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.mesh.manager import DeviceManager

    data_dir = llmhosts_dir()
    mgr = DeviceManager(data_dir)

    async def _remove() -> Any:
        await mgr.initialize()
        return await mgr.remove(name_or_id)

    removed = _run_async(_remove())

    if removed:
        console.print(f"[green]✓[/green] Removed device [bold]{name_or_id}[/bold]")
    else:
        console.print(f"[red]Error:[/red] No device found matching [bold]{name_or_id}[/bold]")
        sys.exit(1)


@device.command("status")
def device_status() -> None:
    """Show mesh health and model availability across devices."""
    from rich.panel import Panel

    from llmhosts.config import llmhosts_dir
    from llmhosts.mesh.manager import DeviceManager

    data_dir = llmhosts_dir()
    mgr = DeviceManager(data_dir)

    async def _status() -> Any:
        await mgr.initialize()
        return await mgr.health_check()

    try:
        status = _run_async(_status())
    except Exception as exc:
        console.print(f"[red]Error:[/red] Health check failed: {exc}")
        sys.exit(1)

    # Header
    health_icon = "[green]●[/green]" if status.mesh_healthy else "[red]●[/red]"
    health_label = "healthy" if status.mesh_healthy else "degraded"

    total_devices = (1 if status.local_device else 0) + len(status.remote_devices)

    header = (
        f"Mesh: {health_icon} {health_label}  |  "
        f"Devices: {total_devices}  |  "
        f"Models: {status.total_models}  |  "
        f"GPU VRAM: {status.total_gpu_vram_gb:.1f}GB"
    )

    console.print()
    console.print(Panel(header, title="[bold]LLMHosts Mesh[/bold]", border_style="cyan"))
    console.print()

    # Local device
    if status.local_device:
        d = status.local_device
        console.print(f"  [bold]Local:[/bold] {d.name} ({d.role.value})")
        if d.hardware_summary:
            console.print(f"    HW: {d.hardware_summary}")
        if d.models:
            shown = d.models[:5]
            console.print(
                f"    Models: {', '.join(shown)}" + (f" +{len(d.models) - 5} more" if len(d.models) > 5 else "")
            )
        console.print()

    # Remote devices
    if status.remote_devices:
        console.print("  [bold]Remote Devices:[/bold]")
        for d in status.remote_devices:
            health = "[green]●[/green]" if d.healthy else "[red]●[/red]"
            latency = f" ({d.latency_ms:.0f}ms)" if d.latency_ms is not None else ""
            console.print(f"    {health} {d.name} [{d.role.value}]{latency}")
            if d.tunnel_url:
                console.print(f"      URL: {d.tunnel_url}")
            if d.models:
                shown = d.models[:3]
                extra = f" +{len(d.models) - 3} more" if len(d.models) > 3 else ""
                console.print(f"      Models: {', '.join(shown)}{extra}")
        console.print()
    elif not status.local_device:
        console.print("  [dim]No devices registered. Add one with: llmhosts device add <name>[/dim]")
        console.print()

    # Model availability table
    async def _get_models() -> Any:
        mgr2 = DeviceManager(data_dir)
        await mgr2.initialize()
        return await mgr2.get_all_models()

    all_models = _run_async(_get_models())
    if all_models:
        # Build model -> devices map
        model_devices: dict[str, list[str]] = {}
        all_devs = ([status.local_device] if status.local_device else []) + status.remote_devices
        dev_name_map = {d.id: d.name for d in all_devs}

        for dev_id, models in all_models.items():
            for model in models:
                model_devices.setdefault(model, []).append(dev_name_map.get(dev_id, dev_id[:8]))

        table = Table(title="Model Availability", show_lines=False)
        table.add_column("Model", style="cyan", no_wrap=True)
        table.add_column("Available On", max_width=60)

        for model, dev_names in sorted(model_devices.items()):
            table.add_row(model, ", ".join(dev_names))

        console.print(table)


# ---- Hive commands (GPU pooling) ----


@main.group()
def hive() -> None:
    """Manage your LLMHosts Hive (GPU sharing pool).

    Pro-tier: share GPU resources with friends. Privacy-tiered routing,
    karma system, invite-based membership.
    """


def _headscale_acl_path() -> Path | None:
    """Return Path for HEADSCALE_ACL_PATH if set, else None (file-based ACL sync)."""
    import os
    from pathlib import Path

    p = os.environ.get("HEADSCALE_ACL_PATH")
    return Path(p) if p else None


@hive.command("create")
@click.argument("name")
@click.option("--owner-name", "owner_name", required=True, help="Your display name as owner")
def hive_create(name: str, owner_name: str) -> None:
    """Create a new Hive (GPU sharing group).

    \b
    Example:
        llmhosts hive create "My Hive" --owner-name Alice
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.headscale import HeadscaleClient
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    headscale = HeadscaleClient.from_env()
    mgr = HiveManager(data_dir, headscale=headscale)

    async def _create() -> Any:
        await mgr.initialize()
        h = await mgr.create_hive(name, owner_name)
        await mgr.sync_headscale_acls(_headscale_acl_path())
        return h

    try:
        h = _run_async(_create())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    console.print(f"[green]✓[/green] Created Hive [bold]{h.name}[/bold]")
    console.print(f"  ID:         {h.id}")
    console.print(f"  Invite:     [bold]{h.invite_code}[/bold]")
    console.print(f"  Share:      llmhosts hive join {h.invite_code} --name <friend-name>")

    if h.headscale_preauth_key:
        import os

        hs_url = os.environ.get("HEADSCALE_URL", "http://127.0.0.1:8080")
        console.print()
        console.print("  [bold cyan]Mesh networking enabled (Headscale)[/bold cyan]")
        console.print(f"  Tailnet:    {h.headscale_user}.hive.llmhosts.local")
        console.print("  Join your node to the mesh:")
        console.print(f"    [dim]tailscale up --login-server {hs_url} --authkey {h.headscale_preauth_key}[/dim]")
    else:
        console.print("  [dim](Headscale not configured — mesh networking disabled)[/dim]")


@hive.command("join")
@click.argument("invite_code")
@click.option("--name", "member_name", required=True, help="Your display name")
@click.option("--email", default=None, help="Your email (optional)")
def hive_join(invite_code: str, member_name: str, email: str | None) -> None:
    """Join a Hive using an invite code.

    Requires the Hive registry to be shared (e.g. same machine or shared path).

    \b
    Example:
        llmhosts hive join ABC12345 --name Bob
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.headscale import HeadscaleClient
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir, headscale=HeadscaleClient.from_env())

    async def _join() -> tuple[Any, Any]:
        await mgr.initialize()
        member = await mgr.join_hive(invite_code, member_name, email)
        hive = await mgr.get_hive()
        await mgr.sync_headscale_acls(_headscale_acl_path())
        return member, hive

    try:
        member, hive = _run_async(_join())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    console.print(f"[green]✓[/green] Joined Hive as [bold]{member.name}[/bold]")

    if hive and hive.headscale_preauth_key:
        import os

        hs_url = os.environ.get("HEADSCALE_URL", "http://127.0.0.1:8080")
        console.print()
        console.print("  [bold cyan]Connect to the mesh:[/bold cyan]")
        console.print(f"    [dim]tailscale up --login-server {hs_url} --authkey {hive.headscale_preauth_key}[/dim]")


@hive.command("invite")
def hive_invite() -> None:
    """Generate an invite link/code for your Hive."""
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir)

    async def _invite() -> Any:
        await mgr.initialize()
        return await mgr.generate_invite()

    try:
        inv = _run_async(_invite())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    console.print("[bold]Invite Code:[/bold]", inv.invite_code)
    console.print("[dim]Friends run: llmhosts hive join", inv.invite_code, "--name <their-name>[/dim]")


@hive.command("members")
def hive_members() -> None:
    """List all members of your Hive."""
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir)

    async def _list() -> Any:
        await mgr.initialize()
        hive = await mgr.get_hive()
        if hive is None:
            return None
        return await mgr.list_members()

    members = _run_async(_list())
    if members is None:
        console.print("[red]Error:[/red] Not in a Hive. Create or join one first.")
        sys.exit(1)
    if not members:
        console.print("[dim]No members.[/dim]")
        return

    table = Table(title="Hive Members", show_lines=False)
    table.add_column("Name", style="bold cyan")
    table.add_column("Role", style="dim")
    table.add_column("Karma", justify="right")
    table.add_column("Contributed", justify="right")
    table.add_column("Consumed", justify="right")

    for m in members:
        table.add_row(
            m.name,
            m.role.value,
            f"{m.karma_score:.2f}",
            f"{m.compute_contributed_hours:.1f}h",
            f"{m.compute_consumed_hours:.1f}h",
        )
    console.print(table)


@hive.command("karma")
def hive_karma() -> None:
    """Show karma (contribute/consume ratio) for all members."""
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.karma import KarmaEngine
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir)
    karma = KarmaEngine(mgr)

    async def _karma() -> Any:
        await mgr.initialize()
        return await karma.get_priority_order()

    order = _run_async(_karma())
    members = _run_async(mgr.list_members())
    name_map = {m.id: m.name for m in members} if members else {}

    if not order:
        console.print("[dim]No Hive or no members.[/dim]")
        return

    table = Table(title="Karma (contribute/consume)", show_lines=False)
    table.add_column("Rank", justify="right", style="dim")
    table.add_column("Member", style="bold cyan")
    table.add_column("Karma", justify="right")
    for i, (mid, karma_val) in enumerate(order, 1):
        name = name_map.get(mid, mid[:8])
        table.add_row(str(i), name, f"{karma_val:.2f}")
    console.print(table)


@hive.command("leave")
def hive_leave() -> None:
    """Leave the current Hive."""
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.headscale import HeadscaleClient
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir, headscale=HeadscaleClient.from_env())

    async def _leave() -> Any:
        await mgr.initialize()
        left = await mgr.leave_hive()
        await mgr.sync_headscale_acls(_headscale_acl_path())
        return left

    try:
        left = _run_async(_leave())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if left:
        console.print("[green]✓[/green] Left the Hive.")
    else:
        console.print("[red]Error:[/red] Not in a Hive or could not leave.")
        sys.exit(1)


@hive.command("status")
def hive_status() -> None:
    """Show Hive status (members, karma, routing)."""
    from rich.panel import Panel

    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.manager import HiveManager

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir)

    async def _status() -> Any:
        await mgr.initialize()
        return await mgr.get_hive()

    hive = _run_async(_status())
    if hive is None:
        console.print('[dim]No Hive. Create one with: llmhosts hive create "My Hive" --owner-name <name>[/dim]')
        return

    members_str = ", ".join(m.name for m in hive.members)
    header = f"Hive: [bold]{hive.name}[/bold]  |  Members: {len(hive.members)}/{hive.max_members}  |  Invite: {hive.invite_code}"
    console.print()
    console.print(Panel(header, title="[bold]LLMHosts Hive[/bold]", border_style="cyan"))
    console.print()
    console.print(f"  [bold]Members:[/bold] {members_str}")


@hive.command("share")
@click.option("--on", "share_on", is_flag=True, help="Enable sharing your node with the Hive")
@click.option("--off", "share_off", is_flag=True, help="Disable sharing your node with the Hive")
@click.option(
    "--schedule",
    default=None,
    metavar="HH:MM-HH:MM",
    help="Daily sharing window (e.g. 22:00-08:00). Omit to clear.",
)
@click.option(
    "--model", "model_name", default=None, metavar="MODEL", help="Per-model: --on adds to shared list, --off removes"
)
def hive_share(
    share_on: bool,
    share_off: bool,
    schedule: str | None,
    model_name: str | None,
) -> None:
    """Control node sharing (what and when you share with the Hive).

    \b
    Examples:
        llmhosts hive share --on
        llmhosts hive share --off
        llmhosts hive share --schedule "22:00-08:00"
        llmhosts hive share --model llama3:8b --on
        llmhosts hive share --model llama3:8b --off
    """
    from llmhosts.config import llmhosts_dir
    from llmhosts.hive.manager import HiveManager

    if share_on and share_off:
        console.print("[red]Error:[/red] Use either --on or --off, not both.")
        sys.exit(1)

    data_dir = llmhosts_dir()
    mgr = HiveManager(data_dir)

    async def _run() -> None:
        await mgr.initialize()
        if mgr.get_sharing_settings() is None:
            raise ValueError("Not in a Hive. Create or join one first.")
        if model_name is None:
            if share_on:
                await mgr.set_sharing_settings(sharing_enabled=True)
            if share_off:
                await mgr.set_sharing_settings(sharing_enabled=False)
        else:
            if share_on:
                settings = mgr.get_sharing_settings()
                raw = settings.get("shared_models") if settings else None
                current = list(raw) if isinstance(raw, list) else []
                if model_name not in current:
                    current.append(model_name)
                await mgr.set_sharing_settings(shared_models=current)
            elif share_off:
                settings = mgr.get_sharing_settings()
                raw = settings.get("shared_models") if settings else None
                current = [m for m in (list(raw) if isinstance(raw, list) else []) if m != model_name]
                await mgr.set_sharing_settings(shared_models=current)
        if schedule is not None:
            await mgr.set_sharing_settings(sharing_schedule=schedule.strip() or None)

    try:
        _run_async(_run())
    except ValueError as exc:
        console.print(f"[red]Error:[/red] {exc}")
        sys.exit(1)

    if share_on or share_off or schedule is not None:
        settings = mgr.get_sharing_settings()
        if settings:
            sched = settings.get("sharing_schedule") or "(any time)"
            models_raw = settings.get("shared_models")
            models = list(models_raw) if isinstance(models_raw, list) else []
            model_str = ", ".join(models) if models else "(all models)"
            console.print("[green]✓[/green] Sharing updated:")
            console.print(f"  Enabled:  {settings.get('sharing_enabled', True)}")
            console.print(f"  Schedule: {sched}")
            console.print(f"  Models:   {model_str}")


main.add_command(device)
main.add_command(hive)


@main.command("suggest-models")
@click.option(
    "--use-case",
    "-u",
    multiple=True,
    type=click.Choice(["coding", "general", "creative", "chat", "analysis"], case_sensitive=False),
    help="Prioritise for specific use case(s). Repeat for multiple.",
)
@click.option("--show-installed", is_flag=True, default=False, help="Include already-installed models in results.")
@click.option("--json-output", "json_out", is_flag=True, default=False, help="Output as JSON instead of a table.")
def suggest_models(use_case: tuple[str, ...], show_installed: bool, json_out: bool) -> None:
    """Suggest optimal models based on your hardware profile.

    Detects GPU/RAM, checks installed local models, and recommends
    the best models for your hardware and use case.

    \b
    Examples:
        llmhosts suggest-models
        llmhosts suggest-models --use-case coding
        llmhosts suggest-models -u coding -u general
        llmhosts suggest-models --json-output
    """
    from rich.panel import Panel

    from llmhosts.discovery.hardware import HardwareDetector
    from llmhosts.discovery.suggest import ModelSuggester

    # 1. Detect hardware
    console.print("[dim]Detecting hardware...[/dim]")
    try:
        hardware = _run_async(HardwareDetector.detect())
    except Exception as exc:
        console.print(f"[red]Error:[/red] Hardware detection failed: {exc}")
        sys.exit(1)

    # Display hardware summary
    if hardware.has_gpu:
        gpu_info = ", ".join(f"{g.name} ({g.vram_total_mb / 1024:.0f}GB)" for g in hardware.gpus)
        console.print(f"  GPU: [bold]{gpu_info}[/bold]")
    else:
        console.print(f"  [yellow]No GPU detected[/yellow] -- using RAM ({hardware.ram_total_gb:.0f}GB) for estimates")
    console.print(f"  RAM: {hardware.ram_total_gb:.0f}GB total, {hardware.ram_available_gb:.0f}GB available")

    # 2. Discover installed local models
    installed_models: list[str] = []
    try:
        from llmhosts.config import load_config
        from llmhosts.discovery.ollama import OllamaDiscovery

        config = load_config()

        async def _list_installed() -> list[str]:
            async with OllamaDiscovery(host=config.ollama.host, timeout=5.0) as ollama:
                if await ollama.is_available():
                    models = await ollama.list_models()
                    return [m.name for m in models]
            return []

        installed_models = _run_async(_list_installed())
        if installed_models:
            console.print(f"  Local models: [green]{len(installed_models)} installed[/green]")
        else:
            console.print("  Local models: [dim]none installed[/dim]")
    except Exception:
        console.print("  Inference engine: [dim]not available[/dim]")
    console.print()

    # 3. Generate suggestions
    suggester = ModelSuggester()
    use_cases = list(use_case) if use_case else None
    suggestions = suggester.suggest(
        hardware=hardware,
        installed_models=installed_models,
        use_cases=use_cases,
    )

    if not show_installed:
        not_installed = [s for s in suggestions if not s.already_installed]
        installed_count = len(suggestions) - len(not_installed)
        suggestions = not_installed
    else:
        installed_count = 0

    if not suggestions:
        console.print("[dim]No new model suggestions for your hardware.[/dim]")
        if installed_count:
            console.print(f"  ({installed_count} model(s) already installed -- use --show-installed to see them)")
        return

    # 4. Output
    if json_out:
        import json as json_mod

        output = [s.model_dump() for s in suggestions]
        console.print(json_mod.dumps(output, indent=2))
        return

    # Rich table output
    table = Table(title="Recommended Models", show_lines=True)
    table.add_column("Model", style="bold cyan", no_wrap=True, max_width=28)
    table.add_column("Size", justify="right", style="dim", no_wrap=True)
    table.add_column("VRAM", justify="right", no_wrap=True)
    table.add_column("Quality", justify="center", no_wrap=True)
    table.add_column("Speed", justify="center", no_wrap=True)
    table.add_column("Use Cases", max_width=25)
    table.add_column("Reason", max_width=50)

    def _stars(n: int) -> str:
        return "*" * n + " " * (5 - n)

    for s in suggestions:
        status_prefix = "[dim][installed] [/dim]" if s.already_installed else ""
        quality_style = "green" if s.quality_rating >= 4 else ("yellow" if s.quality_rating >= 3 else "dim")
        speed_style = "green" if s.speed_rating >= 4 else ("yellow" if s.speed_rating >= 3 else "dim")

        table.add_row(
            f"{status_prefix}{s.display_name}",
            f"{s.size_gb:.1f}GB",
            f"{s.vram_required_gb:.0f}GB",
            f"[{quality_style}]{_stars(s.quality_rating)}[/{quality_style}]",
            f"[{speed_style}]{_stars(s.speed_rating)}[/{speed_style}]",
            ", ".join(s.use_cases),
            s.reason,
        )

    console.print(table)

    if installed_count and not show_installed:
        console.print(f"\n[dim]({installed_count} already-installed model(s) hidden -- use --show-installed)[/dim]")

    # Show pull commands
    pull_cmds = suggester.get_pull_commands(suggestions)
    if pull_cmds:
        console.print()
        top_cmds = pull_cmds[:3]
        console.print(
            Panel(
                "\n".join(f"  [bold]{cmd}[/bold]" for cmd in top_cmds) + ("\n  ..." if len(pull_cmds) > 3 else ""),
                title="Quick Start",
                subtitle="Copy & paste to install",
                border_style="green",
            )
        )


# ---- Audit command group (TrustShield Pillar 2: TrustLedger) ----


@main.group()
def audit() -> None:
    """TrustLedger audit log management -- export, verify, and inspect."""


@audit.command("export")
@click.option("--format", "fmt", type=click.Choice(["jsonl", "csv"]), default="jsonl", help="Export format")
@click.option("--since", default=None, help="Start date (ISO format, e.g. 2026-02-01)")
@click.option("--until", default=None, help="End date (ISO format, e.g. 2026-02-22)")
@click.option("--event-type", default=None, help="Filter by event type")
@click.option("--model", default=None, help="Filter by model name")
@click.option("-o", "--output", "output_path", required=True, type=click.Path(), help="Output file path")
def audit_export(
    fmt: str, since: str | None, until: str | None, event_type: str | None, model: str | None, output_path: str
) -> None:
    """Export audit log to JSONL or CSV."""
    from datetime import datetime as dt
    from pathlib import Path

    from llmhosts.audit.exporter import AuditExporter, ExportFilter

    db_path = Path.home() / ".local" / "share" / "llmhosts" / "audit.db"
    if not db_path.exists():
        console.print("[red]No audit database found. Start the server first to create one.[/red]")
        raise SystemExit(1)

    filters = ExportFilter(
        since=dt.fromisoformat(since) if since else None,
        until=dt.fromisoformat(until) if until else None,
        event_type=event_type,
        model=model,
    )

    exporter = AuditExporter(db_path)
    out = Path(output_path)

    async def _do_export() -> int:
        if fmt == "csv":
            return await exporter.export_csv(out, filters)
        return await exporter.export_jsonl(out, filters)

    count = _run_async(_do_export())
    console.print(f"[green]Exported {count} entries to {output_path}[/green]")


@audit.command("verify")
@click.argument("file_path", type=click.Path(exists=True))
def audit_verify(file_path: str) -> None:
    """Verify HMAC chain integrity of an exported JSONL file."""
    import json
    from pathlib import Path

    from llmhosts.audit.hmac_chain import HMACChain

    entries: list[dict] = []
    path = Path(file_path)
    with open(path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                entries.append(json.loads(line))

    if not entries:
        console.print("[yellow]Empty file -- nothing to verify.[/yellow]")
        return

    # Check if entries have hmac field
    if "hmac" not in entries[0]:
        console.print(
            "[yellow]No HMAC field found in entries. Chain verification requires HMAC-enabled exports.[/yellow]"
        )
        return

    # Derive chain key from machine-bound seed (same as used for key encryption)
    from llmhosts.config import llmhosts_dir
    from llmhosts.keys.encryption import KeyEncryption

    enc = KeyEncryption(llmhosts_dir())
    seed = enc._get_machine_seed()
    chain = HMACChain(HMACChain.derive_key(seed))
    result = chain.verify_chain(entries)

    if result.valid:
        console.print(f"[green]✓ Chain verified: {result.verified_entries} entries intact[/green]")
    else:
        console.print(
            f"[red]✗ Chain broken at entry {result.first_break_index} (request_id={result.first_break_request_id})[/red]"
        )
        console.print(f"[red]  {result.message}[/red]")
        raise SystemExit(1)


@audit.command("stats")
def audit_stats() -> None:
    """Show summary statistics of the audit log."""
    from pathlib import Path

    from llmhosts.audit.exporter import AuditExporter

    db_path = Path.home() / ".local" / "share" / "llmhosts" / "audit.db"
    if not db_path.exists():
        console.print("[red]No audit database found.[/red]")
        raise SystemExit(1)

    exporter = AuditExporter(db_path)

    stats = _run_async(exporter.get_stats())

    table = Table(title="Audit Log Statistics", show_header=False)
    table.add_column("Metric", style="bold")
    table.add_column("Value")

    table.add_row("Total entries", str(stats["total_entries"]))
    table.add_row("Date range", f"{stats['date_range']['earliest']} → {stats['date_range']['latest']}")
    table.add_row("Cache hit rate", f"{stats['cache_hit_rate']:.1%}")
    table.add_row("Avg latency", f"{stats['avg_latency']}ms")

    if stats.get("top_models"):
        models_str = ", ".join(f"{m}: {c}" for m, c in stats["top_models"].items())
        table.add_row("Top models", models_str)

    console.print(table)


# ---- TLS command group (TrustShield Pillar 4: SecureMesh) ----


@main.group()
def tls() -> None:
    """SecureMesh TLS certificate management -- CA, certs, rotation."""


@tls.command("init")
@click.option("--force", is_flag=True, help="Re-create CA even if one exists")
def tls_init(force: bool) -> None:
    """Initialize a local CA and proxy server certificate."""
    from pathlib import Path

    from llmhosts.security.tls_manager import TLSManager

    tls_dir = Path.home() / ".config" / "llmhosts" / "tls"

    if (tls_dir / "ca.crt").exists() and not force:
        console.print("[yellow]CA already exists. Use --force to recreate.[/yellow]")
        return

    mgr = TLSManager(tls_dir)
    mgr.init_ca()
    mgr.issue_cert("proxy")
    console.print("[green]✓ Local CA initialized at ~/.config/llmhosts/tls/[/green]")
    console.print("[green]✓ Proxy server certificate issued[/green]")


@tls.command("issue")
@click.option("--name", required=True, help="Backend name (e.g. 'local', 'vllm-node1')")
def tls_issue(name: str) -> None:
    """Issue a certificate for a backend."""
    from pathlib import Path

    from llmhosts.security.tls_manager import TLSManager

    tls_dir = Path.home() / ".config" / "llmhosts" / "tls"
    if not (tls_dir / "ca.crt").exists():
        console.print("[red]No CA found. Run 'llmhosts tls init' first.[/red]")
        raise SystemExit(1)

    mgr = TLSManager(tls_dir)
    _key_path, crt_path = mgr.issue_cert(name)

    from cryptography import x509 as cx509

    cert = cx509.load_pem_x509_certificate(crt_path.read_bytes())
    console.print(f"[green]✓ Certificate issued for '{name}'[/green]")
    console.print(f"  Expires: {cert.not_valid_after_utc.isoformat()}")


@tls.command("list")
def tls_list() -> None:
    """List all certificates with expiry dates."""
    from pathlib import Path

    from llmhosts.security.tls_manager import TLSManager

    tls_dir = Path.home() / ".config" / "llmhosts" / "tls"
    if not tls_dir.exists():
        console.print("[yellow]No TLS directory found. Run 'llmhosts tls init' first.[/yellow]")
        return

    mgr = TLSManager(tls_dir)
    certs = mgr.list_certs()

    if not certs:
        console.print("[yellow]No certificates found.[/yellow]")
        return

    table = Table(title="TLS Certificates")
    table.add_column("Name", style="bold")
    table.add_column("Serial")
    table.add_column("Not Before")
    table.add_column("Not After")
    table.add_column("Fingerprint")
    table.add_column("Status")

    for cert in certs:
        status = "[red]REVOKED[/red]" if cert.revoked else "[green]Active[/green]"
        serial_str = hex(cert.serial)
        table.add_row(
            cert.name,
            serial_str[:16] + "..." if len(serial_str) > 16 else serial_str,
            cert.not_before.strftime("%Y-%m-%d"),
            cert.not_after.strftime("%Y-%m-%d"),
            cert.fingerprint[:16] + "...",
            status,
        )

    console.print(table)


@tls.command("rotate")
@click.option("--name", required=True, help="Backend name to rotate cert for")
def tls_rotate(name: str) -> None:
    """Rotate a certificate for a backend (issue new, keep old valid)."""
    from pathlib import Path

    from llmhosts.security.tls_manager import TLSManager

    tls_dir = Path.home() / ".config" / "llmhosts" / "tls"
    mgr = TLSManager(tls_dir)
    _key_path, crt_path = mgr.rotate_cert(name)

    from cryptography import x509 as cx509

    cert = cx509.load_pem_x509_certificate(crt_path.read_bytes())
    console.print(f"[green]✓ Certificate rotated for '{name}'[/green]")
    console.print(f"  New expiry: {cert.not_valid_after_utc.isoformat()}")


@tls.command("revoke")
@click.option("--name", required=True, help="Backend name to revoke cert for")
def tls_revoke(name: str) -> None:
    """Revoke a certificate for a backend."""
    from pathlib import Path

    from llmhosts.security.tls_manager import TLSManager

    tls_dir = Path.home() / ".config" / "llmhosts" / "tls"
    mgr = TLSManager(tls_dir)
    mgr.revoke_cert(name)
    console.print(f"[green]✓ Certificate revoked for '{name}'[/green]")


# ---- Monitor command (TrustShield Pillar 5: TransparencyDashboard) ----


@main.command("monitor")
@click.option("--interval", default=2.0, type=float, help="Update interval in seconds")
@click.option("--json", "as_json", is_flag=True, help="Output single snapshot as JSON")
def monitor(interval: float, as_json: bool) -> None:
    """Real-time resource monitoring -- CPU, memory, network.

    Shows a live updating display of system resources used by LLMHosts.
    Use --json for a single machine-readable snapshot.
    """
    from llmhosts.security.resource_monitor import ResourceMonitor

    rm = ResourceMonitor()
    snapshot = rm.snapshot()

    if as_json:
        import json as _json

        console.print(_json.dumps(snapshot.to_dict(), indent=2, default=str))
        return

    # Live display using Rich
    from rich.live import Live
    from rich.panel import Panel

    def _render_snapshot() -> Panel:
        s = rm.snapshot()
        lines = [
            f"[bold]CPU[/bold]:        {s.cpu_percent:.1f}%",
            f"[bold]Memory[/bold]:     {s.memory_mb:.1f} MB ({s.memory_percent:.1f}%)",
            f"[bold]Disk I/O[/bold]:   R {s.disk_read_bytes / 1024:.1f} KB  W {s.disk_write_bytes / 1024:.1f} KB",
            f"[bold]Network[/bold]:    ↑ {s.network_sent_bytes / 1024:.1f} KB  ↓ {s.network_recv_bytes / 1024:.1f} KB",
            f"[bold]Connections[/bold]: {s.open_connections}",
            f"[bold]Requests[/bold]:   {s.active_requests}",
        ]
        return Panel("\n".join(lines), title="LLMHosts Resource Monitor", subtitle="Ctrl+C to exit")

    try:
        with Live(_render_snapshot(), refresh_per_second=1.0 / interval) as live:
            import time

            while True:
                time.sleep(interval)
                live.update(_render_snapshot())
    except KeyboardInterrupt:
        console.print("[dim]Monitor stopped.[/dim]")


# ---- Alerts command group ----


@main.group()
def alerts() -> None:
    """Manage configurable alerting rules.

    View current alert states and test notification channels.
    """


@alerts.command("list")
@click.option("--config", "config_path", default=None, type=click.Path(exists=True), help="Config file path")
def alerts_list(config_path: str | None) -> None:
    """Show current alert rule definitions and their states.

    Displays a Rich table of all configured alerting rules with
    their condition, severity, duration, and target channels.
    """
    from pathlib import Path

    from llmhosts.config import AlertingConfig, load_config
    from llmhosts.health.alerting import AlertEvaluator, AlertRule

    cfg = load_config(Path(config_path) if config_path else None)
    alerting: AlertingConfig = cfg.alerting

    if not alerting.enabled:
        console.print(
            "[yellow]Alerting is disabled.[/yellow] Enable it in config.toml: [bold]alerting.enabled = true[/bold]"
        )
        return

    if not alerting.rules:
        console.print("[dim]No alerting rules configured.[/dim] Add rules in config.toml under [[alerting.rules]].")
        return

    rules = [
        AlertRule(
            name=r.name,
            condition=r.condition,
            duration_seconds=r.duration_seconds,
            severity=r.severity,
            channels=r.channels,
        )
        for r in alerting.rules
    ]

    evaluator = AlertEvaluator(rules)

    table = Table(title="Alerting Rules", show_lines=True)
    table.add_column("Name", style="bold cyan")
    table.add_column("Condition")
    table.add_column("Duration (s)", justify="right")
    table.add_column("Severity")
    table.add_column("Channels")
    table.add_column("State", justify="center")

    for rule in evaluator.rules:
        severity_style = "red bold" if rule.severity == "critical" else "yellow"
        table.add_row(
            rule.name,
            rule.condition,
            str(rule.duration_seconds),
            f"[{severity_style}]{rule.severity}[/{severity_style}]",
            ", ".join(rule.channels),
            "[green]OK[/green]",
        )

    console.print(table)

    # Show configured webhooks
    if alerting.webhooks:
        console.print("\n[bold]Configured Webhooks:[/bold]")
        for name, url in alerting.webhooks.items():
            masked = url[:30] + "..." if len(url) > 30 else url
            console.print(f"  [cyan]{name}[/cyan]: {masked}")


@alerts.command("test")
@click.option("--channel", default="console", help="Channel name to test (default: console)")
@click.option("--config", "config_path", default=None, type=click.Path(exists=True), help="Config file path")
def alerts_test(channel: str, config_path: str | None) -> None:
    """Send a test notification to a specific channel.

    Verifies that the notification channel is configured correctly
    by sending a synthetic test alert.
    """
    from pathlib import Path

    from llmhosts.config import load_config
    from llmhosts.health.alerting import AlertResult, AlertState
    from llmhosts.health.notifications import ConsoleChannel, NotificationChannel, WebhookChannel

    cfg = load_config(Path(config_path) if config_path else None)

    # Build channel map
    channels: dict[str, NotificationChannel] = {"console": ConsoleChannel()}
    for name, url in cfg.alerting.webhooks.items():
        channels[name] = WebhookChannel(url=url)

    if channel not in channels:
        console.print(f"[red]Unknown channel:[/red] {channel!r}")
        console.print(f"Available channels: {', '.join(channels)}")
        return

    test_alert = AlertResult(
        rule_name="test-alert",
        state=AlertState.FIRING,
        previous_state=AlertState.OK,
        changed_at=0.0,
        value=999.0,
        message="[WARNING] test-alert: this is a test notification",
    )

    success = _run_async(channels[channel].send(test_alert))

    if success:
        console.print(f"[green]Test notification sent to channel:[/green] [bold]{channel}[/bold]")
    else:
        console.print(f"[red]Test notification failed for channel:[/red] [bold]{channel}[/bold]")


# ---- Telemetry commands ----


@main.command("telemetry")
@click.option("--status", is_flag=True, default=False, help="Show telemetry status and event counts")
@click.option("--recent", default=0, type=int, help="Show N most recent events")
def telemetry_cmd(status: bool, recent: int) -> None:
    """Manage routing telemetry (Data Flywheel).

    Telemetry captures anonymized routing events to improve the router
    over time.  Query text is never stored -- only a SHA-256 hash is kept.
    """
    from llmhosts.telemetry.manager import get_emitter, telemetry_status

    info = telemetry_status()
    if info.get("enabled"):
        console.print("[green]Telemetry:[/green] [bold]ENABLED[/bold]")
        console.print(f"Events collected: [bold]{info.get('total_events', 0)}[/bold]")
        if info.get("oldest_event"):
            console.print(f"Oldest event:     {info['oldest_event']}")
        if info.get("newest_event"):
            console.print(f"Newest event:     {info['newest_event']}")
        console.print(f"Database:         {info.get('db_path', 'unknown')}")
    else:
        console.print("[dim]Telemetry:[/dim] DISABLED")

    if recent > 0:
        try:
            emitter = get_emitter()
            events = emitter.recent_events(limit=recent)
            if events:
                table = Table(title=f"Last {recent} Routing Events", show_lines=True)
                table.add_column("Timestamp", style="dim", no_wrap=True)
                table.add_column("Tier")
                table.add_column("Model", style="cyan")
                table.add_column("Latency (ms)", justify="right")
                table.add_column("Tokens In/Out", justify="right")
                table.add_column("Cache", justify="center")
                for ev in events:
                    table.add_row(
                        (ev.get("timestamp") or "")[:19],
                        ev.get("tier_used", ""),
                        ev.get("model", ""),
                        f"{ev.get('latency_ms', 0):.1f}",
                        f"{ev.get('input_tokens', 0)}/{ev.get('output_tokens', 0)}",
                        "[green]HIT[/green]" if ev.get("cache_hit") else "[dim]miss[/dim]",
                    )
                console.print(table)
            else:
                console.print("[dim]No events recorded yet.[/dim]")
        except Exception as exc:
            console.print(f"[red]Failed to retrieve events:[/red] {exc}")


# ---- Train-router command ----


@main.command("train-router")
@click.option(
    "--dataset",
    type=click.Choice(["synthetic", "telemetry"]),
    default="synthetic",
    show_default=True,
    help="Data source for training examples.",
)
@click.option(
    "--n-samples",
    default=10_000,
    show_default=True,
    type=int,
    help="Number of training samples (synthetic mode only).",
)
@click.option("--epochs", default=3, show_default=True, type=int, help="Training epochs.")
@click.option(
    "--output-dir",
    default=None,
    type=click.Path(),
    help="Directory for adapter checkpoints (default: ~/.llmhosts/weights/modernbert/checkpoints).",
)
def train_router(dataset: str, n_samples: int, epochs: int, output_dir: str | None) -> None:
    """Fine-tune the ModernBERT routing classifier with LoRA.

    Generates synthetic training data (or analyses telemetry distribution),
    fine-tunes the ModernBERT encoder with a LoRA adapter, and registers
    the resulting weights in the local weight registry.

    Requires: pip install llmhosts[full]

    \b
    Examples:
        llmhosts train-router
        llmhosts train-router --n-samples 5000 --epochs 5
        llmhosts train-router --dataset telemetry
        llmhosts train-router --output-dir ./my-weights
    """
    from pathlib import Path as _Path

    from llmhosts.training import TRAINING_AVAILABLE
    from llmhosts.training.registry import WeightRegistry
    from llmhosts.training.synthetic import SyntheticDataGenerator
    from llmhosts.training.trainer import LoRATrainer, TrainerConfig

    if not TRAINING_AVAILABLE:
        console.print(
            "[red]Training dependencies not available.[/red]\nInstall with: [bold]pip install llmhosts\\[full][/bold]"
        )
        raise SystemExit(1)

    # Collect training examples
    if dataset == "synthetic":
        console.print(f"[bold]Generating {n_samples:,} synthetic training examples…[/bold]")
        gen = SyntheticDataGenerator()
        examples = gen.generate(n_samples=n_samples)
        console.print(f"[green]Generated {len(examples):,} examples.[/green]")
    else:
        # telemetry mode: curate from DB (will return empty + stats)
        from llmhosts.telemetry.emitter import DEFAULT_TELEMETRY_DB as _DEFAULT_DB
        from llmhosts.training.curator import DataCurator

        console.print("[bold]Curating training data from telemetry…[/bold]")
        curator = DataCurator()
        examples, stats = curator.curate_from_telemetry(_DEFAULT_DB)
        if stats:
            console.print(
                f"[dim]Telemetry DB has {stats.total_events:,} events. "
                "Actual text not stored — using synthetic examples calibrated to distribution.[/dim]"
            )
        # Fall back to synthetic even in telemetry mode (privacy constraint)
        console.print("[dim]Falling back to synthetic data (privacy constraint).[/dim]")
        gen = SyntheticDataGenerator()
        examples = gen.generate(n_samples=n_samples)

    # Build config
    config = TrainerConfig(epochs=epochs)
    if output_dir is not None:
        config.output_dir = _Path(output_dir)

    console.print(
        f"[bold]Starting LoRA fine-tuning:[/bold] epochs={epochs}, rank={config.lora_rank}, lr={config.learning_rate}"
    )

    trainer = LoRATrainer()
    try:
        result = trainer.train(examples, config)
    except ImportError as exc:
        console.print(f"[red]Training failed:[/red] {exc}")
        raise SystemExit(1) from exc

    console.print("[green]Training complete![/green]")
    console.print(f"  Accuracy:    [bold]{result.accuracy:.1%}[/bold]")
    console.print(f"  Latency p50: [bold]{result.latency_ms:.1f}ms[/bold]")
    console.print(f"  Delta size:  [bold]{result.delta_size_mb:.1f}MB[/bold]")
    console.print(f"  Weights:     {result.weights_path}")

    # Register in the local registry
    registry = WeightRegistry()
    manifest = registry.register(result)
    console.print(f"[green]Registered as version:[/green] [bold]{manifest.version}[/bold]")
    console.print(
        "\nActivate with: [bold]llmhosts update-weights --force[/bold] "
        f"(or manually: registry.activate('{manifest.version}'))"
    )


# ---- Update-weights command ----


@main.command("update-weights")
@click.option(
    "--registry-url",
    default=None,
    help="Remote manifest URL (default: HuggingFace Hub).",
)
@click.option(
    "--force",
    is_flag=True,
    default=False,
    help="Force download even if local version is current.",
)
def update_weights(registry_url: str | None, force: bool) -> None:
    """Check for and download updated ModernBERT router weights.

    Fetches the remote weight manifest (HuggingFace Hub by default) and
    downloads newer adapter weights when available.  All network errors are
    handled gracefully.

    \b
    Examples:
        llmhosts update-weights
        llmhosts update-weights --registry-url https://my-hub.com/manifest.json
        llmhosts update-weights --force
    """
    from llmhosts.training.registry import WeightRegistry
    from llmhosts.training.updater import WeightUpdater

    registry = WeightRegistry()
    active = registry.get_active()

    console.print("[bold]Checking for ModernBERT weight updates…[/bold]")
    if active:
        console.print(f"  Current version: [dim]{active.version}[/dim]")
    else:
        console.print("  Current version: [dim]none[/dim]")

    if force and active:
        # Temporarily clear active to force comparison
        console.print("[dim]--force: clearing active version to trigger re-download.[/dim]")
        registry.deactivate_all()

    updater = WeightUpdater(registry=registry)
    result = updater.check_and_update(registry_url=registry_url)

    if result.updated:
        console.print(f"[green]Updated to version:[/green] [bold]{result.version}[/bold]")
        console.print(f"[dim]{result.message}[/dim]")
    else:
        console.print(f"[dim]{result.message}[/dim]")
        # Show current versions
        versions = registry.list_versions()
        if versions:
            table = Table(title="Local ModernBERT Weight Versions", show_lines=False)
            table.add_column("Version")
            table.add_column("Accuracy", justify="right")
            table.add_column("Samples", justify="right")
            table.add_column("Size (MB)", justify="right")
            table.add_column("Active", justify="center")
            for m in versions:
                table.add_row(
                    m.version,
                    f"{m.accuracy:.1%}",
                    f"{m.n_training_samples:,}",
                    f"{m.delta_size_mb:.1f}",
                    "[green]YES[/green]" if m.active else "[dim]no[/dim]",
                )
            console.print(table)
        else:
            console.print("[dim]No local weight versions registered yet.[/dim]")


# ---- Uninstall command ----


@main.command("uninstall")
@click.option("--all", "remove_all", is_flag=True, help="Also remove downloaded models")
@click.option("--keep-data", is_flag=True, help="Preserve the data directory (~/.llmhosts)")
@click.option("--remove-config", is_flag=True, help="Remove config and data (REMOVED by default)")
@click.option("--remove-engine", is_flag=True, help="Also remove the inference engine binary")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
def uninstall_cmd(remove_all: bool, keep_data: bool, remove_config: bool, remove_engine: bool, yes: bool) -> None:
    """Remove LLMHosts config and cache. AI models and engine are KEPT.

    We never delete your AI models or inference engine unless you
    explicitly ask with --all or --remove-engine.

    \b
    REMOVED (default):  Config, cache, audit DB, router weights
    KEPT (default):     AI models, inference engine

    \b
    Examples:
        llmhosts uninstall              # Remove config + data, keep models
        llmhosts uninstall --all        # Remove everything including models
        llmhosts uninstall --keep-data  # Preserve all data
        llmhosts uninstall -y           # Skip confirmation prompt
    """
    from pathlib import Path as _Path

    from rich.panel import Panel

    from llmhosts.uninstall import uninstall

    # Resolve actual paths based on current HOME (supports monkeypatch in tests)
    home = _Path.home()
    data_dir = str(home / ".llmhosts")
    config_dir = str(home / ".config" / "llmhosts")
    models_dir = str(home / ".llmhosts" / "models")

    data_exists = os.path.exists(data_dir)
    config_exists = os.path.exists(config_dir)
    anything_exists = data_exists or config_exists

    if not anything_exists:
        console.print("[dim]Nothing to remove.[/dim]")
        return

    # Build the uninstall plan panel
    lines: list[str] = ["The following will be removed:\n"]

    if keep_data:
        lines.append("[bold]KEPT:[/bold]")
        lines.append(f"  Data directory: {data_dir} (--keep-data)")
        if config_exists:
            lines.append(f"  Config: {config_dir} (--keep-data)")
    else:
        lines.append("[bold red]REMOVED:[/bold red]")
        if data_exists:
            lines.append(f"  Data directory: {data_dir}")
        if config_exists:
            lines.append(f"  Config: {config_dir}")

    lines.append("")
    lines.append("[bold green]KEPT:[/bold green]")
    lines.append("  AI models -- we never delete your downloaded models")
    if not remove_engine:
        lines.append("  Inference engine -- your GPU setup stays intact")
    lines.append("")

    if remove_engine:
        lines.append("[bold yellow]CAUTION:[/bold yellow] --remove-engine will also remove the inference engine")
        lines.append("")

    if remove_all:
        lines.append("[bold yellow]--all:[/bold yellow] Models will also be removed")
        lines.append("")

    lines.append("[dim]To remove the package: pip uninstall llmhosts[/dim]")

    panel_content = "\n".join(lines)
    console.print(Panel(panel_content, title="[bold]Uninstall Plan[/bold]", border_style="dim"))

    if not yes and not click.confirm("Continue?"):
        click.echo("Cancelled.")
        return

    # Effective keep_data: if --keep-data is set, or neither --keep-data nor --remove-config
    # are set but the command is just `uninstall` (default removes data)
    result = uninstall(
        data_dir=data_dir,
        config_dir=config_dir,
        models_dir=models_dir,
        confirm=False,
        remove_all=remove_all,
        keep_data=keep_data,
    )

    any_removed = any(result.values())
    if any_removed:
        for what, removed in result.items():
            if removed:
                console.print(f"  [green]Removed:[/green] {what}")
        console.print("[bold green]Done.[/bold green]")
    else:
        console.print("[dim]Nothing to remove.[/dim]")


# ---- Remit commands ----


@main.group()
def remit() -> None:
    """Manage GPU remit -- control which backends LLMHosts can use."""


@remit.command("show")
def remit_show() -> None:
    """Show current remit configuration."""
    from llmhosts.config import load_config

    cfg = load_config()
    r = cfg.remit

    console.print(f"[bold]Remit Mode:[/bold] {r.mode}")
    if r.mode == "allowlist":
        console.print(f"[bold]Allowed:[/bold] {', '.join(r.allowlist) or '(none)'}")
    elif r.mode == "denylist":
        console.print(f"[bold]Denied:[/bold] {', '.join(r.denylist) or '(none)'}")
    if r.schedule:
        console.print("[bold]Schedule:[/bold]")
        for s in r.schedule:
            days_str = ", ".join(s.days)
            console.print(f"  {s.start_hour}:00-{s.end_hour}:00 on {days_str}")
    else:
        console.print("[dim]No schedule restrictions (available 24/7)[/dim]")
    console.print(f"[dim]Hot reload: {'enabled' if r.hot_reload else 'disabled'}[/dim]")


@remit.command("allow")
@click.argument("backend_id")
def remit_allow(backend_id: str) -> None:
    """Add a backend to the allowlist."""
    from llmhosts.config import load_config, save_config

    cfg = load_config()
    if cfg.remit.mode != "allowlist":
        cfg.remit.mode = "allowlist"
    if backend_id not in cfg.remit.allowlist:
        cfg.remit.allowlist.append(backend_id)
    save_config(cfg)
    console.print(f"[green]Added '{backend_id}' to allowlist[/green]")


@remit.command("deny")
@click.argument("backend_id")
def remit_deny(backend_id: str) -> None:
    """Add a backend to the denylist."""
    from llmhosts.config import load_config, save_config

    cfg = load_config()
    if cfg.remit.mode != "denylist":
        cfg.remit.mode = "denylist"
    if backend_id not in cfg.remit.denylist:
        cfg.remit.denylist.append(backend_id)
    save_config(cfg)
    console.print(f"[red]Added '{backend_id}' to denylist[/red]")


@remit.command("schedule")
@click.option("--start", type=int, required=True, help="Start hour (0-23)")
@click.option("--end", type=int, required=True, help="End hour (1-24)")
@click.option("--days", default="mon,tue,wed,thu,fri,sat,sun", help="Comma-separated days")
def remit_schedule(start: int, end: int, days: str) -> None:
    """Set a schedule for when GPUs are available."""
    from llmhosts.config import RemitScheduleEntry, load_config, save_config

    cfg = load_config()
    entry = RemitScheduleEntry(
        start_hour=start,
        end_hour=end,
        days=[d.strip().lower() for d in days.split(",")],
    )
    cfg.remit.schedule.append(entry)
    save_config(cfg)
    console.print(f"[green]Added schedule: {start}:00-{end}:00 on {days}[/green]")
