"""Tests for CLI telemetry module: OpenTelemetry configuration for agent SDK.

Tests cover:
- configure_agent_telemetry() function
- NucleusSpanExporter class
- NucleusMetricsExporter class
- _attr_value_to_otlp() helper function
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock, Mock, patch

import httpx
import pytest
from opentelemetry.sdk.metrics.export import MetricExportResult
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SpanExportResult

from terminaluse.lib.telemetry.otel_config import (
    NucleusMetricsExporter,
    NucleusSpanExporter,
    _attr_value_to_otlp,
    configure_agent_telemetry,
)

# =============================================================================
# configure_agent_telemetry() Tests
# =============================================================================


class TestConfigureAgentTelemetry:
    """Tests for the configure_agent_telemetry() function."""

    @pytest.mark.unit
    def test_returns_false_when_base_url_not_set(self):
        """Should return False when TERMINALUSE_BASE_URL is not set."""
        with patch.dict(os.environ, {}, clear=True):
            # Ensure env vars are not set
            os.environ.pop("TERMINALUSE_BASE_URL", None)
            os.environ.pop("TERMINALUSE_AGENT_API_KEY", None)
            result = configure_agent_telemetry()
        assert result is False

    @pytest.mark.unit
    def test_returns_false_when_api_key_not_set(self):
        """Should return False when TERMINALUSE_AGENT_API_KEY is not set."""
        with patch.dict(os.environ, {"TERMINALUSE_BASE_URL": "https://api.example.com"}, clear=True):
            result = configure_agent_telemetry()
        assert result is False

    @pytest.mark.unit
    def test_returns_false_when_both_env_vars_empty(self):
        """Should return False when both env vars are empty strings."""
        with patch.dict(
            os.environ,
            {"TERMINALUSE_BASE_URL": "", "TERMINALUSE_AGENT_API_KEY": ""},
            clear=True,
        ):
            result = configure_agent_telemetry()
        assert result is False

    @pytest.mark.unit
    def test_returns_true_when_env_vars_set(self):
        """Should return True when both required env vars are set."""
        with patch.dict(
            os.environ,
            {
                "TERMINALUSE_BASE_URL": "https://api.example.com",
                "TERMINALUSE_AGENT_API_KEY": "test-api-key",
            },
            clear=True,
        ):
            # Mock the trace and metrics providers to avoid global state changes
            with patch("terminaluse.lib.telemetry.otel_config.trace.set_tracer_provider"):
                with patch("terminaluse.lib.telemetry.otel_config.metrics.set_meter_provider"):
                    result = configure_agent_telemetry()
        assert result is True

    @pytest.mark.unit
    def test_uses_default_service_name_when_not_set(self):
        """Should use 'unknown-agent' as default service name."""
        with patch.dict(
            os.environ,
            {
                "TERMINALUSE_BASE_URL": "https://api.example.com",
                "TERMINALUSE_AGENT_API_KEY": "test-api-key",
            },
            clear=True,
        ):
            with patch("terminaluse.lib.telemetry.otel_config.trace.set_tracer_provider") as mock_set_tracer:
                with patch("terminaluse.lib.telemetry.otel_config.metrics.set_meter_provider"):
                    with patch("terminaluse.lib.telemetry.otel_config.TracerProvider") as mock_tracer_provider:
                        with patch("terminaluse.lib.telemetry.otel_config.Resource.create") as mock_resource:
                            configure_agent_telemetry()
                            # Check that Resource.create was called with default service name
                            mock_resource.assert_called_once()
                            call_args = mock_resource.call_args[0][0]
                            assert call_args["service.name"] == "unknown-agent"
                            assert call_args["service.version"] == "unknown"

    @pytest.mark.unit
    def test_uses_custom_service_name_when_set(self):
        """Should use custom service name from TERMINALUSE_AGENT_NAME."""
        with patch.dict(
            os.environ,
            {
                "TERMINALUSE_BASE_URL": "https://api.example.com",
                "TERMINALUSE_AGENT_API_KEY": "test-api-key",
                "TERMINALUSE_AGENT_NAME": "my-custom-agent",
                "TERMINALUSE_AGENT_VERSION": "1.2.3",
            },
            clear=True,
        ):
            with patch("terminaluse.lib.telemetry.otel_config.trace.set_tracer_provider"):
                with patch("terminaluse.lib.telemetry.otel_config.metrics.set_meter_provider"):
                    with patch("terminaluse.lib.telemetry.otel_config.TracerProvider"):
                        with patch("terminaluse.lib.telemetry.otel_config.Resource.create") as mock_resource:
                            configure_agent_telemetry()
                            call_args = mock_resource.call_args[0][0]
                            assert call_args["service.name"] == "my-custom-agent"
                            assert call_args["service.version"] == "1.2.3"

    @pytest.mark.unit
    def test_configures_tracer_provider_with_batch_processor(self):
        """Should add BatchSpanProcessor to the tracer provider."""
        with patch.dict(
            os.environ,
            {
                "TERMINALUSE_BASE_URL": "https://api.example.com",
                "TERMINALUSE_AGENT_API_KEY": "test-api-key",
            },
            clear=True,
        ):
            with patch("terminaluse.lib.telemetry.otel_config.trace.set_tracer_provider"):
                with patch("terminaluse.lib.telemetry.otel_config.metrics.set_meter_provider"):
                    with patch("terminaluse.lib.telemetry.otel_config.TracerProvider") as mock_tracer_provider:
                        mock_instance = MagicMock()
                        mock_tracer_provider.return_value = mock_instance
                        configure_agent_telemetry()
                        # Should have added BatchSpanProcessor
                        assert mock_instance.add_span_processor.call_count == 1

    @pytest.mark.unit
    def test_auto_instrument_false_does_not_call_instrument_functions(self):
        """Should NOT call instrumentation functions when auto_instrument=False."""
        with patch.dict(
            os.environ,
            {
                "TERMINALUSE_BASE_URL": "https://api.example.com",
                "TERMINALUSE_AGENT_API_KEY": "test-api-key",
            },
            clear=True,
        ):
            with patch("terminaluse.lib.telemetry.otel_config.trace.set_tracer_provider"):
                with patch("terminaluse.lib.telemetry.otel_config.metrics.set_meter_provider"):
                    with patch("terminaluse.lib.telemetry.otel_config._instrument_httpx") as mock_httpx:
                        with patch("terminaluse.lib.telemetry.otel_config._instrument_fastapi") as mock_fastapi:
                            configure_agent_telemetry(auto_instrument=False)
                            mock_httpx.assert_not_called()
                            mock_fastapi.assert_not_called()

    @pytest.mark.unit
    def test_auto_instrument_true_calls_instrument_functions(self):
        """Should call instrumentation functions when auto_instrument=True."""
        with patch.dict(
            os.environ,
            {
                "TERMINALUSE_BASE_URL": "https://api.example.com",
                "TERMINALUSE_AGENT_API_KEY": "test-api-key",
            },
            clear=True,
        ):
            with patch("terminaluse.lib.telemetry.otel_config.trace.set_tracer_provider"):
                with patch("terminaluse.lib.telemetry.otel_config.metrics.set_meter_provider"):
                    with patch("terminaluse.lib.telemetry.otel_config._instrument_httpx") as mock_httpx:
                        with patch("terminaluse.lib.telemetry.otel_config._instrument_fastapi") as mock_fastapi:
                            configure_agent_telemetry(auto_instrument=True)
                            mock_httpx.assert_called_once()
                            mock_fastapi.assert_called_once()


# =============================================================================
# NucleusSpanExporter Tests
# =============================================================================


class TestNucleusSpanExporter:
    """Tests for the NucleusSpanExporter class."""

    @pytest.mark.unit
    def test_endpoint_construction(self):
        """Should construct endpoint with /traces suffix."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")
        assert exporter.endpoint == "https://api.example.com/traces"

    @pytest.mark.unit
    def test_endpoint_strips_trailing_slash(self):
        """Should strip trailing slash before adding /traces."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com/", api_key="test-key")
        assert exporter.endpoint == "https://api.example.com/traces"

    @pytest.mark.unit
    def test_api_key_stored(self):
        """Should store the API key."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="my-secret-key")
        assert exporter.api_key == "my-secret-key"

    @pytest.mark.unit
    def test_export_empty_spans_returns_success(self):
        """Should return SUCCESS for empty span list."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")
        result = exporter.export([])
        assert result == SpanExportResult.SUCCESS

    @pytest.mark.unit
    def test_export_sends_correct_headers(self):
        """Should send x-agent-api-key header and Content-Type."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-api-key")

        mock_span = self._create_mock_span()

        with patch.object(exporter._client, "post") as mock_post:
            mock_response = Mock()
            mock_response.raise_for_status = Mock()
            mock_post.return_value = mock_response

            exporter.export([mock_span])

            mock_post.assert_called_once()
            call_kwargs = mock_post.call_args[1]
            assert call_kwargs["headers"]["x-agent-api-key"] == "test-api-key"
            assert call_kwargs["headers"]["Content-Type"] == "application/json"

    @pytest.mark.unit
    def test_export_returns_success_on_200(self):
        """Should return SUCCESS on HTTP 200."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_span = self._create_mock_span()

        with patch.object(exporter._client, "post") as mock_post:
            mock_response = Mock()
            mock_response.raise_for_status = Mock()  # No exception = success
            mock_post.return_value = mock_response

            result = exporter.export([mock_span])
            assert result == SpanExportResult.SUCCESS

    @pytest.mark.unit
    def test_export_returns_failure_on_http_error(self):
        """Should return FAILURE on HTTP error status."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_span = self._create_mock_span()

        with patch.object(exporter._client, "post") as mock_post:
            mock_response = Mock()
            mock_response.status_code = 500
            mock_post.return_value = mock_response
            mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
                "Server Error",
                request=Mock(),
                response=mock_response,
            )

            result = exporter.export([mock_span])
            assert result == SpanExportResult.FAILURE

    @pytest.mark.unit
    def test_export_returns_failure_on_connection_error(self):
        """Should return FAILURE on connection errors."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_span = self._create_mock_span()

        with patch.object(exporter._client, "post") as mock_post:
            mock_post.side_effect = httpx.ConnectError("Connection refused")

            result = exporter.export([mock_span])
            assert result == SpanExportResult.FAILURE

    @pytest.mark.unit
    def test_export_returns_failure_on_generic_exception(self):
        """Should return FAILURE on generic exceptions."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_span = self._create_mock_span()

        with patch.object(exporter._client, "post") as mock_post:
            mock_post.side_effect = Exception("Something went wrong")

            result = exporter.export([mock_span])
            assert result == SpanExportResult.FAILURE

    @pytest.mark.unit
    def test_shutdown_closes_client(self):
        """Should close the HTTP client on shutdown."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")

        with patch.object(exporter._client, "close") as mock_close:
            exporter.shutdown()
            mock_close.assert_called_once()

    @pytest.mark.unit
    def test_force_flush_returns_true(self):
        """Should return True for force_flush (no-op)."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")
        assert exporter.force_flush() is True
        assert exporter.force_flush(timeout_millis=5000) is True

    @pytest.mark.unit
    def test_spans_to_otlp_format(self):
        """Should convert spans to OTLP JSON format."""
        exporter = NucleusSpanExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_span = self._create_mock_span(
            name="test-span",
            trace_id=0x12345678901234567890123456789012,
            span_id=0x1234567890123456,
        )

        otlp_data = exporter._spans_to_otlp([mock_span])

        assert "resourceSpans" in otlp_data
        assert len(otlp_data["resourceSpans"]) > 0

        # Check resource span structure
        resource_span = otlp_data["resourceSpans"][0]
        assert "resource" in resource_span
        assert "scopeSpans" in resource_span

    def _create_mock_span(
        self,
        name: str = "test-span",
        trace_id: int = 0x12345678901234567890123456789012,
        span_id: int = 0x1234567890123456,
    ) -> ReadableSpan:
        """Create a mock ReadableSpan for testing."""
        mock_span = Mock(spec=ReadableSpan)
        mock_span.name = name

        # Mock context
        mock_context = Mock()
        mock_context.trace_id = trace_id
        mock_context.span_id = span_id
        mock_span.context = mock_context

        # Mock resource
        mock_resource = Mock()
        mock_resource.attributes = {"service.name": "test-service"}
        mock_span.resource = mock_resource

        # Mock instrumentation scope
        mock_scope = Mock()
        mock_scope.name = "test-scope"
        mock_scope.version = "1.0.0"
        mock_span.instrumentation_scope = mock_scope

        # Mock timing
        mock_span.start_time = 1704067200000000000  # Example nanosecond timestamp
        mock_span.end_time = 1704067201000000000

        # Mock kind
        mock_kind = Mock()
        mock_kind.value = 1
        mock_span.kind = mock_kind

        # Mock status
        mock_status = Mock()
        mock_status.status_code = Mock()
        mock_status.status_code.value = 0
        mock_span.status = mock_status

        # Mock parent (no parent)
        mock_span.parent = None

        # Mock attributes
        mock_span.attributes = {"test.attr": "value"}

        # Mock events (empty)
        mock_span.events = []

        return mock_span


