"""Integration tests for the ``llmhosts up`` pipeline (LLMHostUp).

Tests three customer scenarios with mocked HTTP and subsystems:
1. Clean Box   — No engines found, provisioning path
2. Ollama User — Engine found with models, auth succeeds
3. Power User  — Multiple engines, full flow succeeds
"""

from __future__ import annotations

from datetime import datetime  # noqa: F401 -- needed for Pydantic model_rebuild
from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.auth.device_flow import AuthenticationError, Credentials
from llmhosts.discovery.models import DiscoveredEngine, DiscoveredModel
from llmhosts.tunnel.models import TunnelProvider, TunnelStatus
from llmhosts.up import LLMHostUp

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

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


def _make_credentials(**overrides: Any) -> Credentials:
    """Build a Credentials instance with sensible defaults."""
    defaults = {
        "api_key": "sk-test-1234567890abcdef",
        "user_id": "user_abc123",
        "email": "test@example.com",
        "name": "Test User",
        "plan": "pro",
        "server_url": "https://llmhosts.com",
    }
    defaults.update(overrides)
    return Credentials(**defaults)


def _make_tunnel_status(
    *, active: bool = True, url: str | None = "wss://llmhosts.com/api/relay/connect"
) -> TunnelStatus:
    """Build a TunnelStatus with sensible defaults."""
    return TunnelStatus(
        provider=TunnelProvider.RELAY if active else TunnelProvider.NONE,
        active=active,
        url=url,
        local_port=4000,
    )


def _make_engine(
    engine_type: str = "ollama",
    host: str = "localhost",
    port: int = 11434,
    models: list[str] | None = None,
) -> DiscoveredEngine:
    """Build a DiscoveredEngine with sensible defaults."""
    model_list = [DiscoveredModel(id=m) for m in (models or [])]
    return DiscoveredEngine(
        engine_type=engine_type,
        host=host,
        port=port,
        available=True,
        models=model_list,
        confidence=1.0,
    )


def _make_console() -> MagicMock:
    """Create a mock Rich console that accepts any print call."""
    console = MagicMock()
    console.print = MagicMock()
    return console


# ---------------------------------------------------------------------------
# Scenario 1: Clean Box — no engines detected
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestCleanBox:
    """No engines running. Scanner returns empty. Provisioning may run."""

    @pytest.fixture()
    def console(self) -> MagicMock:
        return _make_console()

    @pytest.fixture()
    def up(self, console: MagicMock) -> LLMHostUp:
        return LLMHostUp(console, server_url="https://test.llmhosts.com", port=4000)

    async def test_no_engines_detected(self, up: LLMHostUp, console: MagicMock) -> None:
        """When scanner finds nothing, phase_assess reports no engines."""
        with patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[]):
            await up._phase_assess()

        assert up._engines == []
        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "no inference engines" in printed.lower() or "phase 1" in printed.lower()

    async def test_tunnel_failure_allows_local(self, up: LLMHostUp, console: MagicMock) -> None:
        """When tunnel fails, connect phase continues with local-only access."""
        mock_tunnel_status = _make_tunnel_status(active=False, url=None)

        with (
            patch.object(up._tunnel_mgr, "start", new_callable=AsyncMock, return_value=mock_tunnel_status),
            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("no server"),
            ),
        ):
            await up._phase_connect()

        assert up._tunnel_url is None
        assert up._credentials is None
        # Should still print a success banner (local-only mode)
        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "local" in printed.lower() or "tunnel" in printed.lower()

    async def test_auth_failure_continues_locally(self, up: LLMHostUp, console: MagicMock) -> None:
        """When auth fails, connect phase completes with local-only mode."""
        mock_tunnel_status = _make_tunnel_status(active=True)

        with (
            patch.object(up._tunnel_mgr, "start", new_callable=AsyncMock, return_value=mock_tunnel_status),
            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("auth failed"),
            ),
        ):
            await up._phase_connect()

        # Tunnel URL was set, but credentials were not
        assert up._tunnel_url == "wss://llmhosts.com/api/relay/connect"
        assert up._credentials is None


