"""Coverage-focused tests for ``llmhosts/up.py`` — LLMHostUp pipeline.

Core-first pipeline tests covering:
- _engine_to_backend_entry
- _phase_assess: GPU detection, model recommendation, engine scan
- _phase_provision: GGUF download, skip-when-local, download errors
- _phase_load: Core engine load, fallback on missing engine
- _phase_connect: tunnel/auth/register
- _start_tunnel plan-gate/success/failure
- _register_device success/failure/guard
- _run_with_heartbeat signal/watcher/proxy/shutdown
- _run_watcher start/loop
- watcher callbacks with/without dispatcher
- _heartbeat_loop normal/downgrade/tunnel-stop
- _run_proxy config/server/uvicorn
- _mark_device_offline with/without creds
"""

from __future__ import annotations

import asyncio
import io
import platform
from datetime import datetime  # noqa: F401 — needed for Pydantic model_rebuild
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest
from rich.console import Console

from llmhosts.auth.device_flow import AuthenticationError, Credentials
from llmhosts.discovery.models import (
    DiscoveredEngine,
    DiscoveredModel,
    GPUInfo,
    HardwareProfile,
)
from llmhosts.proxy.dispatcher import BackendEntry
from llmhosts.tunnel.models import TunnelProvider, TunnelStatus
from llmhosts.up import _DEFAULT_SERVER_URL, LLMHostUp, _engine_to_backend_entry

# Fix Pydantic forward-reference resolution (datetime in TYPE_CHECKING block)
TunnelStatus.model_rebuild()
DiscoveredEngine.model_rebuild()

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_console() -> Console:
    """Create a real Rich Console that captures output to a StringIO buffer.

    ``no_color=True`` strips ANSI escape sequences so assertions can use
    plain-text substring checks.
    """
    return Console(file=io.StringIO(), no_color=True)


def _creds(**overrides: Any) -> Credentials:
    defaults: dict[str, Any] = {
        "api_key": "sk-test-123456789abcdefgh",
        "user_id": "user_abc",
        "email": "dev@test.com",
        "name": "Test Dev",
        "plan": "pro",
        "server_url": "https://test.llmhosts.com",
    }
    defaults.update(overrides)
    return Credentials(**defaults)


def _engine(
    engine_type: str = "ollama",
    host: str = "127.0.0.1",
    port: int = 11434,
    models: list[str] | None = None,
    confidence: float = 0.95,
) -> DiscoveredEngine:
    model_list = [DiscoveredModel(id=m) for m in (models or ["llama3:8b"])]
    return DiscoveredEngine(
        engine_type=engine_type,
        host=host,
        port=port,
        available=True,
        models=model_list,
        confidence=confidence,
    )


def _tunnel_active() -> TunnelStatus:
    return TunnelStatus(
        provider=TunnelProvider.RELAY,
        active=True,
        url="wss://llmhosts.com/api/relay/connect",
        local_port=4000,
    )


def _tunnel_inactive(error: str = "No tunnel available") -> TunnelStatus:
    return TunnelStatus(
        provider=TunnelProvider.NONE,
        active=False,
        url=None,
        local_port=4000,
        error=error,
    )


def _make_up(console: Console | None = None, **kwargs: Any) -> LLMHostUp:
    c = console or _make_console()
    defaults = {"server_url": "https://test.llmhosts.com", "port": 4000}
    defaults.update(kwargs)
    return LLMHostUp(c, **defaults)


def _mock_httpx_client(*, get_resp: MagicMock | None = None, post_resp: MagicMock | None = None) -> MagicMock:
    """Create a mock httpx.AsyncClient context manager."""
    client = AsyncMock()
    client.__aenter__ = AsyncMock(return_value=client)
    client.__aexit__ = AsyncMock(return_value=False)
    if get_resp:
        client.get = AsyncMock(return_value=get_resp)
    if post_resp:
        client.post = AsyncMock(return_value=post_resp)
    return client


# ===========================================================================
# TestEngineToBackendEntry
# ===========================================================================


@pytest.mark.unit
class TestEngineToBackendEntry:
    """Test _engine_to_backend_entry module-level function."""

    def test_ollama_engine_gets_ollama_type(self) -> None:
        eng = _engine(engine_type="ollama", host="127.0.0.1", port=11434, models=["llama3:8b"])
        entry = _engine_to_backend_entry(eng)
        assert entry.backend_type == "ollama"
        assert entry.name == "ollama-127.0.0.1:11434"
        assert entry.models == ["llama3:8b"]
        assert entry.priority == 0  # localhost
        assert entry.healthy is True

    def test_non_ollama_engine_keeps_own_type(self) -> None:
        eng = _engine(engine_type="vllm", host="192.168.1.50", port=8000, models=["mistral:7b"])
        entry = _engine_to_backend_entry(eng)
        assert entry.backend_type == "vllm"
        assert entry.name == "vllm-192.168.1.50:8000"
        assert entry.priority == 5  # remote host

    def test_localhost_string_gets_priority_zero(self) -> None:
        eng = _engine(host="localhost", port=11434)
        entry = _engine_to_backend_entry(eng)
        assert entry.priority == 0

    def test_remote_host_gets_priority_five(self) -> None:
        eng = _engine(host="10.0.0.5", port=11434)
        entry = _engine_to_backend_entry(eng)
        assert entry.priority == 5

    def test_multiple_models_listed(self) -> None:
        eng = _engine(models=["llama3:8b", "codellama:7b", "phi3:mini"])
        entry = _engine_to_backend_entry(eng)
        assert entry.models == ["llama3:8b", "codellama:7b", "phi3:mini"]


# ===========================================================================
# TestLLMHostUpInit
# ===========================================================================


