"""``llmhosts up`` -- Infrastructure Autopilot.

Five-phase pipeline that turns any machine into production AI infrastructure:

1. **Assess**     -- Detect GPU hardware + recommend model for available VRAM
2. **Provision**  -- Download recommended GGUF model if none present locally
3. **Load**       -- Load model into LLMHosts Core inference engine
4. **Connect**    -- Open tunnel + authenticate + register device
5. **Run**        -- Background watcher (live model detection) + heartbeat + savings tracking

Three customer flows handled transparently:
- **Clean box**: detect GPU -> download model -> load into Core -> start proxy -> connect
- **Existing user**: detect GPU + local models -> load into Core -> connect
- **Power user**: multi-GPU detection -> load model -> connect

``pip install llmhosts && llmhosts up`` -- that's it.
"""

from __future__ import annotations

import asyncio
import logging
import platform
import signal
from typing import TYPE_CHECKING, Any

import httpx

from llmhosts.auth.device_flow import AuthenticationError, Credentials, DeviceCodeFlow
from llmhosts.discovery.models import (  # noqa: TC001 — used at runtime
    DiscoveredEngine,
    HardwareProfile,
)
from llmhosts.discovery.savings import SavingsTracker
from llmhosts.discovery.scanner import UniversalScanner
from llmhosts.discovery.watcher import DiscoveryWatcher
from llmhosts.ports import find_available_port, start_ipv6_bridge
from llmhosts.progress import LogProgressReporter, ProgressReporter, RichProgressReporter
from llmhosts.proxy.dispatcher import BackendEntry
from llmhosts.tunnel.manager import TunnelManager
from llmhosts.tunnel.models import TunnelConfig

if TYPE_CHECKING:
    from rich.console import Console

logger = logging.getLogger(__name__)

# Heartbeat interval in seconds
_HEARTBEAT_INTERVAL = 30

# Server URL (production)
_DEFAULT_SERVER_URL = "https://llmhosts.com"


def _engine_to_backend_entry(engine: DiscoveredEngine) -> BackendEntry:
    """Convert a discovered engine into a dispatcher BackendEntry."""
    return BackendEntry(
        name=f"{engine.engine_type}-{engine.host}:{engine.port}",
        base_url=engine.base_url,
        backend_type=engine.engine_type,
        models=[m.id for m in engine.models],
        priority=0 if engine.host in ("127.0.0.1", "localhost") else 5,
        healthy=engine.available,
    )