# ---------------------------------------------------------------------------
# Scenario 2: Ollama User — engine running with models
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestOllamaUser:
    """Ollama is running with models. Auth succeeds. Full flow completes."""

    @pytest.fixture()
    def console(self) -> MagicMock:
        return _make_console()

    @pytest.fixture()
    def credentials(self) -> Credentials:
        return _make_credentials()

    @pytest.fixture()
    def up(self, console: MagicMock) -> LLMHostUp:
        return LLMHostUp(console, server_url="https://test.llmhosts.com", port=4000)

    async def test_engine_detected_with_models(self, up: LLMHostUp, console: MagicMock) -> None:
        """When scanner finds Ollama with models, they are reported."""
        engine = _make_engine("ollama", models=["llama3.1:latest", "codellama:13b"])

        with patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[engine]):
            await up._phase_assess()

        assert len(up._engines) == 1
        assert len(up._engines[0].models) == 2
        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "1 engine" in printed.lower() or "2 model" in printed.lower()

    async def test_existing_credentials_reused(self, up: LLMHostUp, credentials: Credentials) -> None:
        """When stored credentials are valid, no new auth flow is started."""
        mock_response = MagicMock()
        mock_response.status_code = 200

        with (
            patch.object(up._auth_flow, "load_credentials", new_callable=AsyncMock, return_value=credentials),
            patch("llmhosts.up.httpx.AsyncClient") as mock_client_cls,
        ):
            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.get = AsyncMock(return_value=mock_response)
            mock_client_cls.return_value = mock_client

            await up._authenticate()

        assert up._credentials is not None
        assert up._credentials.email == "test@example.com"
        assert up._credentials.plan == "pro"

    async def test_device_registration_sends_engines(
        self, up: LLMHostUp, console: MagicMock, credentials: Credentials
    ) -> None:
        """Device registration includes discovered engines and models."""
        up._credentials = credentials
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._engines = [_make_engine("ollama", models=["llama3.1:latest", "codellama:13b"])]
        up._hardware = None  # Will use platform.machine() fallback

        mock_register_response = MagicMock()
        mock_register_response.status_code = 201

        with patch("llmhosts.up.httpx.AsyncClient") as mock_client_cls:
            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.post = AsyncMock(return_value=mock_register_response)
            mock_client_cls.return_value = mock_client

            await up._register_device()

            mock_client.post.assert_awaited_once()
            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.1:latest", "codellama:13b"]
            assert json_body["tunnel_url"] == "wss://llmhosts.com/api/relay/connect"
            assert len(json_body["engines"]) == 1
            assert json_body["engines"][0]["type"] == "ollama"

        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "registered" in printed.lower()