@pytest.mark.unit
class TestLLMHostUpInit:
    """Test constructor defaults."""

    def test_defaults(self) -> None:
        console = _make_console()
        up = LLMHostUp(console)
        assert up._server_url == _DEFAULT_SERVER_URL
        assert up._port == 11400
        assert up._no_tui is True
        assert up._credentials is None
        assert up._tunnel_url is None
        assert up._engines == []
        assert up._hardware is None
        assert up._dispatcher is None

    def test_custom_params(self) -> None:
        console = _make_console()
        up = LLMHostUp(console, server_url="https://custom.com", port=9999, no_tui=False)
        assert up._server_url == "https://custom.com"
        assert up._port == 9999
        assert up._no_tui is False


# ===========================================================================
# TestPhaseAssess
# ===========================================================================


@pytest.mark.unit
class TestPhaseAssess:
    """Phase 1: Assess — GPU detection, model recommendation, engine scan."""

    async def test_with_gpus_detected_via_hardware_profile(self) -> None:
        """GPU info is printed when HardwareProfile has GPUs."""
        up = _make_up()
        hw = HardwareProfile(
            ram_total_gb=64.0,
            gpus=[GPUInfo(name="RTX 4090", vram_total_mb=24576)],
        )

        with (
            patch(
                "llmhosts.inference.gpu.detect_gpus",
                return_value={"gpus": [], "total_vram_mb": 0, "total_free_vram_mb": 0},
            ),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[_engine()]),
            patch("llmhosts.models.download.list_local_models", return_value=[]),
        ):
            mock_hw_cls.detect = AsyncMock(return_value=hw)
            await up._phase_assess()

        assert up._hardware is hw
        assert len(up._engines) == 1
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "RTX 4090" in output
        assert "24GB" in output
        # Model recommendation should be present
        assert up._recommended_model is not None

    async def test_with_gpus_detected_via_rust(self) -> None:
        """GPU info from Rust detect_gpus is printed."""
        up = _make_up()
        gpu_data = {
            "gpus": [{"name": "RTX 4080", "vram_total_mb": 16384, "vram_free_mb": 14000}],
            "total_vram_mb": 16384,
            "total_free_vram_mb": 14000,
        }

        with (
            patch("llmhosts.inference.gpu.detect_gpus", return_value=gpu_data),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[]),
            patch("llmhosts.models.download.list_local_models", return_value=[]),
        ):
            mock_hw_cls.detect = AsyncMock(side_effect=RuntimeError("not available"))
            await up._phase_assess()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "RTX 4080" in output
        assert "16384MB" in output

    async def test_cpu_only_mode(self) -> None:
        """CPU-only mode when no GPUs detected."""
        up = _make_up()
        hw = HardwareProfile(ram_total_gb=16.0, gpus=[])

        with (
            patch(
                "llmhosts.inference.gpu.detect_gpus",
                return_value={"gpus": [], "total_vram_mb": 0, "total_free_vram_mb": 0},
            ),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[]),
            patch("llmhosts.models.download.list_local_models", return_value=[]),
        ):
            mock_hw_cls.detect = AsyncMock(return_value=hw)
            await up._phase_assess()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "CPU-only" in output or "None detected" in output
        # Should still recommend smallest model
        assert up._recommended_model is not None

    async def test_gpu_detection_failure_continues(self) -> None:
        """Graceful fallback when GPU detection throws."""
        up = _make_up()

        with (
            patch("llmhosts.inference.gpu.detect_gpus", side_effect=RuntimeError("nvidia-smi not found")),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[]),
            patch("llmhosts.models.download.list_local_models", return_value=[]),
        ):
            mock_hw_cls.detect = AsyncMock(side_effect=RuntimeError("no hw"))
            await up._phase_assess()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        # Should still print CPU-only and continue
        assert "None detected" in output
        assert up._hardware is None
        # Model recommendation still works (falls back to smallest)
        assert up._recommended_model is not None

    async def test_local_models_displayed(self) -> None:
        """When local GGUF models exist, they are shown in output."""
        up = _make_up()
        hw = HardwareProfile(ram_total_gb=8.0)

        with (
            patch(
                "llmhosts.inference.gpu.detect_gpus",
                return_value={"gpus": [], "total_vram_mb": 0, "total_free_vram_mb": 0},
            ),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[]),
            patch("llmhosts.models.download.list_local_models", return_value=["qwen2.5:7b", "llama3.1:8b"]),
        ):
            mock_hw_cls.detect = AsyncMock(return_value=hw)
            await up._phase_assess()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "qwen2.5:7b" in output
        assert "llama3.1:8b" in output
        assert up._local_models == ["qwen2.5:7b", "llama3.1:8b"]

    async def test_engines_still_discovered(self) -> None:
        """External engines (Ollama, vLLM, etc.) are still scanned."""
        up = _make_up()
        hw = HardwareProfile(ram_total_gb=32.0)
        engines = [
            _engine(models=["llama3:8b", "codellama:7b"], confidence=0.95),
            _engine(engine_type="vllm", host="10.0.0.5", port=8000, models=["mistral:7b"], confidence=0.7),
        ]

        with (
            patch(
                "llmhosts.inference.gpu.detect_gpus",
                return_value={"gpus": [], "total_vram_mb": 0, "total_free_vram_mb": 0},
            ),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=engines),
            patch("llmhosts.models.download.list_local_models", return_value=[]),
        ):
            mock_hw_cls.detect = AsyncMock(return_value=hw)
            await up._phase_assess()

        assert len(up._engines) == 2
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "2 engine" in output
        assert "3 model" in output

    async def test_recommended_model_for_high_vram(self) -> None:
        """With high VRAM, a larger model is recommended."""
        up = _make_up()
        gpu_data = {
            "gpus": [{"name": "RTX 4090", "vram_total_mb": 24576, "vram_free_mb": 22000}],
            "total_vram_mb": 24576,
            "total_free_vram_mb": 22000,
        }

        with (
            patch("llmhosts.inference.gpu.detect_gpus", return_value=gpu_data),
            patch("llmhosts.discovery.hardware.HardwareDetector") as mock_hw_cls,
            patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[]),
            patch("llmhosts.models.download.list_local_models", return_value=[]),
        ):
            mock_hw_cls.detect = AsyncMock(side_effect=RuntimeError("n/a"))
            await up._phase_assess()

        assert up._recommended_model is not None
        # 22GB free VRAM should get qwen2.5:14b (needs 10GB) or llama3.1:8b (needs 6GB)
        # The largest that fits should be recommended
        assert up._recommended_model["size_gb"] > 1.0


