# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

from dataclasses import dataclass
from logging import ERROR, WARNING
from unittest import TestCase
from unittest.mock import MagicMock, patch

from opentelemetry.configuration._exceptions import ConfigurationError
from opentelemetry.configuration.instrumentation import (
    configure_instrumentation,
)
from opentelemetry.configuration.models import ExperimentalInstrumentation

_LOAD_EP = "opentelemetry.configuration.instrumentation.load_entry_point"


def _make_instrumentor_class(instance, configuration=None):
    """Return a class mock whose constructor returns ``instance``."""
    cls = MagicMock(return_value=instance)
    cls.configuration = configuration
    instance.is_instrumented_by_opentelemetry = False
    return cls


class TestConfigureInstrumentation(TestCase):
    # pylint: disable=no-self-use
    def test_none_config_is_noop(self):
        configure_instrumentation(None)

    def test_none_python_is_noop(self):
        configure_instrumentation(ExperimentalInstrumentation())

    @patch(_LOAD_EP, side_effect=ConfigurationError("not found"))
    def test_unknown_instrumentor_logs_warning(self, _mock_load):
        with self.assertLogs(
            "opentelemetry.configuration.instrumentation",
            level=WARNING,
        ) as cm:
            configure_instrumentation(
                ExperimentalInstrumentation(python={"unknown_lib": {}})
            )
        self.assertTrue(
            any("unknown_lib" in msg for msg in cm.output),
            f"Expected warning mentioning 'unknown_lib', got: {cm.output}",
        )

    @patch(_LOAD_EP)
    def test_instruments_listed_library_with_no_opts(self, mock_load):
        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(instrumentor)

        configure_instrumentation(
            ExperimentalInstrumentation(python={"requests": {}})
        )

        mock_load.assert_called_once_with(
            "opentelemetry_instrumentor", "requests"
        )
        instrumentor.instrument.assert_called_once_with()

    @patch(_LOAD_EP)
    def test_forwards_kwargs_to_instrumentor_without_configuration(
        self, mock_load
    ):
        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(instrumentor)

        configure_instrumentation(
            ExperimentalInstrumentation(
                python={"flask": {"excluded_urls": "/healthz", "foo": "bar"}}
            )
        )

        instrumentor.instrument.assert_called_once_with(
            excluded_urls="/healthz", foo="bar"
        )

    @patch(_LOAD_EP)
    def test_enabled_false_skips_instrumentation(self, mock_load):
        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(instrumentor)

        configure_instrumentation(
            ExperimentalInstrumentation(
                python={"requests": {"enabled": False}}
            )
        )

        mock_load.assert_not_called()
        instrumentor.instrument.assert_not_called()

    @patch(_LOAD_EP)
    def test_enabled_key_not_forwarded_to_instrumentor(self, mock_load):
        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(instrumentor)

        configure_instrumentation(
            ExperimentalInstrumentation(
                python={"flask": {"enabled": True, "excluded_urls": "/ok"}}
            )
        )

        instrumentor.instrument.assert_called_once_with(excluded_urls="/ok")

    @patch(_LOAD_EP)
    def test_multiple_instrumentors_all_called(self, mock_load):
        flask_inst = MagicMock()
        requests_inst = MagicMock()

        def _side_effect(_group, name):
            return _make_instrumentor_class(
                flask_inst if name == "flask" else requests_inst
            )

        mock_load.side_effect = _side_effect

        configure_instrumentation(
            ExperimentalInstrumentation(
                python={"flask": {}, "requests": {"foo": "bar"}}
            )
        )

        flask_inst.instrument.assert_called_once_with()
        requests_inst.instrument.assert_called_once_with(foo="bar")

    @patch(_LOAD_EP)
    def test_instrumentor_exception_does_not_stop_others(self, mock_load):
        broken_inst = MagicMock()
        broken_inst.instrument.side_effect = RuntimeError("boom")
        ok_inst = MagicMock()

        call_count = 0

        def _side_effect(_group, _name):
            nonlocal call_count
            call_count += 1
            return _make_instrumentor_class(
                broken_inst if call_count == 1 else ok_inst
            )

        mock_load.side_effect = _side_effect

        with self.assertLogs(
            "opentelemetry.configuration.instrumentation",
            level=ERROR,
        ):
            configure_instrumentation(
                ExperimentalInstrumentation(python={"broken": {}, "ok": {}})
            )

        ok_inst.instrument.assert_called_once_with()

    @patch(_LOAD_EP)
    def test_skips_already_instrumented(self, mock_load):
        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(instrumentor)
        instrumentor.is_instrumented_by_opentelemetry = True

        configure_instrumentation(
            ExperimentalInstrumentation(python={"requests": {}})
        )

        instrumentor.instrument.assert_not_called()

    @patch(_LOAD_EP)
    def test_non_dataclass_configuration_attribute_ignored(self, mock_load):
        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(
            instrumentor, configuration="not-a-dataclass"
        )

        configure_instrumentation(
            ExperimentalInstrumentation(python={"requests": {"foo": "bar"}})
        )

        instrumentor.instrument.assert_called_once_with(foo="bar")

    @patch(_LOAD_EP)
    def test_configuration_coerces_opts(self, mock_load):
        @dataclass
        class RequestsConfig:
            excluded_urls: str | None = None
            capture_headers: bool | None = None

        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(
            instrumentor, configuration=RequestsConfig
        )

        configure_instrumentation(
            ExperimentalInstrumentation(
                python={
                    "requests": {
                        "excluded_urls": "/health",
                        "capture_headers": True,
                    }
                }
            )
        )

        instrumentor.instrument.assert_called_once_with(
            excluded_urls="/health", capture_headers=True
        )

    @patch(_LOAD_EP)
    def test_configuration_none_fields_not_forwarded(self, mock_load):
        @dataclass
        class FlaskConfig:
            excluded_urls: str | None = None
            propagate_headers: bool | None = None

        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(
            instrumentor, configuration=FlaskConfig
        )

        configure_instrumentation(
            ExperimentalInstrumentation(
                python={"flask": {"excluded_urls": "/ok"}}
            )
        )

        # propagate_headers was not set, so it must not appear in the call.
        instrumentor.instrument.assert_called_once_with(excluded_urls="/ok")

    @patch(_LOAD_EP)
    def test_configuration_unknown_field_raises(self, mock_load):
        @dataclass
        class StrictConfig:
            excluded_urls: str | None = None

        instrumentor = MagicMock()
        mock_load.return_value = _make_instrumentor_class(
            instrumentor, configuration=StrictConfig
        )

        with self.assertLogs(
            "opentelemetry.configuration.instrumentation",
            level=ERROR,
        ):
            configure_instrumentation(
                ExperimentalInstrumentation(
                    python={
                        "mylib": {"excluded_urls": "/ok", "typo_field": "bad"}
                    }
                )
            )

        instrumentor.instrument.assert_not_called()