# =============================================================================
# NucleusMetricsExporter Tests
# =============================================================================


class TestNucleusMetricsExporter:
    """Tests for the NucleusMetricsExporter class."""

    @pytest.mark.unit
    def test_endpoint_construction(self):
        """Should construct endpoint with /agent-metrics suffix."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")
        assert exporter.endpoint == "https://api.example.com/agent-metrics"

    @pytest.mark.unit
    def test_endpoint_strips_trailing_slash(self):
        """Should strip trailing slash before adding /agent-metrics."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com/", api_key="test-key")
        assert exporter.endpoint == "https://api.example.com/agent-metrics"

    @pytest.mark.unit
    def test_api_key_stored(self):
        """Should store the API key."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="my-secret-key")
        assert exporter.api_key == "my-secret-key"

    @pytest.mark.unit
    def test_export_empty_metrics_returns_success(self):
        """Should return SUCCESS for empty metrics data."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_metrics_data = Mock()
        mock_metrics_data.resource_metrics = []

        result = exporter.export(mock_metrics_data)
        assert result == MetricExportResult.SUCCESS

    @pytest.mark.unit
    def test_export_sends_correct_headers(self):
        """Should send x-agent-api-key header and Content-Type."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-api-key")

        mock_metrics_data = self._create_mock_metrics_data()

        with patch.object(exporter._client, "post") as mock_post:
            mock_response = Mock()
            mock_response.raise_for_status = Mock()
            mock_post.return_value = mock_response

            exporter.export(mock_metrics_data)

            mock_post.assert_called_once()
            call_kwargs = mock_post.call_args[1]
            assert call_kwargs["headers"]["x-agent-api-key"] == "test-api-key"
            assert call_kwargs["headers"]["Content-Type"] == "application/json"

    @pytest.mark.unit
    def test_export_returns_success_on_200(self):
        """Should return SUCCESS on HTTP 200."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_metrics_data = self._create_mock_metrics_data()

        with patch.object(exporter._client, "post") as mock_post:
            mock_response = Mock()
            mock_response.raise_for_status = Mock()
            mock_post.return_value = mock_response

            result = exporter.export(mock_metrics_data)
            assert result == MetricExportResult.SUCCESS

    @pytest.mark.unit
    def test_export_returns_failure_on_http_error(self):
        """Should return FAILURE on HTTP error status."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_metrics_data = self._create_mock_metrics_data()

        with patch.object(exporter._client, "post") as mock_post:
            mock_response = Mock()
            mock_response.status_code = 500
            mock_post.return_value = mock_response
            mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
                "Server Error",
                request=Mock(),
                response=mock_response,
            )

            result = exporter.export(mock_metrics_data)
            assert result == MetricExportResult.FAILURE

    @pytest.mark.unit
    def test_export_returns_failure_on_connection_error(self):
        """Should return FAILURE on connection errors."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_metrics_data = self._create_mock_metrics_data()

        with patch.object(exporter._client, "post") as mock_post:
            mock_post.side_effect = httpx.ConnectError("Connection refused")

            result = exporter.export(mock_metrics_data)
            assert result == MetricExportResult.FAILURE

    @pytest.mark.unit
    def test_export_returns_failure_on_generic_exception(self):
        """Should return FAILURE on generic exceptions."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")

        mock_metrics_data = self._create_mock_metrics_data()

        with patch.object(exporter._client, "post") as mock_post:
            mock_post.side_effect = Exception("Something went wrong")

            result = exporter.export(mock_metrics_data)
            assert result == MetricExportResult.FAILURE

    @pytest.mark.unit
    def test_shutdown_closes_client(self):
        """Should close the HTTP client on shutdown."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")

        with patch.object(exporter._client, "close") as mock_close:
            exporter.shutdown()
            mock_close.assert_called_once()

    @pytest.mark.unit
    def test_force_flush_returns_true(self):
        """Should return True for force_flush (no-op)."""
        exporter = NucleusMetricsExporter(endpoint="https://api.example.com", api_key="test-key")
        assert exporter.force_flush() is True
        assert exporter.force_flush(timeout_millis=5000) is True

    def _create_mock_metrics_data(self):
        """Create mock MetricsData for testing."""
        mock_metrics_data = Mock()

        # Create a mock resource metric
        mock_resource_metric = Mock()
        mock_resource = Mock()
        mock_resource.attributes = {"service.name": "test-service"}
        mock_resource_metric.resource = mock_resource

        # Create a mock scope metric
        mock_scope_metric = Mock()
        mock_scope = Mock()
        mock_scope.name = "test-scope"
        mock_scope.version = "1.0.0"
        mock_scope_metric.scope = mock_scope
        mock_scope_metric.metrics = []

        mock_resource_metric.scope_metrics = [mock_scope_metric]
        mock_metrics_data.resource_metrics = [mock_resource_metric]

        return mock_metrics_data