# ===========================================================================
# TestPhaseProvision
# ===========================================================================


@pytest.mark.unit
class TestPhaseProvision:
    """Phase 2: Provision — download GGUF model."""

    async def test_skips_when_local_models_exist(self) -> None:
        """Returns immediately if local models are already downloaded."""
        up = _make_up()

        with patch("llmhosts.models.download.list_local_models", return_value=["qwen2.5:7b"]):
            await up._phase_provision()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Found 1 model(s)" in output
        assert up._local_models == ["qwen2.5:7b"]

    async def test_download_success(self) -> None:
        """Successful download updates local_models list."""
        up = _make_up()
        up._recommended_model = {"name": "qwen2.5:7b", "size_gb": 4.4}

        with (
            patch("llmhosts.models.download.list_local_models", side_effect=[[], ["qwen2.5:7b"]]),
            patch("llmhosts.models.download.download_model", return_value="/home/user/.llmhosts/models/qwen.gguf"),
        ):
            await up._phase_provision()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Downloading qwen2.5:7b" in output
        assert "Model downloaded" in output
        assert up._local_models == ["qwen2.5:7b"]

    async def test_download_failure_degrades_gracefully(self) -> None:
        """Download failure records degraded state but continues."""
        up = _make_up()
        up._recommended_model = {"name": "qwen2.5:7b", "size_gb": 4.4}

        with (
            patch("llmhosts.models.download.list_local_models", return_value=[]),
            patch("llmhosts.models.download.download_model", side_effect=RuntimeError("huggingface_hub not installed")),
        ):
            await up._phase_provision()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Download error" in output
        assert "provision" in up._degraded

    async def test_no_recommendation_skips(self) -> None:
        """Skips download when no model recommendation is available."""
        up = _make_up()
        up._recommended_model = None

        with patch("llmhosts.models.download.list_local_models", return_value=[]):
            await up._phase_provision()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "No model recommendation" in output
        assert "provision" in up._degraded

    async def test_skips_when_engines_have_models(self) -> None:
        """Existing engines with models still trigger download skip if local models exist."""
        up = _make_up()
        up._engines = [_engine(models=["llama3:8b"])]

        with patch("llmhosts.models.download.list_local_models", return_value=["qwen2.5:7b"]):
            await up._phase_provision()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Found 1 model(s)" in output


# ===========================================================================
# TestPhaseLoad
# ===========================================================================


@pytest.mark.unit
class TestPhaseLoad:
    """Phase 3: Load — load model into LLMHosts Core engine."""

    async def test_load_success(self) -> None:
        """Model loads successfully into Core engine."""
        up = _make_up()
        up._local_models = ["qwen2.5:7b"]

        mock_engine = MagicMock()
        mock_engine.load_model.return_value = {"architecture": "llama", "layers": 32}

        with (
            patch("llmhosts.models.registry.model_path", return_value="/home/user/.llmhosts/models/qwen.gguf"),
            patch("llmhosts.inference.core.LLMHostsInferenceEngine", return_value=mock_engine),
        ):
            await up._phase_load()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Loading qwen2.5:7b" in output
        assert "Loaded" in output
        assert "llama" in output
        assert up._core_engine is mock_engine

    async def test_no_local_models_skips(self) -> None:
        """Skips engine load when no local models available."""
        up = _make_up()
        up._local_models = []

        await up._phase_load()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "No local models" in output
        assert up._core_engine is None

    async def test_core_not_available_degrades(self) -> None:
        """Core engine not compiled — falls back gracefully."""
        up = _make_up()
        up._local_models = ["qwen2.5:7b"]

        with (
            patch("llmhosts.models.registry.model_path", return_value="/home/user/.llmhosts/models/qwen.gguf"),
            patch(
                "llmhosts.inference.core.LLMHostsInferenceEngine",
                side_effect=RuntimeError("Rust extension not compiled"),
            ),
        ):
            await up._phase_load()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Core engine not available" in output or "Load error" in output
        assert "load" in up._degraded

    async def test_load_model_error_degrades(self) -> None:
        """Model load error is caught and degraded."""
        up = _make_up()
        up._local_models = ["qwen2.5:7b"]

        mock_engine = MagicMock()
        mock_engine.load_model.side_effect = RuntimeError("LLMHosts Core engine not available")

        with (
            patch("llmhosts.models.registry.model_path", return_value="/home/user/.llmhosts/models/qwen.gguf"),
            patch("llmhosts.inference.core.LLMHostsInferenceEngine", return_value=mock_engine),
        ):
            await up._phase_load()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Core engine not available" in output
        assert "load" in up._degraded

    async def test_model_path_error_degrades(self) -> None:
        """Unknown model name in model_path raises ValueError."""
        up = _make_up()
        up._local_models = ["unknown:model"]

        with patch("llmhosts.models.registry.model_path", side_effect=ValueError("Unknown model: unknown:model")):
            await up._phase_load()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Model path error" in output
        assert "load" in up._degraded


# ===========================================================================
# TestPhaseConnect
# ===========================================================================