class LLMHostUp:
    """Orchestrates the ``llmhosts up`` Infrastructure Autopilot."""

    def __init__(
        self,
        console: Console,
        *,
        server_url: str = _DEFAULT_SERVER_URL,
        port: int = 11400,
        no_tui: bool = True,
        dry_run: bool = False,
    ) -> None:
        self._console = console
        self._server_url = server_url
        self._port = port
        self._no_tui = no_tui
        self._dry_run = dry_run
        self._progress: ProgressReporter = LogProgressReporter() if no_tui else RichProgressReporter()
        self._tunnel_mgr = TunnelManager()
        self._auth_flow = DeviceCodeFlow(server_url=server_url)
        self._credentials: Credentials | None = None
        self._tunnel_url: str | None = None
        self._device_name: str = platform.node() or "my-device"
        self._shutdown_event = asyncio.Event()
        self._heartbeat_task: asyncio.Task[None] | None = None
        self._savings = SavingsTracker()
        self._scanner = UniversalScanner()
        self._watcher: DiscoveryWatcher | None = None
        self._engines: list[DiscoveredEngine] = []
        self._hardware: HardwareProfile | None = None
        self._start_time: float = 0.0  # set when proxy starts
        self._dispatcher: Any = None  # set during proxy startup
        # Core-first pipeline state
        self._recommended_model: dict[str, Any] | None = None  # set in Phase 1
        self._core_engine: Any = None  # LLMHostsInferenceEngine, set in Phase 3
        self._local_models: list[str] = []  # models found locally
        # Graceful degradation tracking -- records what's working and what failed
        self._degraded: dict[str, str] = {}  # phase -> failure reason

    def _find_available_port(self, preferred: int) -> int:
        """Find an available port, starting from *preferred*."""
        port = find_available_port(preferred)
        if port != preferred and port != 0:
            self._console.print(f"  [yellow]Port {preferred} is in use[/yellow] — using port [bold]{port}[/bold]")
        return port

    async def run(self) -> None:
        """Execute the full 5-phase Infrastructure Autopilot pipeline."""
        self._console.print()
        self._console.print("[bold cyan]LLMHosts[/bold cyan] [dim]— Infrastructure Autopilot[/dim]")
        self._console.print()

        # ── Pre-flight: find an available port ─────────────────────
        self._port = self._find_available_port(self._port)

        # ── Phase 1: Assess — detect GPU hardware ────────────────────
        await self._phase_assess()

        # ── Phase 2: Provision — download model if needed ────────────
        await self._phase_provision()

        # ── Phase 3: Load — load model into Core engine ──────────────
        await self._phase_load()

        # ── Dry-run: stop before starting services ────────────────────
        if self._dry_run:
            self._print_dry_run_summary()
            return

        # ── Phase 4: Connect ─────────────────────────────────────────
        await self._phase_connect()

        # ── Phase 5: Run ─────────────────────────────────────────────
        await self._phase_run()

    # ==================================================================
    # Phase 1: Assess — GPU hardware detection + model recommendation
    # ==================================================================

    async def _phase_assess(self) -> None:
        """Detect GPU hardware and recommend a model for available VRAM."""
        self._console.print("[bold]Phase 1:[/bold] Scanning hardware...")

        # GPU detection via Rust extension (or fallback)
        gpu_info: dict[str, Any] | None = None
        try:
            from llmhosts.inference.gpu import detect_gpus

            gpu_info = detect_gpus()
        except Exception:
            logger.debug("GPU detection not available")

        # CPU info
        import os

        cpu_count = os.cpu_count() or 1

        # Also attempt legacy hardware detection for HardwareProfile
        try:
            from llmhosts.discovery.hardware import HardwareDetector

            self._hardware = await HardwareDetector.detect()
        except Exception:
            logger.debug("Legacy hardware detection skipped")

        # Display GPU info
        if gpu_info and gpu_info.get("gpus"):
            gpu_names = []
            for g in gpu_info["gpus"]:
                name = g.get("name", "Unknown GPU")
                vram = g.get("vram_total_mb", 0)
                gpu_names.append(f"{name} ({vram}MB)")
            self._console.print(f"  GPU: {', '.join(gpu_names)}")
        elif self._hardware and self._hardware.gpus:
            gpu_parts = [f"{g.name} ({g.vram_total_mb / 1024:.0f}GB)" for g in self._hardware.gpus]
            self._console.print(f"  GPU: {', '.join(gpu_parts)}")
        else:
            self._console.print("  GPU: None detected (CPU-only mode)")

        # RAM display
        if self._hardware:
            self._console.print(f"  RAM: {self._hardware.ram_total_gb:.0f}GB | CPUs: {cpu_count}")
        else:
            self._console.print(f"  CPUs: {cpu_count}")

        # Model recommendation based on VRAM
        from llmhosts.models.registry import recommend_for_vram

        vram_mb = 0
        if gpu_info and gpu_info.get("total_free_vram_mb"):
            vram_mb = gpu_info["total_free_vram_mb"]
        elif self._hardware and self._hardware.gpus:
            vram_mb = int(max(g.vram_total_mb for g in self._hardware.gpus))

        self._recommended_model = recommend_for_vram(vram_mb)
        if self._recommended_model:
            self._console.print(
                f"  Recommended model: [bold]{self._recommended_model['name']}[/bold] "
                f"({self._recommended_model['size_gb']}GB)"
            )

        # Check for locally downloaded models
        from llmhosts.models.download import list_local_models

        self._local_models = list_local_models()
        if self._local_models:
            self._console.print(f"  [green]Local models:[/green] {', '.join(self._local_models)}")

        # Also scan for local models via the scanner
        try:
            self._console.print("  [dim]Scanning for inference engines...[/dim]")
            self._engines = await self._scanner.scan()
            if self._engines:
                total_models = sum(len(e.models) for e in self._engines)
                self._console.print(
                    f"  [green]Found {len(self._engines)} engine(s)[/green] with {total_models} model(s)"
                )
        except Exception:
            logger.debug("Engine scan failed")

        # Network check (informational -- offline mode still works)
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                await client.head("https://llmhosts.com")
        except Exception:
            self._degraded["network"] = "No internet connectivity detected"
            self._console.print("  [yellow]Network:[/yellow] Offline detected. Existing local models will still work.")

        # No-GPU performance warning
        has_gpu = bool((gpu_info and gpu_info.get("gpus")) or (self._hardware and self._hardware.has_gpu))
        if not has_gpu:
            self._console.print(
                "  [dim]No GPU detected -- CPU inference is available but slower. "
                "A GPU is recommended for best performance.[/dim]"
            )

    # ==================================================================
    # Phase 2: Provision — download recommended GGUF model
    # ==================================================================

    async def _phase_provision(self) -> None:
        """Download recommended GGUF model if none present locally.

        Graceful degradation: if download fails, we record the failure but
        continue. The proxy can still serve cloud API keys or external engines
        even without a local model.
        """
        from llmhosts.models.download import download_model, list_local_models

        local = list_local_models()
        if local:
            self._local_models = local
            self._console.print()
            self._console.print(f"[bold]Phase 2:[/bold] Found {len(local)} model(s): {', '.join(local)}")
            return

        if not self._recommended_model:
            self._console.print()
            self._console.print("[bold]Phase 2:[/bold] No model recommendation available. Skipping download.")
            self._degraded["provision"] = "No model recommendation"
            return

        self._console.print()
        model_name = self._recommended_model["name"]
        model_size = self._recommended_model["size_gb"]
        self._console.print(f"[bold]Phase 2:[/bold] Downloading {model_name} ({model_size}GB)...")

        try:
            path = download_model(model_name)
            self._console.print(f"  [green]Model downloaded[/green]: {path}")
            self._local_models = list_local_models()
        except Exception as exc:
            self._degraded["provision"] = str(exc)
            logger.debug("Model download failed: %s", exc)
            self._console.print(f"  [yellow]Download error:[/yellow] {exc}")
            self._console.print("  [dim]Download later: llmhosts pull <model>[/dim]")
            self._console.print(
                "  [dim]Proxy will start in cloud-only mode. Add cloud API keys: llmhosts keys add openai sk-...[/dim]"
            )

    # ==================================================================
    # Phase 3: Load — load model into LLMHosts Core engine
    # ==================================================================

    async def _phase_load(self) -> None:
        """Load a local model into the LLMHosts Core inference engine."""
        from llmhosts.models.registry import model_path

        if not self._local_models:
            self._console.print()
            self._console.print("[bold]Phase 3:[/bold] No local models available. Skipping engine load.")
            return

        self._console.print()
        model_name = self._local_models[0]
        self._console.print(f"[bold]Phase 3:[/bold] Loading {model_name} into Core engine...")

        try:
            path = model_path(model_name)
        except ValueError as exc:
            self._degraded["load"] = str(exc)
            self._console.print(f"  [yellow]Model path error:[/yellow] {exc}")
            return

        try:
            from llmhosts.inference.core import LLMHostsInferenceEngine

            engine = LLMHostsInferenceEngine()
            info = engine.load_model(path)
            self._core_engine = engine
            arch = info.get("architecture", "unknown")
            backend = engine.backend_name
            self._console.print(f"  [green]Loaded[/green] {model_name}: {arch} [dim](compute: {backend})[/dim]")
        except RuntimeError as exc:
            # Core engine not available (Rust extension not compiled)
            self._degraded["load"] = str(exc)
            self._console.print(f"  [yellow]Core engine not available:[/yellow] {exc}")
            self._console.print("  [dim]Falling back to external engines or cloud API keys.[/dim]")
        except Exception as exc:
            self._degraded["load"] = str(exc)
            logger.debug("Model load failed: %s", exc)
            self._console.print(f"  [yellow]Load error:[/yellow] {exc}")
            self._console.print("  [dim]Proxy will start without local model.[/dim]")

    # ------------------------------------------------------------------
    # Degraded-mode summary
    # ------------------------------------------------------------------

    def _print_degraded_summary(self) -> None:
        """Print a summary of degraded components if any phases failed."""
        if not self._degraded:
            return

        from rich.panel import Panel

        lines: list[str] = []
        lines.append("[bold yellow]Some components are running in degraded mode:[/bold yellow]")
        lines.append("")

        if "provision" in self._degraded:
            lines.append("[yellow]>[/yellow] [bold]Model download:[/bold] failed")
            lines.append("    No local models available. Add cloud API keys or pull a model.")
            lines.append("    [dim]llmhosts pull qwen2.5:7b  |  llmhosts keys add openai sk-...[/dim]")

        if "load" in self._degraded:
            lines.append("[yellow]>[/yellow] [bold]Core engine:[/bold] unavailable")
            lines.append("    Model not loaded into local engine. External engines or cloud API may still work.")
            lines.append("    [dim]Build Core: cd llmhosts_core && maturin develop[/dim]")

        if "tunnel" in self._degraded:
            lines.append("[yellow]>[/yellow] [bold]Remote access:[/bold] unavailable")
            lines.append(f"    Proxy is accessible locally only at http://localhost:{self._port}")

        if "auth" in self._degraded:
            lines.append("[yellow]>[/yellow] [bold]Cloud authentication:[/bold] skipped")
            lines.append("    Device not registered with LLMHosts.com. Local API still works.")

        if "network" in self._degraded:
            lines.append("[yellow]>[/yellow] [bold]Network:[/bold] offline")
            lines.append("    Working with existing local models only.")
            lines.append("    [dim]Load models offline: llmhosts import (coming soon)[/dim]")

        self._console.print()
        self._console.print(
            Panel(
                "\n".join(lines),
                title="[bold yellow]Degraded Mode[/bold yellow]",
                border_style="yellow",
                padding=(1, 2),
            )
        )

    # ==================================================================
    # Phase 4: Connect — tunnel + authenticate + register device
    # ==================================================================

    async def _phase_connect(self) -> None:
        """Start tunnel, authenticate, and register device.

        Graceful degradation: if tunnel or auth fails, the proxy still runs
        locally. We track failures in ``_degraded`` and show a summary.
        """
        self._console.print()
        self._console.print("[bold]Phase 4:[/bold] Connecting to LLMHosts.com...")

        # Tunnel
        try:
            await self._start_tunnel()
        except Exception as exc:
            self._degraded["tunnel"] = str(exc)
            logger.debug("Tunnel failed: %s", exc)
            self._console.print(f"  [dim]Tunnel unavailable: {exc}[/dim]")

        if not self._tunnel_url:
            self._console.print(f"  [yellow]No tunnel[/yellow] -- local access only at http://localhost:{self._port}")

        # Authenticate
        try:
            await self._authenticate()
        except Exception as exc:
            self._degraded["auth"] = str(exc)
            logger.debug("Auth failed: %s", exc)
            self._console.print(f"  [dim]Authentication skipped: {exc}[/dim]")
            self._console.print(f"  [dim]Proxy runs locally at http://localhost:{self._port}[/dim]")

        if self._credentials:
            # Register device even without tunnel — dashboard still shows status
            await self._register_device()

        # Show degraded state summary if any issues
        self._print_degraded_summary()

        # Success banner
        self._print_success()

    # ==================================================================
    # Phase 5: Run — proxy + watcher + heartbeat + savings
    # ==================================================================

    async def _phase_run(self) -> None:
        """Start proxy, background watcher, heartbeat, and wait for shutdown."""
        await self._run_with_heartbeat()

    # ------------------------------------------------------------------
    # Tunnel
    # ------------------------------------------------------------------

    async def _start_tunnel(self) -> None:
        """Start a tunnel with auto-detection.

        Tunnel is a paid feature (Pro+). If the current plan does not include
        ``tunnel_public``, this method logs a message and returns without starting.
        """
        from llmhosts.plans import get_plan_limits

        if self._credentials:
            limits = get_plan_limits(self._credentials.plan)
            if not limits.tunnel_public:
                self._console.print(
                    "  [dim]Tunnel requires Pro or higher — skipped.[/dim] [cyan]Upgrade at llmhosts.com/pricing[/cyan]"
                )
                return

        self._console.print("  [dim]Starting tunnel...[/dim]")

        config = TunnelConfig(
            port=self._port,
            auth_required=False,
        )

        status = await self._tunnel_mgr.start(config)

        if status.active and status.url:
            self._tunnel_url = status.url
            provider_label = status.provider.value.title()
            self._console.print(f"  [green]Tunnel active[/green] via {provider_label}: {status.url}")
        else:
            error = status.error or "Unknown tunnel error"
            self._console.print(f"  [dim]Tunnel: {error}[/dim]")

    # ------------------------------------------------------------------
    # Authentication
    # ------------------------------------------------------------------

    async def _authenticate(self) -> None:
        """Authenticate with LLMHosts.com via Device Code Flow."""
        existing = await self._auth_flow.load_credentials()
        if existing:
            try:
                async with httpx.AsyncClient(timeout=10.0) as client:
                    resp = await client.get(
                        f"{self._server_url}/api/devices",
                        headers={"Authorization": f"Bearer {existing.api_key}"},
                    )
                    if resp.status_code == 200:
                        # Refresh plan from server so stale cached plan is never used
                        existing = await self._auth_flow.refresh_plan(existing)
                        self._credentials = existing
                        self._console.print(
                            f"  [green]Authenticated[/green] as {existing.email} ({existing.plan} plan)"
                        )
                        return
            except Exception:
                pass

        self._console.print()
        self._console.print("[bold]Authenticate with LLMHosts.com[/bold]")

        try:
            creds = await self._auth_flow.authenticate(
                auto_open_browser=True,
                on_user_code=self._display_auth_code,
            )
            self._credentials = creds
            self._console.print()
            self._console.print(f"  [green]Authenticated[/green] as {creds.email} ({creds.plan} plan)")
        except AuthenticationError as exc:
            self._console.print(f"  [dim]Auth skipped: {exc}[/dim]")

    def _display_auth_code(self, user_code: str, verification_uri: str) -> None:
        """Display the device code for the user."""
        from rich.panel import Panel

        self._console.print()
        self._console.print(
            Panel(
                f"[bold]Open[/bold] [cyan]{verification_uri}[/cyan]\n"
                f"[bold]Enter code:[/bold] [bold yellow]{user_code}[/bold yellow]",
                title="[bold]Authorize This Device[/bold]",
                border_style="blue",
                padding=(1, 2),
            )
        )
        self._console.print("[dim]Waiting for authorization...[/dim]")

    # ------------------------------------------------------------------
    # Device registration
    # ------------------------------------------------------------------

    async def _register_device(self) -> None:
        """Register this device with the LLMHosts.com server."""
        if not self._credentials:
            return

        self._console.print("  [dim]Registering device...[/dim]")

        hardware_summary = self._format_hardware_summary()
        all_models: list[str] = []
        for eng in self._engines:
            all_models.extend(m.id for m in eng.models)

        try:
            async with httpx.AsyncClient(timeout=15.0) as client:
                resp = await client.post(
                    f"{self._server_url}/api/devices",
                    headers={"Authorization": f"Bearer {self._credentials.api_key}"},
                    json={
                        "name": self._device_name,
                        "tunnel_url": self._tunnel_url or "",
                        "models": all_models,
                        "hardware": hardware_summary,
                        "engines": [
                            {
                                "type": e.engine_type,
                                "host": e.host,
                                "port": e.port,
                                "models": [m.id for m in e.models],
                            }
                            for e in self._engines
                        ],
                    },
                )
                if resp.status_code in (200, 201):
                    self._console.print(f'  [green]Device registered[/green] as "{self._device_name}"')
                else:
                    self._console.print(f"  [yellow]Registration warning:[/yellow] {resp.text}")
        except Exception as exc:
            self._console.print(f"  [yellow]Registration failed:[/yellow] {exc}")

    def _format_hardware_summary(self) -> str:
        """Format hardware profile as a concise string."""
        if not self._hardware:
            return platform.machine()
        hw = self._hardware
        parts: list[str] = []
        if hw.gpus:
            for gpu in hw.gpus:
                vram_gb = round(gpu.vram_total_mb / 1024, 1)
                parts.append(f"{gpu.name} ({vram_gb}GB)")
        parts.append(f"{hw.ram_total_gb:.0f}GB RAM")
        return ", ".join(parts)

    # ------------------------------------------------------------------
    # Success banner
    # ------------------------------------------------------------------

    def _print_success(self) -> None:
        """Print the success banner with topology and API details."""
        from rich.panel import Panel

        self._console.print()

        # Topology summary
        topology_parts = []
        if self._core_engine and self._core_engine.is_loaded:
            topology_parts.append("Core engine (loaded)")
        if self._local_models:
            topology_parts.append(f"{len(self._local_models)} local model(s)")
        total_models = len(self._local_models)
        for eng in self._engines:
            count = len(eng.models)
            total_models += count
            topology_parts.append(f"{eng.engine_type} ({count} models)")
        topology = " + ".join(topology_parts) if topology_parts else "No engines"

        # API info
        if self._credentials:
            relay_base = f"{self._server_url}/api/relay/v1"
            api_section = (
                f"\n"
                f"[bold]API Endpoint:[/bold]  [cyan]{relay_base}[/cyan]\n"
                f"[bold]API Key:[/bold]       [dim]{self._credentials.api_key[:20]}...[/dim]\n"
            )
        else:
            api_section = f"\n[bold]Local API:[/bold]     [cyan]http://localhost:{self._port}/v1[/cyan]\n"

        self._console.print(
            Panel(
                f"[bold green]Your infrastructure is live![/bold green]\n"
                f"\n"
                f"[bold]Topology:[/bold]      {topology}\n"
                f"[bold]Models:[/bold]        {total_models} available\n"
                f"[bold]Device:[/bold]        {self._device_name}\n"
                f"[bold]Tunnel:[/bold]        {self._tunnel_url or 'local only'}"
                f"{api_section}"
                f"\n"
                f"[bold]Savings:[/bold]       Tracking active — every request shows cloud savings\n"
                f"[bold]Discovery:[/bold]     Live — new models detected within 5 seconds",
                title="[bold]LLMHosts — Infrastructure Autopilot[/bold]",
                border_style="green",
                padding=(1, 2),
            )
        )
        self._console.print()
        self._console.print("[dim]Press Ctrl+C to disconnect.[/dim]")
        self._console.print()

    # ------------------------------------------------------------------
    # Dry-run summary
    # ------------------------------------------------------------------

    def _print_dry_run_summary(self) -> None:
        """Print what was discovered without starting any services."""
        from llmhosts.server import LLMHostsServer

        self._console.print()
        self._console.print("[bold green]Dry-run complete.[/bold green] The following was discovered:")

        # Hardware
        if self._hardware:
            hw = self._hardware
            if hw.gpus:
                gpu_parts = [f"{g.name} ({g.vram_total_mb / 1024:.0f}GB)" for g in hw.gpus]
                self._console.print(f"  Hardware: {', '.join(gpu_parts)}, {hw.ram_total_gb:.0f}GB RAM")
            else:
                self._console.print(f"  Hardware: CPU only, {hw.ram_total_gb:.0f}GB RAM")
        else:
            self._console.print("  Hardware: Unknown")

        # Local models
        if self._local_models:
            self._console.print(f"  Local models: {', '.join(self._local_models)}")
        else:
            self._console.print("  Local models: None (would download recommended model)")

        # Recommended model
        if self._recommended_model:
            self._console.print(
                f"  Recommended: {self._recommended_model['name']} ({self._recommended_model['size_gb']}GB)"
            )

        # Core engine
        if self._core_engine and self._core_engine.is_loaded:
            self._console.print("  Core engine: [green]loaded[/green]")
        else:
            self._console.print("  Core engine: not loaded")

        # External engines
        if self._engines:
            total_models = sum(len(e.models) for e in self._engines)
            self._console.print(f"  External engines: {len(self._engines)} found with {total_models} model(s)")
            for eng in self._engines:
                model_names = ", ".join(m.id for m in eng.models[:5])
                suffix = f" (+{len(eng.models) - 5} more)" if len(eng.models) > 5 else ""
                self._console.print(f"    {eng.engine_type} @ {eng.host}:{eng.port}: {model_names}{suffix}")

        self._console.print(f"  Port: {self._port}")
        self._console.print(f"\n[dim]{LLMHostsServer.read_only_notice()}[/dim]")

    # ------------------------------------------------------------------
    # Run loop: proxy + watcher + heartbeat
    # ------------------------------------------------------------------

    async def _run_with_heartbeat(self) -> None:
        """Run proxy, background watcher, heartbeat, and savings tracker."""
        loop = asyncio.get_event_loop()

        def _on_signal() -> None:
            self._shutdown_event.set()

        # add_signal_handler is Unix-only; on Windows fall back to signal.signal
        if platform.system() != "Windows":
            for sig in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(sig, _on_signal)
        else:
            signal.signal(signal.SIGINT, lambda *_: _on_signal())
            signal.signal(signal.SIGTERM, lambda *_: _on_signal())

        # Record start time for uptime tracking
        import time as _time_mod

        self._start_time = _time_mod.time()

        # Start IPv6 bridge BEFORE proxy — claims [::1]:PORT so Cursor can't
        # squat it while uvicorn is starting on 127.0.0.1:PORT.
        ipv6_bridge = await start_ipv6_bridge(self._port)

        # Start heartbeat
        self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())

        # Start proxy server
        proxy_task = asyncio.create_task(self._run_proxy())

        # Start discovery watcher (detects model pulls/removes within 5s)
        watcher_task = asyncio.create_task(self._run_watcher())

        # Wait for shutdown signal
        await self._shutdown_event.wait()

        # Cleanup
        self._console.print("\n[dim]Shutting down...[/dim]")

        if ipv6_bridge is not None:
            ipv6_bridge.close()
            await ipv6_bridge.wait_closed()

        if self._watcher:
            await self._watcher.stop()

        if self._heartbeat_task:
            self._heartbeat_task.cancel()
        proxy_task.cancel()
        watcher_task.cancel()

        # Print final savings
        if self._savings.total_requests > 0:
            self._console.print(f"  [bold]Session savings:[/bold] {self._savings.format_display()}")

        # Mark device offline
        await self._mark_device_offline()

        # Stop tunnel
        await self._tunnel_mgr.stop()

        self._console.print("[dim]Disconnected. Your infrastructure is offline.[/dim]")

    async def _run_watcher(self) -> None:
        """Start the discovery watcher for live model/engine detection."""
        try:
            self._watcher = DiscoveryWatcher(
                scanner=self._scanner,
                on_models_changed=self._on_models_changed,
                on_engine_discovered=self._on_engine_discovered,
                on_engine_lost=self._on_engine_lost,
            )
            await self._watcher.start(initial_engines=self._engines)

            # Keep running until shutdown
            while not self._shutdown_event.is_set():
                await asyncio.sleep(1)
        except asyncio.CancelledError:
            pass

    def _on_models_changed(self, engine: DiscoveredEngine, models: list[str]) -> None:
        """Callback: model list changed on an engine."""
        name = f"{engine.engine_type}-{engine.host}:{engine.port}"
        if self._dispatcher:
            self._dispatcher.update_backend_models(name, models)
        self._console.print(f"  [cyan]Models updated[/cyan] on {engine.engine_type}: {len(models)} model(s)")

    def _on_engine_discovered(self, engine: DiscoveredEngine) -> None:
        """Callback: new engine discovered."""
        entry = _engine_to_backend_entry(engine)
        if self._dispatcher:
            self._dispatcher.register_backend(entry)
        self._console.print(
            f"  [green]Engine discovered:[/green] {engine.engine_type} at "
            f"{engine.host}:{engine.port} ({len(engine.models)} models)"
        )

    def _on_engine_lost(self, engine: DiscoveredEngine) -> None:
        """Callback: engine went offline."""
        name = f"{engine.engine_type}-{engine.host}:{engine.port}"
        if self._dispatcher:
            self._dispatcher.unregister_backend(name)
        self._console.print(f"  [yellow]Engine lost:[/yellow] {engine.engine_type} at {engine.host}:{engine.port}")

    async def _heartbeat_loop(self) -> None:
        """Send heartbeat to server every 30 seconds."""
        while not self._shutdown_event.is_set():
            try:
                await asyncio.sleep(_HEARTBEAT_INTERVAL)
                if self._shutdown_event.is_set():
                    break

                if not self._credentials:
                    continue

                # Collect models from watcher if available
                all_models = []
                if self._watcher:
                    all_models = self._watcher.all_models

                payload: dict[str, Any] = {
                    "name": self._device_name,
                    "tunnel_url": self._tunnel_url,
                    "models": all_models,
                    "status": "online",
                }

                # Enhanced heartbeat fields (Phase 3 dashboard)
                import time as _time_mod

                if self._start_time > 0:
                    payload["uptime_seconds"] = int(_time_mod.time() - self._start_time)

                # VRAM usage from hardware profile
                if self._hardware and self._hardware.gpus:
                    payload["vram_usage_mb"] = sum(g.vram_used_mb for g in self._hardware.gpus)

                # Active model (first loaded model from watcher)
                if self._watcher and self._watcher.all_models:
                    payload["active_model"] = self._watcher.all_models[0]

                # Requests served (from dispatcher metrics if available)
                if self._dispatcher:
                    metrics = getattr(self._dispatcher, "request_count", None)
                    if metrics is not None:
                        payload["requests_served"] = metrics

                # Savings in USD
                if self._savings.total_requests > 0:
                    payload["savings"] = self._savings.summary()
                    savings_summary = self._savings.summary()
                    payload["savings_usd"] = savings_summary.get("total_saved_usd", 0.0)

                async with httpx.AsyncClient(timeout=10.0) as client:
                    hb_resp = await client.post(
                        f"{self._server_url}/api/devices/heartbeat",
                        headers={"Authorization": f"Bearer {self._credentials.api_key}"},
                        json=payload,
                    )
                    logger.debug("Heartbeat sent")

                # Detect plan downgrade from heartbeat response
                if hb_resp.status_code == 200:
                    from llmhosts.plans import get_plan_limits

                    hb_data = hb_resp.json()
                    live_plan = hb_data.get("plan", self._credentials.plan)
                    if live_plan != self._credentials.plan:
                        old_plan = self._credentials.plan
                        self._credentials = await self._auth_flow.refresh_plan(self._credentials)
                        limits = get_plan_limits(live_plan)
                        if not limits.tunnel_public and self._tunnel_url:
                            # Plan no longer includes tunnel — stop it
                            self._console.print(
                                f"  [yellow]Plan downgraded[/yellow] {old_plan} → {live_plan}. "
                                "Tunnel stopped. Upgrade at llmhosts.com/pricing"
                            )
                            await self._tunnel_mgr.stop()
                            self._tunnel_url = None
                        else:
                            self._console.print(f"  [dim]Plan updated[/dim] {old_plan} → {live_plan}")
            except asyncio.CancelledError:
                break
            except Exception as exc:
                logger.debug("Heartbeat failed: %s", exc)

    async def _run_proxy(self) -> None:
        """Run the local FastAPI proxy server with all discovered backends registered."""
        try:
            from llmhosts.config import load_config
            from llmhosts.server import LLMHostsServer

            config = load_config()
            config.server.port = self._port
            # Bind to 127.0.0.1 (specific), NOT 0.0.0.0 (wildcard).
            # On Windows, apps like Cursor IDE dynamically grab 127.0.0.1:PORT
            # AFTER a wildcard listener starts — stealing localhost traffic.
            # Specific binding beats wildcard and prevents port-squatting.
            # The relay tunnel connects TO localhost as a client,
            # so it works fine with loopback binding.
            config.server.host = "127.0.0.1"
            config.dashboard.tui = False

            server = LLMHostsServer(config)
            await server.startup()

            if server._app is None:
                logger.error("Server app failed to initialize")
                return

            # Register discovered engines with the dispatcher
            if hasattr(server, "_dispatcher") and server._dispatcher:
                self._dispatcher = server._dispatcher
                for engine in self._engines:
                    entry = _engine_to_backend_entry(engine)
                    self._dispatcher.register_backend(entry)

            import uvicorn

            # On Windows, httptools (C extension HTTP parser) can silently fail
            # to deliver parsed HTTP data to the ASGI app — connections are
            # accepted but no response is ever sent.  Force h11 (pure-Python,
            # reliable on all platforms) to avoid this.
            http_impl = "h11" if platform.system() == "Windows" else "auto"

            uvi_config = uvicorn.Config(
                server._app,
                host=config.server.host,
                port=config.server.port,
                log_level="info",
                http=http_impl,
            )
            uvi_server = uvicorn.Server(uvi_config)
            await uvi_server.serve()
        except asyncio.CancelledError:
            pass
        except Exception as exc:
            logger.error("Proxy server error: %s", exc)

    async def _mark_device_offline(self) -> None:
        """Mark device as offline on the server."""
        if not self._credentials:
            return
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                await client.post(
                    f"{self._server_url}/api/devices/heartbeat",
                    headers={"Authorization": f"Bearer {self._credentials.api_key}"},
                    json={
                        "name": self._device_name,
                        "status": "offline",
                    },
                )
        except Exception:
            pass  # Best-effort