# ---------------------------------------------------------------------------
# Scenario 3: Power User — many models, full flow
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestPowerUser:
    """Many models loaded. Full flow from assess through heartbeat."""

    MODELS: ClassVar[list[str]] = [
        "llama3.1:latest",
        "llama3.1:70b",
        "codellama:34b",
        "mistral:latest",
        "phi3:latest",
        "qwen2.5:7b",
    ]

    @pytest.fixture()
    def console(self) -> MagicMock:
        return _make_console()

    @pytest.fixture()
    def credentials(self) -> Credentials:
        return _make_credentials(plan="team")

    @pytest.fixture()
    def up(self, console: MagicMock) -> LLMHostUp:
        return LLMHostUp(console, server_url="https://test.llmhosts.com", port=4000)

    async def test_many_models_in_table(self, up: LLMHostUp, console: MagicMock) -> None:
        """With multiple models, assess phase displays a table."""
        engine = _make_engine("ollama", models=self.MODELS)

        with patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[engine]):
            await up._phase_assess()

        assert len(up._engines) == 1
        assert len(up._engines[0].models) == 6
        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "6 model" in printed.lower() or "1 engine" in printed.lower()

    async def test_multiple_engines_discovered(self, up: LLMHostUp, console: MagicMock) -> None:
        """Scanner finding multiple engines reports all of them."""
        ollama = _make_engine("ollama", port=11434, models=["llama3.1:latest"])
        vllm = _make_engine("vllm", port=8000, models=["meta-llama/Llama-3.1-8B"])

        with patch.object(up._scanner, "scan", new_callable=AsyncMock, return_value=[ollama, vllm]):
            await up._phase_assess()

        assert len(up._engines) == 2
        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "2 engine" in printed.lower()

    async def test_provisioning_skipped_when_engines_exist(self, up: LLMHostUp, console: MagicMock) -> None:
        """Phase 2 is a no-op when engines were found in Phase 1."""
        up._engines = [_make_engine("ollama", models=["llama3.1:latest"])]

        # provision should return immediately without doing anything
        await up._phase_provision()

        # Console should NOT have any "Provisioning" output
        printed = " ".join(str(call) for call in console.print.call_args_list)
        assert "provisioning" not in printed.lower()

    async def test_full_flow_success(self, up: LLMHostUp, console: MagicMock, credentials: Credentials) -> None:
        """Full run() succeeds: assess → provision → connect → run."""
        with (
            patch.object(up, "_phase_assess", new_callable=AsyncMock) as mock_assess,
            patch.object(up, "_phase_provision", new_callable=AsyncMock) as mock_provision,
            patch.object(up, "_phase_connect", new_callable=AsyncMock) as mock_connect,
            patch.object(up, "_phase_run", new_callable=AsyncMock) as mock_run,
        ):
            await up.run()

        mock_assess.assert_awaited_once()
        mock_provision.assert_awaited_once()
        mock_connect.assert_awaited_once()
        mock_run.assert_awaited_once()

    async def test_heartbeat_sends_correct_payload(self, up: LLMHostUp, credentials: Credentials) -> None:
        """Heartbeat POST includes device name, tunnel URL, models, and status."""
        up._credentials = credentials
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._device_name = "alienware-r16"

        mock_response = MagicMock()
        mock_response.status_code = 200

        with (
            patch("llmhosts.up.httpx.AsyncClient") as mock_client_cls,
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.post = AsyncMock(return_value=mock_response)
            mock_client_cls.return_value = mock_client

            # Run one iteration of heartbeat then stop
            call_count = 0

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

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

            await up._heartbeat_loop()

            mock_client.post.assert_awaited()
            call_kwargs = mock_client.post.call_args
            json_body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
            assert json_body["name"] == "alienware-r16"
            assert json_body["tunnel_url"] == "wss://llmhosts.com/api/relay/connect"
            assert json_body["status"] == "online"

    async def test_mark_device_offline_on_shutdown(self, up: LLMHostUp, credentials: Credentials) -> None:
        """_mark_device_offline sends status=offline to the server."""
        up._credentials = credentials

        mock_response = MagicMock()
        mock_response.status_code = 200

        with patch("llmhosts.up.httpx.AsyncClient") as mock_client_cls:
            mock_client = AsyncMock()
            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client.__aexit__ = AsyncMock(return_value=False)
            mock_client.post = AsyncMock(return_value=mock_response)
            mock_client_cls.return_value = mock_client

            await up._mark_device_offline()

            mock_client.post.assert_awaited_once()
            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_success_banner_content(self, up: LLMHostUp, console: MagicMock, credentials: Credentials) -> None:
        """Success banner includes topology, models, and device name."""
        from rich.panel import Panel

        up._credentials = credentials
        up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
        up._device_name = "alienware-r16"
        up._engines = [_make_engine("ollama", models=self.MODELS)]

        up._print_success()

        # Find the Panel object among the print calls
        panel_content = ""
        for call in console.print.call_args_list:
            args = call.args
            if args and isinstance(args[0], Panel):
                panel_content = str(args[0].renderable)
                break

        assert "live" in panel_content.lower() or "infrastructure" in panel_content.lower()
        assert "alienware-r16" in panel_content
        assert "ollama" in panel_content.lower()
