"""Tests for the Hardware Performance Atlas — telemetry events and provisioner integration."""

from __future__ import annotations

import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.discovery.provisioner import fetch_atlas_recommendations, format_atlas_recommendation
from llmhosts.telemetry.emitter import TelemetryEmitter
from llmhosts.telemetry.events import PerformanceEvent
from llmhosts.telemetry.manager import emit_performance_event

# ---------------------------------------------------------------------------
# PerformanceEvent dataclass
# ---------------------------------------------------------------------------


class TestPerformanceEventDataclass:
    def test_performance_event_dataclass(self) -> None:
        """Creates a PerformanceEvent and verifies all fields are set correctly."""
        now = datetime.datetime.now(datetime.timezone.utc)
        event = PerformanceEvent(
            model_id="llama3.1:8b",
            quantization="Q4_K_M",
            gpu_model="RTX 4060",
            vram_total_mb=8192,
            vram_used_mb=5120,
            tokens_per_second=45.7,
            first_token_latency_ms=312.5,
            context_length_tokens=2048,
            success=True,
            timestamp=now,
        )

        assert event.model_id == "llama3.1:8b"
        assert event.quantization == "Q4_K_M"
        assert event.gpu_model == "RTX 4060"
        assert event.vram_total_mb == 8192
        assert event.vram_used_mb == 5120
        assert event.tokens_per_second == 45.7
        assert event.first_token_latency_ms == 312.5
        assert event.context_length_tokens == 2048
        assert event.success is True
        assert event.timestamp == now

    def test_performance_event_default_timestamp(self) -> None:
        """Timestamp defaults to UTC now when not provided."""
        before = datetime.datetime.now(datetime.timezone.utc)
        event = PerformanceEvent(
            model_id="qwen3:8b",
            quantization="unknown",
            gpu_model="cpu",
            vram_total_mb=0,
            vram_used_mb=0,
            tokens_per_second=8.2,
            first_token_latency_ms=1200.0,
            context_length_tokens=512,
            success=True,
        )
        after = datetime.datetime.now(datetime.timezone.utc)

        assert before <= event.timestamp <= after
        assert event.timestamp.tzinfo is not None

    def test_performance_event_to_dict(self) -> None:
        """to_dict() returns all fields, no extras."""
        event = PerformanceEvent(
            model_id="gemma3:2b",
            quantization="Q8_0",
            gpu_model="RTX 3060",
            vram_total_mb=12288,
            vram_used_mb=4096,
            tokens_per_second=80.0,
            first_token_latency_ms=150.0,
            context_length_tokens=1024,
            success=True,
        )
        d = event.to_dict()

        assert d["model_id"] == "gemma3:2b"
        assert d["quantization"] == "Q8_0"
        assert d["gpu_model"] == "RTX 3060"
        assert d["vram_total_mb"] == 12288
        assert d["tokens_per_second"] == 80.0
        assert d["success"] is True

    def test_performance_event_cpu_zeros_vram(self) -> None:
        """CPU-only events have zero VRAM fields."""
        event = PerformanceEvent(
            model_id="qwen3:1.7b",
            quantization="unknown",
            gpu_model="cpu",
            vram_total_mb=0,
            vram_used_mb=0,
            tokens_per_second=5.0,
            first_token_latency_ms=3000.0,
            context_length_tokens=256,
            success=True,
        )
        assert event.vram_total_mb == 0
        assert event.vram_used_mb == 0
        assert event.gpu_model == "cpu"

    def test_performance_event_failed_inference(self) -> None:
        """success=False is captured correctly."""
        event = PerformanceEvent(
            model_id="llama3.3:70b",
            quantization="Q4_K_M",
            gpu_model="RTX 4090",
            vram_total_mb=24576,
            vram_used_mb=23000,
            tokens_per_second=0.0,
            first_token_latency_ms=0.0,
            context_length_tokens=0,
            success=False,
        )
        assert event.success is False


# ---------------------------------------------------------------------------
# TelemetryEmitter.emit_performance
# ---------------------------------------------------------------------------


