"""Tests for LLMHosts OpenTelemetry distributed tracing.

Covers TracingConfig validation, init_tracing with various exporters,
TracingMiddleware span creation, no-op tracer fallback, zero-overhead
when disabled, and config integration with LLMHostsConfig.
"""

from __future__ import annotations

import asyncio
import time
from typing import Any
from unittest.mock import AsyncMock, patch

import pytest
from pydantic import ValidationError

from llmhosts.config import LLMHostsConfig, ObservabilityConfig
from llmhosts.config import TracingConfig as ConfigTracingConfig
from llmhosts.observability.tracing import (
    TracingConfig,
    TracingMiddleware,
    _build_url_from_scope,
    _get_version,
    _has_otel,
    _NoOpSpan,
    _NoOpTracer,
    get_tracer,
    init_tracing,
    reset_tracing,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def _reset_tracing_state():
    """Ensure each test starts with a clean tracing state."""
    reset_tracing()
    yield
    reset_tracing()


def _make_scope(
    method: str = "GET",
    path: str = "/health",
    headers: list[tuple[bytes, bytes]] | None = None,
    query_string: bytes = b"",
    server: tuple[str, int] | None = ("localhost", 8000),
    scheme: str = "http",
) -> dict[str, Any]:
    """Build a minimal ASGI HTTP scope dict."""
    return {
        "type": "http",
        "method": method,
        "path": path,
        "headers": headers or [],
        "query_string": query_string,
        "server": server,
        "scheme": scheme,
    }


async def _simple_asgi_app(scope: dict[str, Any], receive: Any, send: Any) -> None:
    """Minimal ASGI app that returns a 200 response."""
    await send({"type": "http.response.start", "status": 200, "headers": []})
    await send({"type": "http.response.body", "body": b"ok"})


async def _error_asgi_app(scope: dict[str, Any], receive: Any, send: Any) -> None:
    """ASGI app that raises an exception."""
    raise ValueError("test error")


async def _slow_asgi_app(scope: dict[str, Any], receive: Any, send: Any) -> None:
    """ASGI app that introduces a small delay."""
    await asyncio.sleep(0.01)
    await send({"type": "http.response.start", "status": 200, "headers": []})
    await send({"type": "http.response.body", "body": b"ok"})


async def _status_app_factory(status: int):
    """Return an ASGI app that responds with the given status code."""

    async def app(scope: dict[str, Any], receive: Any, send: Any) -> None:
        await send({"type": "http.response.start", "status": status, "headers": []})
        await send({"type": "http.response.body", "body": b""})

    return app


# ===========================================================================
# TracingConfig tests
# ===========================================================================


class TestTracingConfig:
    """Tests for the TracingConfig Pydantic model."""

    def test_defaults(self):
        """TracingConfig has sensible defaults."""
        config = TracingConfig()
        assert config.enabled is False
        assert config.exporter == "otlp"
        assert config.endpoint == "http://localhost:4317"
        assert config.service_name == "llmhosts-proxy"
        assert config.sample_rate == 1.0

    def test_custom_values(self):
        """TracingConfig accepts custom values."""
        config = TracingConfig(
            enabled=True,
            exporter="jaeger",
            endpoint="http://jaeger:14268",
            service_name="my-service",
            sample_rate=0.5,
        )
        assert config.enabled is True
        assert config.exporter == "jaeger"
        assert config.endpoint == "http://jaeger:14268"
        assert config.service_name == "my-service"
        assert config.sample_rate == 0.5

    def test_console_exporter(self):
        """Console exporter is a valid option."""
        config = TracingConfig(exporter="console")
        assert config.exporter == "console"

    def test_sample_rate_bounds_zero(self):
        """Sample rate of 0.0 is valid (always off)."""
        config = TracingConfig(sample_rate=0.0)
        assert config.sample_rate == 0.0

    def test_sample_rate_bounds_one(self):
        """Sample rate of 1.0 is valid (always on)."""
        config = TracingConfig(sample_rate=1.0)
        assert config.sample_rate == 1.0

    def test_sample_rate_above_one_rejected(self):
        """Sample rate above 1.0 is rejected by validation."""
        with pytest.raises(ValidationError):
            TracingConfig(sample_rate=1.5)

    def test_sample_rate_below_zero_rejected(self):
        """Sample rate below 0.0 is rejected by validation."""
        with pytest.raises(ValidationError):
            TracingConfig(sample_rate=-0.1)

    def test_model_dump(self):
        """TracingConfig serializes to dict correctly."""
        config = TracingConfig(enabled=True, exporter="console")
        data = config.model_dump()
        assert data["enabled"] is True
        assert data["exporter"] == "console"
        assert "endpoint" in data
        assert "service_name" in data
        assert "sample_rate" in data

    def test_model_validate_from_dict(self):
        """TracingConfig can be constructed from a plain dict."""
        config = TracingConfig.model_validate({"enabled": True, "exporter": "console"})
        assert config.enabled is True
        assert config.exporter == "console"

    def test_model_validate_partial(self):
        """TracingConfig fills defaults for missing fields."""
        config = TracingConfig.model_validate({"enabled": True})
        assert config.exporter == "otlp"
        assert config.sample_rate == 1.0


# ===========================================================================
# NoOp tracer tests
# ===========================================================================


class TestNoOpTracer:
    """Tests for the no-op tracer and span implementations."""

    def test_noop_tracer_start_as_current_span(self):
        """No-op tracer returns a no-op span from start_as_current_span."""
        tracer = _NoOpTracer()
        span = tracer.start_as_current_span("test")
        assert isinstance(span, _NoOpSpan)

    def test_noop_tracer_start_span(self):
        """No-op tracer returns a no-op span from start_span."""
        tracer = _NoOpTracer()
        span = tracer.start_span("test")
        assert isinstance(span, _NoOpSpan)

    def test_noop_span_context_manager(self):
        """No-op span works as a context manager."""
        span = _NoOpSpan()
        with span as s:
            assert s is span

    def test_noop_span_set_attribute(self):
        """No-op span accepts set_attribute without error."""
        span = _NoOpSpan()
        span.set_attribute("key", "value")

    def test_noop_span_set_status(self):
        """No-op span accepts set_status without error."""
        span = _NoOpSpan()
        span.set_status("OK")

    def test_noop_span_record_exception(self):
        """No-op span accepts record_exception without error."""
        span = _NoOpSpan()
        span.record_exception(ValueError("test"))

    def test_noop_span_end(self):
        """No-op span end() is callable without error."""
        span = _NoOpSpan()
        span.end()

    def test_noop_tracer_kwargs_accepted(self):
        """No-op tracer methods accept arbitrary kwargs."""
        tracer = _NoOpTracer()
        span = tracer.start_as_current_span("test", kind=None, attributes={"a": 1})
        assert isinstance(span, _NoOpSpan)
        span2 = tracer.start_span("test", kind=None, attributes={"b": 2})
        assert isinstance(span2, _NoOpSpan)


# ===========================================================================
# get_tracer tests
# ===========================================================================


class TestGetTracer:
    """Tests for the get_tracer() function."""

    def test_returns_noop_before_init(self):
        """get_tracer returns a NoOpTracer when init_tracing has not been called."""
        tracer = get_tracer()
        assert isinstance(tracer, _NoOpTracer)

    def test_returns_noop_when_disabled(self):
        """get_tracer returns NoOpTracer when tracing is disabled."""
        init_tracing(TracingConfig(enabled=False))
        tracer = get_tracer()
        assert isinstance(tracer, _NoOpTracer)

    def test_returns_noop_when_otel_not_installed(self):
        """get_tracer returns NoOpTracer when OTel SDK is not available."""
        with patch("llmhosts.observability.tracing._has_otel", return_value=False):
            init_tracing(TracingConfig(enabled=True))
            tracer = get_tracer()
            assert isinstance(tracer, _NoOpTracer)


# ===========================================================================
# init_tracing tests
# ===========================================================================


class TestInitTracing:
    """Tests for the init_tracing() function."""

    def test_disabled_config(self):
        """When enabled=False, init_tracing sets a NoOpTracer."""
        init_tracing(TracingConfig(enabled=False))
        assert isinstance(get_tracer(), _NoOpTracer)

    def test_otel_not_installed(self):
        """When OTel is not importable, init_tracing falls back to NoOpTracer."""
        with patch("llmhosts.observability.tracing._has_otel", return_value=False):
            init_tracing(TracingConfig(enabled=True, exporter="console"))
            assert isinstance(get_tracer(), _NoOpTracer)

    @pytest.mark.skipif(not _has_otel(), reason="OpenTelemetry SDK not installed")
    def test_console_exporter_initializes(self):
        """Console exporter initializes successfully when OTel is available."""
        config = TracingConfig(enabled=True, exporter="console")
        init_tracing(config)
        tracer = get_tracer()
        assert not isinstance(tracer, _NoOpTracer)

    @pytest.mark.skipif(not _has_otel(), reason="OpenTelemetry SDK not installed")
    def test_console_exporter_creates_spans(self):
        """Console exporter produces real spans."""
        config = TracingConfig(enabled=True, exporter="console")
        init_tracing(config)
        tracer = get_tracer()
        with tracer.start_as_current_span("test-span") as span:
            span.set_attribute("test.key", "test-value")
            # Real span should have set_attribute method that doesn't raise
            assert span is not None

    @pytest.mark.skipif(not _has_otel(), reason="OpenTelemetry SDK not installed")
    def test_sample_rate_zero_uses_always_off(self):
        """Sample rate of 0.0 configures ALWAYS_OFF sampler."""
        config = TracingConfig(enabled=True, exporter="console", sample_rate=0.0)
        init_tracing(config)
        # Should still initialize (not NoOp), just won't sample
        assert not isinstance(get_tracer(), _NoOpTracer)

    @pytest.mark.skipif(not _has_otel(), reason="OpenTelemetry SDK not installed")
    def test_sample_rate_partial(self):
        """Fractional sample rate uses TraceIdRatioBased sampler."""
        config = TracingConfig(enabled=True, exporter="console", sample_rate=0.5)
        init_tracing(config)
        assert not isinstance(get_tracer(), _NoOpTracer)


# ===========================================================================
# reset_tracing tests
# ===========================================================================


class TestResetTracing:
    """Tests for the reset_tracing() function."""

    def test_reset_restores_noop(self):
        """reset_tracing restores the NoOpTracer."""
        init_tracing(TracingConfig(enabled=False))
        reset_tracing()
        assert isinstance(get_tracer(), _NoOpTracer)

    def test_reset_allows_reinit(self):
        """After reset, init_tracing can be called again cleanly."""
        init_tracing(TracingConfig(enabled=False))
        reset_tracing()
        init_tracing(TracingConfig(enabled=False))
        assert isinstance(get_tracer(), _NoOpTracer)


# ===========================================================================
# TracingMiddleware tests
# ===========================================================================


class TestTracingMiddleware:
    """Tests for the TracingMiddleware ASGI middleware."""

    async def test_passthrough_when_disabled(self):
        """When enabled=False, middleware delegates directly to inner app."""
        middleware = TracingMiddleware(_simple_asgi_app, enabled=False)
        scope = _make_scope()
        send = AsyncMock()
        receive = AsyncMock()
        await middleware(scope, receive, send)
        # send should have been called (response went through)
        assert send.call_count == 2  # response.start + response.body

    async def test_passthrough_for_non_http(self):
        """Non-HTTP scope types (websocket, lifespan) pass through."""
        called = False

        async def ws_app(scope: Any, receive: Any, send: Any) -> None:
            nonlocal called
            called = True

        middleware = TracingMiddleware(ws_app, enabled=True)
        scope = {"type": "websocket", "path": "/ws"}
        await middleware(scope, AsyncMock(), AsyncMock())
        assert called is True

    async def test_noop_tracer_passthrough(self):
        """With NoOpTracer, middleware passes through without creating spans."""
        reset_tracing()  # ensures NoOpTracer
        middleware = TracingMiddleware(_simple_asgi_app, enabled=True)
        scope = _make_scope()
        send = AsyncMock()
        await middleware(scope, AsyncMock(), send)
        assert send.call_count == 2

    async def test_captures_status_code(self):
        """Middleware captures the HTTP status code from response."""
        middleware = TracingMiddleware(_simple_asgi_app, enabled=True)
        scope = _make_scope()
        captured_status = None

        original_send = AsyncMock()

        async def capturing_send(message: Any) -> None:
            nonlocal captured_status
            if message.get("type") == "http.response.start":
                captured_status = message.get("status")
            await original_send(message)

        # Even with noop tracer, the app should work
        # We test the send wrapper captures status
        await middleware(scope, AsyncMock(), capturing_send)
        assert captured_status == 200

    async def test_error_propagation(self):
        """Middleware re-raises exceptions from the inner app."""
        middleware = TracingMiddleware(_error_asgi_app, enabled=True)
        scope = _make_scope()
        with pytest.raises(ValueError, match="test error"):
            await middleware(scope, AsyncMock(), AsyncMock())

    async def test_different_status_codes(self):
        """Middleware handles various status codes correctly."""
        for status in [200, 201, 301, 400, 404, 500]:
            app = await _status_app_factory(status)
            middleware = TracingMiddleware(app, enabled=True)
            scope = _make_scope()
            send = AsyncMock()
            await middleware(scope, AsyncMock(), send)
            assert send.call_count == 2
            send.reset_mock()

    async def test_request_id_from_headers(self):
        """Middleware extracts X-Request-ID from headers."""
        middleware = TracingMiddleware(_simple_asgi_app, enabled=True)
        scope = _make_scope(headers=[(b"x-request-id", b"test-req-123")])
        send = AsyncMock()
        await middleware(scope, AsyncMock(), send)
        assert send.call_count == 2

    async def test_zero_overhead_when_disabled(self):
        """When tracing is disabled, middleware adds negligible overhead."""
        middleware_disabled = TracingMiddleware(_simple_asgi_app, enabled=False)
        middleware_enabled = TracingMiddleware(_simple_asgi_app, enabled=True)

        scope = _make_scope()
        iterations = 100

        # Measure disabled middleware
        start = time.perf_counter()
        for _ in range(iterations):
            await middleware_disabled(scope, AsyncMock(), AsyncMock())
        disabled_time = time.perf_counter() - start

        # Measure enabled middleware (with NoOpTracer -- should also be fast)
        start = time.perf_counter()
        for _ in range(iterations):
            await middleware_enabled(scope, AsyncMock(), AsyncMock())
        enabled_time = time.perf_counter() - start

        # Both should be reasonably fast; enabled with NoOp should not be
        # more than 5x slower than disabled (generous bound for CI)
        assert enabled_time < disabled_time * 5 + 0.5  # +0.5s buffer for slow CI

    @pytest.mark.skipif(not _has_otel(), reason="OpenTelemetry SDK not installed")
    async def test_real_spans_created(self):
        """With real OTel SDK, middleware creates spans with correct attributes."""
        from opentelemetry.sdk.trace import TracerProvider
        from opentelemetry.sdk.trace.export import SimpleSpanProcessor
        from opentelemetry.sdk.trace.export.in_memory import InMemorySpanExporter

        exporter = InMemorySpanExporter()
        provider = TracerProvider()
        provider.add_span_processor(SimpleSpanProcessor(exporter))

        with (
            patch("llmhosts.observability.tracing._tracer", provider.get_tracer("test")),
            patch("llmhosts.observability.tracing._NoOpTracer"),
        ):
            # Ensure get_tracer returns the real tracer
            middleware = TracingMiddleware(_simple_asgi_app, enabled=True)
            scope = _make_scope(method="POST", path="/v1/chat/completions")
            send = AsyncMock()
            await middleware(scope, AsyncMock(), send)

        spans = exporter.get_finished_spans()
        assert len(spans) == 1
        span = spans[0]
        assert span.name == "POST /v1/chat/completions"
        attrs = dict(span.attributes or {})
        assert attrs["http.method"] == "POST"
        assert attrs["http.route"] == "/v1/chat/completions"
        assert attrs["http.status_code"] == 200
        assert "http.duration_ms" in attrs


# ===========================================================================
# URL builder tests
# ===========================================================================


class TestBuildUrlFromScope:
    """Tests for the _build_url_from_scope helper."""

    def test_simple_url(self):
        """Build a simple HTTP URL."""
        scope = _make_scope(path="/health", server=("localhost", 8000))
        url = _build_url_from_scope(scope)
        assert url == "http://localhost:8000/health"

    def test_default_http_port_omitted(self):
        """Port 80 on HTTP is omitted."""
        scope = _make_scope(path="/", server=("example.com", 80))
        url = _build_url_from_scope(scope)
        assert url == "http://example.com/"

    def test_default_https_port_omitted(self):
        """Port 443 on HTTPS is omitted."""
        scope = _make_scope(path="/api", server=("example.com", 443), scheme="https")
        url = _build_url_from_scope(scope)
        assert url == "https://example.com/api"

    def test_query_string(self):
        """Query string is appended."""
        scope = _make_scope(path="/search", query_string=b"q=test&page=1")
        url = _build_url_from_scope(scope)
        assert url == "http://localhost:8000/search?q=test&page=1"

    def test_no_server(self):
        """When server is None, only path is returned."""
        scope = _make_scope(path="/test", server=None)
        url = _build_url_from_scope(scope)
        assert url == "/test"


# ===========================================================================
# Config integration tests
# ===========================================================================


class TestConfigIntegration:
    """Tests for TracingConfig integration with the main LLMHostsConfig."""

    def test_observability_has_tracing(self):
        """ObservabilityConfig includes a TracingConfig field."""
        obs = ObservabilityConfig()
        assert hasattr(obs, "tracing")
        assert isinstance(obs.tracing, ConfigTracingConfig)

    def test_tracing_defaults_in_observability(self):
        """TracingConfig defaults are preserved within ObservabilityConfig."""
        obs = ObservabilityConfig()
        assert obs.tracing.enabled is False
        assert obs.tracing.exporter == "otlp"
        assert obs.tracing.endpoint == "http://localhost:4317"

    def test_llmhosts_config_has_tracing(self):
        """LLMHostsConfig.observability.tracing is accessible."""
        config = LLMHostsConfig()
        assert config.observability.tracing.enabled is False

    def test_llmhosts_config_custom_tracing(self):
        """LLMHostsConfig can be constructed with custom tracing values."""
        config = LLMHostsConfig.model_validate(
            {"observability": {"tracing": {"enabled": True, "exporter": "console", "sample_rate": 0.5}}}
        )
        assert config.observability.tracing.enabled is True
        assert config.observability.tracing.exporter == "console"
        assert config.observability.tracing.sample_rate == 0.5

    def test_config_serialization_roundtrip(self):
        """TracingConfig survives serialization/deserialization roundtrip."""
        original = LLMHostsConfig(
            observability=ObservabilityConfig(
                tracing=ConfigTracingConfig(enabled=True, exporter="jaeger", sample_rate=0.3)
            )
        )
        data = original.model_dump()
        restored = LLMHostsConfig.model_validate(data)
        assert restored.observability.tracing.enabled is True
        assert restored.observability.tracing.exporter == "jaeger"
        assert restored.observability.tracing.sample_rate == 0.3


# ===========================================================================
# Helper function tests
# ===========================================================================


class TestHelpers:
    """Tests for module-level helper functions."""

    def test_get_version_returns_string(self):
        """_get_version returns the llmhosts version string."""
        version = _get_version()
        assert isinstance(version, str)
        assert version != "unknown"

    def test_has_otel_returns_bool(self):
        """_has_otel returns a boolean."""
        result = _has_otel()
        assert isinstance(result, bool)