# =============================================================================
# _attr_value_to_otlp() Tests
# =============================================================================


class TestAttrValueToOtlp:
    """Tests for the _attr_value_to_otlp() helper function."""

    @pytest.mark.unit
    def test_bool_true_conversion(self):
        """Should convert True to boolValue."""
        result = _attr_value_to_otlp(True)
        assert result == {"boolValue": True}

    @pytest.mark.unit
    def test_bool_false_conversion(self):
        """Should convert False to boolValue."""
        result = _attr_value_to_otlp(False)
        assert result == {"boolValue": False}

    @pytest.mark.unit
    def test_int_conversion(self):
        """Should convert int to intValue as string."""
        result = _attr_value_to_otlp(42)
        assert result == {"intValue": "42"}

    @pytest.mark.unit
    def test_negative_int_conversion(self):
        """Should convert negative int to intValue as string."""
        result = _attr_value_to_otlp(-123)
        assert result == {"intValue": "-123"}

    @pytest.mark.unit
    def test_float_conversion(self):
        """Should convert float to doubleValue."""
        result = _attr_value_to_otlp(3.14)
        assert result == {"doubleValue": 3.14}

    @pytest.mark.unit
    def test_string_conversion(self):
        """Should convert string to stringValue."""
        result = _attr_value_to_otlp("hello")
        assert result == {"stringValue": "hello"}

    @pytest.mark.unit
    def test_bytes_conversion(self):
        """Should convert bytes to bytesValue (decoded as UTF-8)."""
        result = _attr_value_to_otlp(b"hello")
        assert result == {"bytesValue": "hello"}

    @pytest.mark.unit
    def test_bytes_with_invalid_utf8(self):
        """Should handle bytes with invalid UTF-8 by replacing invalid chars."""
        result = _attr_value_to_otlp(b"\xff\xfe")
        assert "bytesValue" in result

    @pytest.mark.unit
    def test_list_conversion(self):
        """Should convert list to arrayValue."""
        result = _attr_value_to_otlp([1, "two", 3.0])
        assert result == {
            "arrayValue": {
                "values": [
                    {"intValue": "1"},
                    {"stringValue": "two"},
                    {"doubleValue": 3.0},
                ]
            }
        }

    @pytest.mark.unit
    def test_tuple_conversion(self):
        """Should convert tuple to arrayValue."""
        result = _attr_value_to_otlp((True, False))
        assert result == {
            "arrayValue": {
                "values": [
                    {"boolValue": True},
                    {"boolValue": False},
                ]
            }
        }

    @pytest.mark.unit
    def test_empty_list_conversion(self):
        """Should convert empty list to empty arrayValue."""
        result = _attr_value_to_otlp([])
        assert result == {"arrayValue": {"values": []}}

    @pytest.mark.unit
    def test_unknown_type_converted_to_string(self):
        """Should convert unknown types to stringValue via str()."""
        result = _attr_value_to_otlp({"key": "value"})
        assert result == {"stringValue": "{'key': 'value'}"}

    @pytest.mark.unit
    def test_none_converted_to_string(self):
        """Should convert None to stringValue."""
        result = _attr_value_to_otlp(None)
        assert result == {"stringValue": "None"}