class TestEmitPerformanceEvent:
    def test_emit_performance_event(self, tmp_path: Path) -> None:
        """Emits a performance event and verifies it is stored in the DB."""
        db = tmp_path / "perf_test.db"
        emitter = TelemetryEmitter(db_path=db, enabled=True)

        event = PerformanceEvent(
            model_id="llama3.1:8b",
            quantization="Q4_K_M",
            gpu_model="RTX 4060",
            vram_total_mb=8192,
            vram_used_mb=5120,
            tokens_per_second=45.7,
            first_token_latency_ms=312.5,
            context_length_tokens=2048,
            success=True,
        )
        emitter.emit_performance(event)

        # Verify by reading the DB directly
        import sqlite3

        with sqlite3.connect(db) as conn:
            conn.row_factory = sqlite3.Row
            rows = conn.execute("SELECT * FROM performance_events").fetchall()

        assert len(rows) == 1
        row = dict(rows[0])
        assert row["model_id"] == "llama3.1:8b"
        assert row["quantization"] == "Q4_K_M"
        assert row["gpu_model"] == "RTX 4060"
        assert row["vram_total_mb"] == 8192
        assert row["vram_used_mb"] == 5120
        assert abs(row["tokens_per_second"] - 45.7) < 0.01
        assert abs(row["first_token_latency_ms"] - 312.5) < 0.01
        assert row["context_length_tokens"] == 2048
        assert row["success"] == 1  # stored as INTEGER
        assert row["created_at"]  # non-empty ISO timestamp

    def test_emit_performance_disabled(self, tmp_path: Path) -> None:
        """Disabled emitter does not write to DB."""
        db = tmp_path / "disabled.db"
        emitter = TelemetryEmitter(db_path=db, enabled=False)

        event = PerformanceEvent(
            model_id="test:model",
            quantization="unknown",
            gpu_model="cpu",
            vram_total_mb=0,
            vram_used_mb=0,
            tokens_per_second=1.0,
            first_token_latency_ms=100.0,
            context_length_tokens=0,
            success=True,
        )
        emitter.emit_performance(event)
        assert not db.exists()

    def test_emit_performance_never_raises(self) -> None:
        """Performance emit must never raise, even with a bad DB path."""
        emitter = TelemetryEmitter(db_path=Path("/nonexistent/path/perf.db"), enabled=True)
        event = PerformanceEvent(
            model_id="x",
            quantization="unknown",
            gpu_model="cpu",
            vram_total_mb=0,
            vram_used_mb=0,
            tokens_per_second=1.0,
            first_token_latency_ms=1.0,
            context_length_tokens=0,
            success=True,
        )
        # Must not raise
        emitter.emit_performance(event)

    def test_emit_performance_multiple_events(self, tmp_path: Path) -> None:
        """Multiple performance events are all stored correctly."""
        db = tmp_path / "multi.db"
        emitter = TelemetryEmitter(db_path=db, enabled=True)

        for i in range(5):
            event = PerformanceEvent(
                model_id=f"model:{i}",
                quantization="Q4_K_M",
                gpu_model="RTX 4090",
                vram_total_mb=24576,
                vram_used_mb=i * 1000,
                tokens_per_second=float(50 + i),
                first_token_latency_ms=float(100 + i * 10),
                context_length_tokens=512 * i,
                success=True,
            )
            emitter.emit_performance(event)

        import sqlite3

        with sqlite3.connect(db) as conn:
            count = conn.execute("SELECT COUNT(*) FROM performance_events").fetchone()[0]

        assert count == 5


# ---------------------------------------------------------------------------
# Manager convenience wrapper
# ---------------------------------------------------------------------------


class TestManagerEmitPerformance:
    def test_emit_performance_event_via_manager(self, tmp_path: Path) -> None:
        """Manager wrapper stores the event via the singleton emitter."""
        db = tmp_path / "mgr.db"
        from llmhosts.telemetry import manager

        original_emitter = manager._emitter
        try:
            manager._emitter = TelemetryEmitter(db_path=db, enabled=True)
            event = PerformanceEvent(
                model_id="qwen3:8b",
                quantization="Q4_K_M",
                gpu_model="RTX 4060",
                vram_total_mb=8192,
                vram_used_mb=6000,
                tokens_per_second=60.0,
                first_token_latency_ms=200.0,
                context_length_tokens=1024,
                success=True,
            )
            emit_performance_event(event)

            import sqlite3

            with sqlite3.connect(db) as conn:
                count = conn.execute("SELECT COUNT(*) FROM performance_events").fetchone()[0]

            assert count == 1
        finally:
            manager._emitter = original_emitter


# ---------------------------------------------------------------------------
# Provisioner — fetch_atlas_recommendations
# ---------------------------------------------------------------------------