@pytest.mark.unit
class TestPhaseConnect:
    """Phase 4: Connect — tunnel + auth + register."""

    async def test_connect_with_tunnel_and_register(self) -> None:
        """Lines 225, 236: register_device is called when both creds and tunnel exist."""
        up = _make_up()
        up._engines = [_engine()]
        creds = _creds()

        with (
            patch.object(up, "_start_tunnel", new_callable=AsyncMock) as mock_tunnel,
            patch.object(up, "_authenticate", new_callable=AsyncMock) as mock_auth,
            patch.object(up, "_register_device", new_callable=AsyncMock) as mock_register,
            patch.object(up, "_print_success") as mock_success,
        ):

            async def _set_tunnel() -> None:
                up._tunnel_url = "wss://llmhosts.com/api/relay/connect"

            async def _set_creds() -> None:
                up._credentials = creds

            mock_tunnel.side_effect = _set_tunnel
            mock_auth.side_effect = _set_creds
            await up._phase_connect()

        mock_register.assert_awaited_once()
        mock_success.assert_called_once()

    async def test_connect_without_tunnel_shows_local_only(self) -> None:
        """Line 218: warns about local-only access when no tunnel."""
        up = _make_up()

        with (
            patch.object(up, "_start_tunnel", new_callable=AsyncMock),
            patch.object(up, "_authenticate", new_callable=AsyncMock),
            patch.object(up, "_print_success"),
        ):
            await up._phase_connect()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "local access only" in output.lower() or "No tunnel" in output

    async def test_connect_no_creds_skips_register(self) -> None:
        """Register is skipped when credentials are None."""
        up = _make_up()
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"

        with (
            patch.object(up, "_start_tunnel", new_callable=AsyncMock),
            patch.object(up, "_authenticate", new_callable=AsyncMock),
            patch.object(up, "_register_device", new_callable=AsyncMock) as mock_register,
            patch.object(up, "_print_success"),
        ):
            await up._phase_connect()

        mock_register.assert_not_awaited()

    async def test_phase_run_delegates_to_run_with_heartbeat(self) -> None:
        """Line 236: _phase_run just calls _run_with_heartbeat."""
        up = _make_up()
        with patch.object(up, "_run_with_heartbeat", new_callable=AsyncMock) as mock_rwh:
            await up._phase_run()
        mock_rwh.assert_awaited_once()


# ===========================================================================
# TestStartTunnel
# ===========================================================================


@pytest.mark.unit
class TestStartTunnel:
    """Tunnel start with plan-gating, success, and failure paths."""

    async def test_free_plan_skips_tunnel(self) -> None:
        """Lines 252-256: free plan does not include tunnel_public -> skipped."""
        up = _make_up()
        up._credentials = _creds(plan="free")

        await up._start_tunnel()

        assert up._tunnel_url is None
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Pro or higher" in output or "Upgrade" in output

    async def test_tunnel_success(self) -> None:
        """Lines 268-271: successful tunnel stores URL and prints provider."""
        up = _make_up()
        up._credentials = _creds(plan="pro")

        with patch.object(up._tunnel_mgr, "start", new_callable=AsyncMock, return_value=_tunnel_active()):
            await up._start_tunnel()

        assert up._tunnel_url == "wss://llmhosts.com/api/relay/connect"
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Tunnel active" in output

    async def test_tunnel_failure(self) -> None:
        """Lines 272-274: failed tunnel prints error message."""
        up = _make_up()
        up._credentials = _creds(plan="pro")

        with patch.object(
            up._tunnel_mgr,
            "start",
            new_callable=AsyncMock,
            return_value=_tunnel_inactive("relay not available"),
        ):
            await up._start_tunnel()

        assert up._tunnel_url is None
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "relay not available" in output

    async def test_no_credentials_starts_tunnel_anyway(self) -> None:
        """Lines 250+258: when no credentials, tunnel start is attempted."""
        up = _make_up()
        up._credentials = None

        with patch.object(up._tunnel_mgr, "start", new_callable=AsyncMock, return_value=_tunnel_active()):
            await up._start_tunnel()

        assert up._tunnel_url == "wss://llmhosts.com/api/relay/connect"


# ===========================================================================
# TestAuthenticate
# ===========================================================================


@pytest.mark.unit
class TestAuthenticate:
    """Authentication flow — existing creds, fresh auth, failure."""

    async def test_existing_valid_credentials_reused(self) -> None:
        """Lines 290-297: valid stored credentials skip fresh auth."""
        up = _make_up()
        creds = _creds()
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_client = _mock_httpx_client(get_resp=mock_resp)

        with (
            patch.object(up._auth_flow, "load_credentials", new_callable=AsyncMock, return_value=creds),
            patch.object(up._auth_flow, "refresh_plan", new_callable=AsyncMock, return_value=creds),
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
        ):
            await up._authenticate()

        assert up._credentials is creds

    async def test_existing_credentials_invalid_triggers_fresh_auth(self) -> None:
        """Lines 298-299: stored creds that fail validation trigger fresh auth."""
        up = _make_up()
        old_creds = _creds()
        new_creds = _creds(email="new@test.com")

        # First: load returns old creds, validation raises
        mock_resp = MagicMock()
        mock_resp.status_code = 401
        mock_client = _mock_httpx_client(get_resp=mock_resp)

        with (
            patch.object(up._auth_flow, "load_credentials", new_callable=AsyncMock, return_value=old_creds),
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch.object(up._auth_flow, "authenticate", new_callable=AsyncMock, return_value=new_creds),
        ):
            await up._authenticate()

        assert up._credentials is new_creds

    async def test_existing_credentials_network_error_triggers_fresh_auth(self) -> None:
        """Lines 298-299: network error on validation triggers fresh auth flow."""
        up = _make_up()
        old_creds = _creds()
        new_creds = _creds(email="fresh@test.com")

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.get = AsyncMock(side_effect=httpx.ConnectError("conn refused"))

        with (
            patch.object(up._auth_flow, "load_credentials", new_callable=AsyncMock, return_value=old_creds),
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch.object(up._auth_flow, "authenticate", new_callable=AsyncMock, return_value=new_creds),
        ):
            await up._authenticate()

        assert up._credentials is new_creds

    async def test_fresh_auth_success(self) -> None:
        """Lines 305-311: fresh authenticate() returns credentials."""
        up = _make_up()
        creds = _creds()

        with (
            patch.object(up._auth_flow, "load_credentials", new_callable=AsyncMock, return_value=None),
            patch.object(up._auth_flow, "authenticate", new_callable=AsyncMock, return_value=creds),
        ):
            await up._authenticate()

        assert up._credentials is creds
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Authenticated" in output

    async def test_auth_error_continues_locally(self) -> None:
        """Lines 312-313: AuthenticationError is caught, credentials remain None."""
        up = _make_up()

        with (
            patch.object(up._auth_flow, "load_credentials", new_callable=AsyncMock, return_value=None),
            patch.object(
                up._auth_flow,
                "authenticate",
                new_callable=AsyncMock,
                side_effect=AuthenticationError("timed out"),
            ),
        ):
            await up._authenticate()

        assert up._credentials is None
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "skipped" in output.lower()

    def test_display_auth_code(self) -> None:
        """Lines 317-329: _display_auth_code prints Panel with code and URL."""
        up = _make_up()
        up._display_auth_code("ABC-123", "https://llmhosts.com/device")
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "ABC-123" in output
        assert "llmhosts.com/device" in output
        assert "Waiting for authorization" in output


# ===========================================================================
# TestRegisterDevice
# ===========================================================================


@pytest.mark.unit
class TestRegisterDevice:
    """Device registration via HTTP POST."""

    async def test_register_success(self) -> None:
        """Lines 368-369: HTTP 201 prints 'Device registered'."""
        up = _make_up()
        up._credentials = _creds()
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._engines = [_engine(models=["llama3:8b", "codellama:7b"])]
        up._hardware = None

        mock_resp = MagicMock()
        mock_resp.status_code = 201
        mock_client = _mock_httpx_client(post_resp=mock_resp)

        with patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client):
            await up._register_device()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Device registered" in output

        # Verify payload
        call_kwargs = mock_client.post.call_args
        json_body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
        assert json_body["models"] == ["llama3:8b", "codellama:7b"]
        assert json_body["tunnel_url"] == "wss://llmhosts.com/api/relay/connect"

    async def test_register_non_success_status(self) -> None:
        """Lines 370-371: non-200/201 prints warning with response text."""
        up = _make_up()
        up._credentials = _creds()
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._engines = [_engine()]

        mock_resp = MagicMock()
        mock_resp.status_code = 409
        mock_resp.text = "device already exists"
        mock_client = _mock_httpx_client(post_resp=mock_resp)

        with patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client):
            await up._register_device()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Registration warning" in output
        assert "device already exists" in output

    async def test_register_network_error(self) -> None:
        """Lines 372-373: exception during registration prints failure message."""
        up = _make_up()
        up._credentials = _creds()
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._engines = [_engine()]

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(side_effect=ConnectionError("DNS resolution failed"))

        with patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client):
            await up._register_device()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Registration failed" in output

    async def test_register_guards_no_creds(self) -> None:
        """Line 337-338: returns immediately if no credentials."""
        up = _make_up()
        up._credentials = None
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        await up._register_device()  # should not raise

    async def test_register_guards_no_tunnel(self) -> None:
        """Line 337-338: returns immediately if no tunnel URL."""
        up = _make_up()
        up._credentials = _creds()
        up._tunnel_url = None
        await up._register_device()  # should not raise


# ===========================================================================
# TestFormatHardwareSummary
# ===========================================================================


@pytest.mark.unit
class TestFormatHardwareSummary:
    """_format_hardware_summary — GPU/RAM summary string."""

    def test_with_gpus(self) -> None:
        """Lines 381-384: GPUs are listed with VRAM."""
        up = _make_up()
        up._hardware = HardwareProfile(
            ram_total_gb=64.0,
            gpus=[
                GPUInfo(name="RTX 4090", vram_total_mb=24576),
                GPUInfo(name="RTX 3090", vram_total_mb=24576),
            ],
        )
        summary = up._format_hardware_summary()
        assert "RTX 4090" in summary
        assert "RTX 3090" in summary
        assert "24.0GB" in summary
        assert "64GB RAM" in summary

    def test_without_gpus(self) -> None:
        """Lines 385-386: no GPUs, just RAM."""
        up = _make_up()
        up._hardware = HardwareProfile(ram_total_gb=32.0, gpus=[])
        summary = up._format_hardware_summary()
        assert "32GB RAM" in summary
        assert "RTX" not in summary

    def test_no_hardware_falls_back_to_platform_machine(self) -> None:
        """Lines 377-378: returns platform.machine() when no hardware profile."""
        up = _make_up()
        up._hardware = None
        summary = up._format_hardware_summary()
        assert summary == platform.machine()


# ===========================================================================
# TestWatcherCallbacks
# ===========================================================================