class TestFetchAtlasRecommendations:
    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_network_failure(self) -> None:
        """Returns empty list on network failure."""
        import httpx

        with patch("httpx.AsyncClient") as mock_client_cls:
            mock_client = AsyncMock()
            mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
            mock_client.get.side_effect = httpx.ConnectError("Connection refused")

            result = await fetch_atlas_recommendations("RTX 4060", 8192)

        assert result == []

    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_timeout(self) -> None:
        """Returns empty list on request timeout."""
        import httpx

        with patch("httpx.AsyncClient") as mock_client_cls:
            mock_client = AsyncMock()
            mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
            mock_client.get.side_effect = httpx.TimeoutException("Request timed out")

            result = await fetch_atlas_recommendations("RTX 4090", 24576)

        assert result == []

    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_http_error(self) -> None:
        """Returns empty list on HTTP error responses."""
        import httpx

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

            mock_response = MagicMock()
            mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
                "Server error", request=MagicMock(), response=MagicMock()
            )
            mock_client.get.return_value = mock_response

            result = await fetch_atlas_recommendations("RTX 4060", 8192)

        assert result == []

    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_success(self) -> None:
        """Returns sorted list of dicts on successful response."""

        mock_data = [
            {
                "id": "cuid1",
                "gpuModel": "RTX 4060",
                "vramMb": 8192,
                "modelId": "qwen3:8b",
                "quantization": "Q4_K_M",
                "tpsP50": 45.7,
                "tpsP10": 30.2,
                "successRate": 0.98,
                "measurementCount": 120,
                "firstTokenLatencyMs": 312.5,
                "updatedAt": "2026-02-24T00:00:00Z",
            },
            {
                "id": "cuid2",
                "gpuModel": "RTX 4060",
                "vramMb": 8192,
                "modelId": "gemma3:9b",
                "quantization": "Q4_K_M",
                "tpsP50": 55.0,
                "tpsP10": 40.0,
                "successRate": 0.95,
                "measurementCount": 80,
                "firstTokenLatencyMs": 250.0,
                "updatedAt": "2026-02-24T00:00:00Z",
            },
        ]

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

            mock_response = MagicMock()
            mock_response.raise_for_status = MagicMock()
            mock_response.json.return_value = mock_data
            mock_client.get.return_value = mock_response

            result = await fetch_atlas_recommendations("RTX 4060", 8192)

        assert len(result) == 2
        # Sorted by tps_p50 descending — gemma3:9b (55.0) comes first
        assert result[0]["model_id"] == "gemma3:9b"
        assert result[0]["tps_p50"] == 55.0
        assert result[1]["model_id"] == "qwen3:8b"
        assert result[1]["tps_p50"] == 45.7

    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_empty_response(self) -> None:
        """Returns empty list when API returns empty array."""
        with patch("httpx.AsyncClient") as mock_client_cls:
            mock_client = AsyncMock()
            mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

            mock_response = MagicMock()
            mock_response.raise_for_status = MagicMock()
            mock_response.json.return_value = []
            mock_client.get.return_value = mock_response

            result = await fetch_atlas_recommendations("GTX 1080", 8192)

        assert result == []

    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_invalid_json(self) -> None:
        """Returns empty list when response is not a list."""
        with patch("httpx.AsyncClient") as mock_client_cls:
            mock_client = AsyncMock()
            mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)

            mock_response = MagicMock()
            mock_response.raise_for_status = MagicMock()
            mock_response.json.return_value = {"error": "not a list"}
            mock_client.get.return_value = mock_response

            result = await fetch_atlas_recommendations("RTX 4060", 8192)

        assert result == []

    @pytest.mark.asyncio
    async def test_fetch_atlas_recommendations_skips_malformed_entries(self) -> None:
        """Skips entries missing required fields."""
        mock_data = [
            {"id": "1"},  # no model_id or tps_p50
            {
                "id": "2",
                "modelId": "qwen3:8b",
                "tpsP50": 45.0,
                "successRate": 0.99,
                "measurementCount": 50,
            },
        ]

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

            mock_response = MagicMock()
            mock_response.raise_for_status = MagicMock()
            mock_response.json.return_value = mock_data
            mock_client.get.return_value = mock_response

            result = await fetch_atlas_recommendations("RTX 4060", 8192)

        # Only the valid entry survives
        assert len(result) == 1
        assert result[0]["model_id"] == "qwen3:8b"


# ---------------------------------------------------------------------------
# Provisioner — format_atlas_recommendation
# ---------------------------------------------------------------------------


class TestFormatAtlasRecommendation:
    def test_format_atlas_recommendation(self) -> None:
        """Verifies the format string output."""
        entry = {
            "model_id": "qwen3:8b",
            "tps_p50": 45.7,
            "success_rate": 0.98,
            "measurementCount": 120,
        }
        result = format_atlas_recommendation(entry)

        assert "120" in result
        assert "qwen3:8b" in result
        assert "46" in result  # rounded from 45.7
        assert "tok/s" in result

    def test_format_atlas_recommendation_rounds_tps(self) -> None:
        """Tokens per second is rounded to integer in output."""
        entry = {
            "model_id": "llama3.1:8b",
            "tps_p50": 99.9,
            "success_rate": 1.0,
            "measurementCount": 500,
        }
        result = format_atlas_recommendation(entry)
        assert "100" in result

    def test_format_atlas_recommendation_single_user(self) -> None:
        """Works with a single measurement."""
        entry = {
            "model_id": "gemma3:2b",
            "tps_p50": 120.0,
            "success_rate": 1.0,
            "measurementCount": 1,
        }
        result = format_atlas_recommendation(entry)
        assert "1 users" in result
        assert "gemma3:2b" in result
        assert "120" in result