@pytest.mark.unit
class TestWatcherCallbacks:
    """Watcher callbacks: _on_models_changed, _on_engine_discovered, _on_engine_lost."""

    def test_on_models_changed_with_dispatcher(self) -> None:
        """Lines 512-515: updates dispatcher and prints message."""
        up = _make_up()
        up._dispatcher = MagicMock()
        eng = _engine(models=["llama3:8b", "codellama:7b"])

        up._on_models_changed(eng, ["llama3:8b", "codellama:7b", "phi3:mini"])

        up._dispatcher.update_backend_models.assert_called_once_with(
            "ollama-127.0.0.1:11434", ["llama3:8b", "codellama:7b", "phi3:mini"]
        )
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Models updated" in output

    def test_on_models_changed_without_dispatcher(self) -> None:
        """Lines 513: no dispatcher, only console output."""
        up = _make_up()
        up._dispatcher = None
        eng = _engine()
        up._on_models_changed(eng, ["llama3:8b"])
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Models updated" in output

    def test_on_engine_discovered_with_dispatcher(self) -> None:
        """Lines 519-521: registers backend entry with dispatcher."""
        up = _make_up()
        up._dispatcher = MagicMock()
        eng = _engine(engine_type="vllm", host="10.0.0.5", port=8000, models=["mistral:7b"])

        up._on_engine_discovered(eng)

        up._dispatcher.register_backend.assert_called_once()
        entry = up._dispatcher.register_backend.call_args[0][0]
        assert isinstance(entry, BackendEntry)
        assert entry.backend_type == "vllm"
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Engine discovered" in output

    def test_on_engine_discovered_without_dispatcher(self) -> None:
        """Lines 520: no dispatcher, only console output."""
        up = _make_up()
        up._dispatcher = None
        eng = _engine()
        up._on_engine_discovered(eng)
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Engine discovered" in output

    def test_on_engine_lost_with_dispatcher(self) -> None:
        """Lines 529-531: unregisters backend from dispatcher."""
        up = _make_up()
        up._dispatcher = MagicMock()
        eng = _engine(engine_type="ollama", host="127.0.0.1", port=11434)

        up._on_engine_lost(eng)

        up._dispatcher.unregister_backend.assert_called_once_with("ollama-127.0.0.1:11434")
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Engine lost" in output

    def test_on_engine_lost_without_dispatcher(self) -> None:
        """Lines 530: no dispatcher, only console output."""
        up = _make_up()
        up._dispatcher = None
        eng = _engine()
        up._on_engine_lost(eng)
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Engine lost" in output


# ===========================================================================
# TestHeartbeatLoop
# ===========================================================================


@pytest.mark.unit
class TestHeartbeatLoop:
    """_heartbeat_loop — normal heartbeat, plan downgrade, network error."""

    async def test_normal_heartbeat_with_watcher_models(self) -> None:
        """Lines 547-548: heartbeat includes watcher's all_models."""
        up = _make_up()
        up._credentials = _creds()
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"

        mock_watcher = MagicMock()
        mock_watcher.all_models = ["llama3:8b", "codellama:7b"]
        up._watcher = mock_watcher

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"plan": "pro"}
        mock_client = _mock_httpx_client(post_resp=mock_resp)

        call_count = 0

        def _shutdown_after_one() -> bool:
            nonlocal call_count
            call_count += 1
            return call_count > 2

        up._shutdown_event.is_set = _shutdown_after_one  # type: ignore[assignment]

        with (
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await up._heartbeat_loop()

        call_kwargs = mock_client.post.call_args
        json_body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
        assert json_body["models"] == ["llama3:8b", "codellama:7b"]

    async def test_heartbeat_no_credentials_skips(self) -> None:
        """Line 542-543: no credentials -> continue (skip HTTP call)."""
        up = _make_up()
        up._credentials = None

        call_count = 0

        def _shutdown_after_one() -> bool:
            nonlocal call_count
            call_count += 1
            return call_count > 2

        up._shutdown_event.is_set = _shutdown_after_one  # type: ignore[assignment]

        with patch("asyncio.sleep", new_callable=AsyncMock):
            await up._heartbeat_loop()

        # Should complete without error, no HTTP calls made

    async def test_heartbeat_plan_downgrade_stops_tunnel(self) -> None:
        """Lines 575-586: plan changes and new plan lacks tunnel -> tunnel stopped."""
        up = _make_up()
        up._credentials = _creds(plan="pro")
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"plan": "free"}  # downgrade
        mock_client = _mock_httpx_client(post_resp=mock_resp)

        downgraded_creds = _creds(plan="free")

        call_count = 0

        def _shutdown_after_one() -> bool:
            nonlocal call_count
            call_count += 1
            return call_count > 2

        up._shutdown_event.is_set = _shutdown_after_one  # type: ignore[assignment]

        with (
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch("asyncio.sleep", new_callable=AsyncMock),
            patch.object(up._auth_flow, "refresh_plan", new_callable=AsyncMock, return_value=downgraded_creds),
            patch.object(up._tunnel_mgr, "stop", new_callable=AsyncMock) as mock_stop,
        ):
            await up._heartbeat_loop()

        mock_stop.assert_awaited_once()
        assert up._tunnel_url is None
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Plan downgraded" in output

    async def test_heartbeat_plan_change_no_tunnel_impact(self) -> None:
        """Lines 587-588: plan changes but still has tunnel -> just prints update."""
        up = _make_up()
        up._credentials = _creds(plan="pro")
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"plan": "team"}  # upgrade
        mock_client = _mock_httpx_client(post_resp=mock_resp)

        upgraded_creds = _creds(plan="team")

        call_count = 0

        def _shutdown_after_one() -> bool:
            nonlocal call_count
            call_count += 1
            return call_count > 2

        up._shutdown_event.is_set = _shutdown_after_one  # type: ignore[assignment]

        with (
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch("asyncio.sleep", new_callable=AsyncMock),
            patch.object(up._auth_flow, "refresh_plan", new_callable=AsyncMock, return_value=upgraded_creds),
        ):
            await up._heartbeat_loop()

        assert up._tunnel_url == "wss://llmhosts.com/api/relay/connect"  # unchanged
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Plan updated" in output

    async def test_heartbeat_includes_savings_data(self) -> None:
        """Lines 558-559: savings data is included when requests > 0."""
        up = _make_up()
        up._credentials = _creds()
        up._savings = MagicMock()
        up._savings.total_requests = 42
        up._savings.summary.return_value = {"requests": 42, "savings": "$1.23"}

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"plan": "pro"}
        mock_client = _mock_httpx_client(post_resp=mock_resp)

        call_count = 0

        def _shutdown_after_one() -> bool:
            nonlocal call_count
            call_count += 1
            return call_count > 2

        up._shutdown_event.is_set = _shutdown_after_one  # type: ignore[assignment]

        with (
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await up._heartbeat_loop()

        call_kwargs = mock_client.post.call_args
        json_body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
        assert "savings" in json_body

    async def test_heartbeat_network_error_logged(self) -> None:
        """Lines 591-592: network errors are caught and logged (loop continues)."""
        up = _make_up()
        up._credentials = _creds()

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(side_effect=ConnectionError("timeout"))

        call_count = 0

        def _shutdown_after_one() -> bool:
            nonlocal call_count
            call_count += 1
            return call_count > 2

        up._shutdown_event.is_set = _shutdown_after_one  # type: ignore[assignment]

        with (
            patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await up._heartbeat_loop()

        # Should complete without raising — error is handled

    async def test_heartbeat_cancelled_breaks(self) -> None:
        """Lines 589-590: CancelledError breaks the heartbeat loop cleanly."""
        up = _make_up()
        up._credentials = _creds()

        with patch("asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError):
            await up._heartbeat_loop()

        # Should return cleanly

    async def test_heartbeat_shutdown_after_sleep(self) -> None:
        """Lines 539-540: shutdown check after sleep breaks loop."""
        up = _make_up()
        up._credentials = _creds()

        # is_set returns False first (enter loop), then True after sleep
        call_count = 0

        def _shutdown_after_sleep() -> bool:
            nonlocal call_count
            call_count += 1
            # First call (loop check) -> False, second call (after sleep) -> True
            return call_count > 1

        up._shutdown_event.is_set = _shutdown_after_sleep  # type: ignore[assignment]

        with patch("asyncio.sleep", new_callable=AsyncMock):
            await up._heartbeat_loop()

        # Exited cleanly after detecting shutdown post-sleep


# ===========================================================================
# TestRunWatcher
# ===========================================================================


@pytest.mark.unit
class TestRunWatcher:
    """_run_watcher — starts DiscoveryWatcher and loops."""

    async def test_watcher_starts_and_loops(self) -> None:
        """Lines 495-506: creates watcher, starts it, loops until shutdown."""
        up = _make_up()
        up._engines = [_engine()]

        with patch("llmhosts.up.DiscoveryWatcher") as mock_watcher_cls:
            mock_watcher = AsyncMock()
            mock_watcher.start = AsyncMock()
            mock_watcher_cls.return_value = mock_watcher

            # Shutdown after brief run
            async def _run_then_shutdown() -> None:
                await asyncio.sleep(0.05)
                up._shutdown_event.set()

            shutdown_task = asyncio.create_task(_run_then_shutdown())

            await up._run_watcher()
            await shutdown_task

        mock_watcher.start.assert_awaited_once()

    async def test_watcher_cancelled(self) -> None:
        """Lines 507-508: CancelledError is caught silently."""
        up = _make_up()

        with patch("llmhosts.up.DiscoveryWatcher") as mock_watcher_cls:
            mock_watcher = AsyncMock()
            mock_watcher.start = AsyncMock(side_effect=asyncio.CancelledError)
            mock_watcher_cls.return_value = mock_watcher

            await up._run_watcher()  # should not raise


# ===========================================================================
# TestRunProxy
# ===========================================================================


@pytest.mark.unit
class TestRunProxy:
    """_run_proxy — config load, server startup, uvicorn serve."""

    async def test_run_proxy_success(self) -> None:
        """Lines 596-628: loads config, starts server, registers engines, runs uvicorn."""
        up = _make_up()
        up._engines = [_engine(models=["llama3:8b"])]

        mock_config = MagicMock()
        mock_config.server = MagicMock()
        mock_config.dashboard = MagicMock()

        mock_server = MagicMock()
        mock_server.startup = AsyncMock()
        mock_server._app = MagicMock()
        mock_server._dispatcher = MagicMock()

        mock_uvi_server = MagicMock()
        mock_uvi_server.serve = AsyncMock()

        with (
            patch("llmhosts.config.load_config", return_value=mock_config),
            patch("llmhosts.server.LLMHostsServer", return_value=mock_server),
            patch("uvicorn.Config"),
            patch("uvicorn.Server", return_value=mock_uvi_server),
        ):
            await up._run_proxy()

        mock_server.startup.assert_awaited_once()
        mock_uvi_server.serve.assert_awaited_once()
        assert up._dispatcher is mock_server._dispatcher

    async def test_run_proxy_app_none(self) -> None:
        """Lines 608-610: returns early if server._app is None."""
        up = _make_up()

        mock_config = MagicMock()
        mock_config.server = MagicMock()
        mock_config.dashboard = MagicMock()

        mock_server = MagicMock()
        mock_server.startup = AsyncMock()
        mock_server._app = None

        with (
            patch("llmhosts.config.load_config", return_value=mock_config),
            patch("llmhosts.server.LLMHostsServer", return_value=mock_server),
        ):
            await up._run_proxy()

        # Should return without calling uvicorn

    async def test_run_proxy_cancelled(self) -> None:
        """Lines 629-630: CancelledError is caught silently."""
        up = _make_up()

        with patch("llmhosts.config.load_config", side_effect=asyncio.CancelledError):
            await up._run_proxy()  # should not raise

    async def test_run_proxy_general_error(self) -> None:
        """Lines 631-632: general exceptions are logged."""
        up = _make_up()

        with patch("llmhosts.config.load_config", side_effect=RuntimeError("bad config")):
            await up._run_proxy()  # should not raise


# ===========================================================================
# TestRunWithHeartbeat
# ===========================================================================


@pytest.mark.unit
class TestRunWithHeartbeat:
    """_run_with_heartbeat — signal handling, tasks, shutdown, savings display."""

    async def test_full_run_with_heartbeat_lifecycle(self) -> None:
        """Lines 445-491: sets up signals, starts tasks, waits for shutdown, cleans up."""
        up = _make_up()
        up._credentials = _creds()
        up._savings = MagicMock()
        up._savings.total_requests = 10
        up._savings.format_display.return_value = "Saved $5.00"

        with (
            patch.object(up, "_heartbeat_loop", new_callable=AsyncMock),
            patch.object(up, "_run_proxy", new_callable=AsyncMock),
            patch.object(up, "_run_watcher", new_callable=AsyncMock),
            patch.object(up, "_mark_device_offline", new_callable=AsyncMock) as mock_offline,
            patch.object(up._tunnel_mgr, "stop", new_callable=AsyncMock) as mock_tunnel_stop,
        ):
            # Signal shutdown immediately
            async def _immediate_shutdown() -> None:
                await asyncio.sleep(0.01)
                up._shutdown_event.set()

            shutdown_task = asyncio.create_task(_immediate_shutdown())

            await up._run_with_heartbeat()
            await shutdown_task

        mock_offline.assert_awaited_once()
        mock_tunnel_stop.assert_awaited_once()
        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Shutting down" in output
        assert "Saved $5.00" in output
        assert "Disconnected" in output

    async def test_run_with_heartbeat_no_savings(self) -> None:
        """Lines 482: savings display is skipped when no requests recorded."""
        up = _make_up()

        with (
            patch.object(up, "_heartbeat_loop", new_callable=AsyncMock),
            patch.object(up, "_run_proxy", new_callable=AsyncMock),
            patch.object(up, "_run_watcher", new_callable=AsyncMock),
            patch.object(up, "_mark_device_offline", new_callable=AsyncMock),
            patch.object(up._tunnel_mgr, "stop", new_callable=AsyncMock),
        ):

            async def _immediate_shutdown() -> None:
                await asyncio.sleep(0.01)
                up._shutdown_event.set()

            shutdown_task = asyncio.create_task(_immediate_shutdown())
            await up._run_with_heartbeat()
            await shutdown_task

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Session savings" not in output

    async def test_run_with_heartbeat_stops_watcher(self) -> None:
        """Lines 473-474: if watcher exists, it is stopped during shutdown."""
        up = _make_up()
        mock_watcher = AsyncMock()
        mock_watcher.stop = AsyncMock()
        up._watcher = mock_watcher

        with (
            patch.object(up, "_heartbeat_loop", new_callable=AsyncMock),
            patch.object(up, "_run_proxy", new_callable=AsyncMock),
            patch.object(up, "_run_watcher", new_callable=AsyncMock),
            patch.object(up, "_mark_device_offline", new_callable=AsyncMock),
            patch.object(up._tunnel_mgr, "stop", new_callable=AsyncMock),
        ):

            async def _immediate_shutdown() -> None:
                await asyncio.sleep(0.01)
                up._shutdown_event.set()

            shutdown_task = asyncio.create_task(_immediate_shutdown())
            await up._run_with_heartbeat()
            await shutdown_task

        mock_watcher.stop.assert_awaited_once()


# ===========================================================================
# TestMarkDeviceOffline
# ===========================================================================


@pytest.mark.unit
class TestMarkDeviceOffline:
    """_mark_device_offline — with/without credentials."""

    async def test_no_credentials_returns_early(self) -> None:
        """Line 637: returns immediately if no credentials."""
        up = _make_up()
        up._credentials = None
        await up._mark_device_offline()  # should not raise or make HTTP calls

    async def test_with_credentials_sends_offline(self) -> None:
        """Lines 638-649: sends offline heartbeat."""
        up = _make_up()
        up._credentials = _creds()

        mock_client = _mock_httpx_client(post_resp=MagicMock(status_code=200))

        with patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client):
            await up._mark_device_offline()

        call_kwargs = mock_client.post.call_args
        json_body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
        assert json_body["status"] == "offline"

    async def test_network_error_is_swallowed(self) -> None:
        """Lines 648-649: exceptions are swallowed (best-effort)."""
        up = _make_up()
        up._credentials = _creds()

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(side_effect=ConnectionError("server down"))

        with patch("llmhosts.up.httpx.AsyncClient", return_value=mock_client):
            await up._mark_device_offline()  # should not raise


# ===========================================================================
# TestPrintSuccess
# ===========================================================================


@pytest.mark.unit
class TestPrintSuccess:
    """_print_success — success banner variations."""

    def test_with_credentials_and_tunnel(self) -> None:
        """Lines 408-414: shows relay endpoint and API key."""
        up = _make_up()
        up._credentials = _creds()
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._device_name = "gpu-box"
        up._engines = [_engine(models=["llama3:8b", "codellama:7b"])]

        up._print_success()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "live" in output.lower() or "infrastructure" in output.lower()
        assert "gpu-box" in output
        assert "relay" in output.lower() or "API Endpoint" in output
        assert "sk-test" in output

    def test_without_credentials(self) -> None:
        """Lines 415-416: shows local-only API endpoint."""
        up = _make_up()
        up._credentials = None
        up._tunnel_url = None
        up._device_name = "my-laptop"
        up._engines = [_engine(models=["phi3:mini"])]

        up._print_success()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Local API" in output or "0.0.0.0" in output

    def test_no_engines(self) -> None:
        """Line 405: 'No engines' when engine list is empty."""
        up = _make_up()
        up._credentials = None
        up._engines = []

        up._print_success()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "No engines" in output

    def test_ctrl_c_message_shown(self) -> None:
        """Line 436: 'Press Ctrl+C' hint is always shown."""
        up = _make_up()
        up._engines = []

        up._print_success()

        output = up._console.file.getvalue()  # type: ignore[union-attr]
        assert "Ctrl+C" in output
