import json
import logging
import os
import sys
import types
from unittest.mock import MagicMock, patch

import pytest
from pydantic import ValidationError

def _make_fake_datadog_module():
    """Return a fake 'datadog' top-level module with statsd, api, initialize."""
    mod = types.ModuleType("datadog")
    mod.statsd = MagicMock()
    mod.api = MagicMock()
    mod.initialize = MagicMock()
    return mod

class TestMetricProviderInterface:
    """Testcases for MetricProvider abstract base class"""

    def test_cannot_instantiate_abstract(self):
        from monitoring.interfaces import MetricProvider
        with pytest.raises(TypeError):
            MetricProvider()  # type: ignore[abstract]

    def test_concrete_subclass_works(self):
        from monitoring.interfaces import MetricProvider

        class ConcreteProvider(MetricProvider):
            def send(self, *args, **kwargs):
                return "ok"

        p = ConcreteProvider()
        assert p.send() == "ok"

    def test_abstract_send_body_covered_via_super(self):
        """Call super().send() to execute the abstract body"""
        from monitoring.interfaces import MetricProvider

        class ConcreteProvider(MetricProvider):
            def send(self, *args, **kwargs):
                super().send(*args, **kwargs)  # hits the abstract `pass`
                return "called"

        p = ConcreteProvider()
        assert p.send() == "called"


class TestMonitoringConfig:
    """Testcases for MonitoringConfig"""

    def setup_method(self):
        from monitoring.config import MonitoringConfig
        self.MonitoringConfig = MonitoringConfig

    def test_default_values(self):
        cfg = self.MonitoringConfig()
        assert cfg.provider == "console"
        assert cfg.datadog_api_key is None
        assert cfg.datadog_app_key is None
        assert cfg.datadog_hostname is None
        assert cfg.sumologic_url is None
        assert cfg.logger is None

    def test_custom_provider_datadog(self):
        cfg = self.MonitoringConfig(provider="datadog")
        assert cfg.provider == "datadog"

    def test_custom_provider_sumologic(self):
        cfg = self.MonitoringConfig(provider="sumologic")
        assert cfg.provider == "sumologic"

    def test_invalid_provider_raises(self):
        with pytest.raises(ValidationError):
            self.MonitoringConfig(provider="unknown_provider")

    def test_logger_field_accepted(self):
        logger = logging.getLogger("test.config")
        cfg = self.MonitoringConfig(logger=logger)
        assert cfg.logger is logger

    def test_all_fields_set(self):
        logger = logging.getLogger("test.all")
        cfg = self.MonitoringConfig(
            provider="datadog",
            datadog_api_key="apikey",
            datadog_app_key="appkey",
            datadog_hostname="myhost",
            sumologic_url="http://sumo.example.com",
            logger=logger,
        )
        assert cfg.datadog_api_key == "apikey"
        assert cfg.datadog_app_key == "appkey"
        assert cfg.datadog_hostname == "myhost"
        assert cfg.sumologic_url == "http://sumo.example.com"


class TestMonitoringFacade:
    """Testcases for MonitoringFacade"""

    def setup_method(self):
        from monitoring.client import MonitoringFacade
        from monitoring.config import MonitoringConfig
        self.MonitoringFacade = MonitoringFacade
        self.MonitoringConfig = MonitoringConfig
        # Reset singleton state before each test
        MonitoringFacade._instance = None
        MonitoringFacade._config = None

    def teardown_method(self):
        self.MonitoringFacade._instance = None
        self.MonitoringFacade._config = None

    def test_raises_when_not_configured(self):
        with pytest.raises(ValueError, match="not configured"):
            self.MonitoringFacade.get_instance()

    def test_configure_resets_instance(self):
        fake_instance = MagicMock()
        self.MonitoringFacade._instance = fake_instance
        self.MonitoringFacade.configure(self.MonitoringConfig(provider="console"))
        assert self.MonitoringFacade._instance is None

    def test_returns_console_provider(self):
        self.MonitoringFacade.configure(self.MonitoringConfig(provider="console"))
        from monitoring.providers.console import ConsoleProvider
        instance = self.MonitoringFacade.get_instance()
        assert isinstance(instance, ConsoleProvider)

    def test_returns_sumologic_provider(self):
        self.MonitoringFacade.configure(
            self.MonitoringConfig(provider="sumologic", sumologic_url="http://sumo.example.com")
        )
        from monitoring.providers.sumologic import SumologicProvider
        instance = self.MonitoringFacade.get_instance()
        assert isinstance(instance, SumologicProvider)

    def test_returns_datadog_provider(self):
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            self.MonitoringFacade.configure(
                self.MonitoringConfig(provider="datadog", datadog_api_key="k")
            )
            from monitoring.providers.datadog import DatadogProvider
            instance = self.MonitoringFacade.get_instance()
            assert isinstance(instance, DatadogProvider)

    def test_singleton_behavior(self):
        self.MonitoringFacade.configure(self.MonitoringConfig(provider="console"))
        a = self.MonitoringFacade.get_instance()
        b = self.MonitoringFacade.get_instance()
        assert a is b


class TestConsoleProvider:
    """Testcases for Console provider"""

    def _make_provider(self, logger=None):
        from monitoring.providers.console import ConsoleProvider
        return ConsoleProvider(logger=logger)

    def test_default_logger_used(self):
        from monitoring.providers.console import ConsoleProvider
        with patch("monitoring.providers.console.get_logger") as mock_get_logger:
            mock_get_logger.return_value = MagicMock()
            p = ConsoleProvider()
            mock_get_logger.assert_called_once()

    def test_custom_logger_stored(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        assert p.logger is logger

    # --- metric_type routing ---
    def _send_and_check(self, metric_type, expected_log_fragment, **extra_kwargs):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("my.metric", 5, metric_type=metric_type, **extra_kwargs)
        logger.info.assert_called_once()
        msg = logger.info.call_args[0][0]
        assert expected_log_fragment in msg

    def test_send_count(self):
        self._send_and_check("count", "COUNT")

    def test_send_increment(self):
        self._send_and_check("increment", "INCREMENT")

    def test_send_decrement(self):
        self._send_and_check("decrement", "DECREMENT")

    def test_send_gauge(self):
        self._send_and_check("gauge", "GAUGE")

    def test_send_histogram(self):
        self._send_and_check("histogram", "HISTOGRAM")

    def test_send_rate(self):
        self._send_and_check("rate", "RATE")

    def test_send_distribution(self):
        self._send_and_check("distribution", "DISTRIBUTION")

    def test_send_set(self):
        self._send_and_check("set", "SET")

    def test_send_event(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send(metric_type="event", event_name="deploy", message="v1.0 deployed")
        logger.info.assert_called_once()
        msg = logger.info.call_args[0][0]
        assert "EVENT" in msg

    def test_send_fallback_event_via_kwargs(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send(event_name="alert", message="disk full")
        logger.info.assert_called_once()
        assert "EVENT" in logger.info.call_args[0][0]

    def test_send_fallback_gauge(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("my.metric", 3.14)
        logger.info.assert_called_once()
        assert "GAUGE" in logger.info.call_args[0][0]

    def test_format_tags_empty(self):
        p = self._make_provider(logger=MagicMock())
        assert p._format_tags(None) == ""
        assert p._format_tags({}) == ""

    def test_format_tags_with_values(self):
        p = self._make_provider(logger=MagicMock())
        result = p._format_tags({"env": "test"})
        assert "env:test" in result

    def test_send_with_tags(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("my.metric", 1, metric_type="count", tags={"env": "test"})
        msg = logger.info.call_args[0][0]
        assert "env:test" in msg


class TestDatadogProvider:
    """Testcases for Datadog provider"""

    def _make_provider(self, api_key=None, app_key=None, host_name=None, logger=None):
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            from monitoring.providers.datadog import DatadogProvider
            p = DatadogProvider(
                api_key=api_key, app_key=app_key,
                host_name=host_name, logger=logger or MagicMock()
            )
            p.statsd = fake_dd.statsd
            p.api = fake_dd.api
        return p

    # --- Init tests ---
    def test_init_without_datadog_lib(self):
        """When datadog is not installed, statsd/api must be None and warning logged."""
        logger = MagicMock()
        with patch.dict(sys.modules, {"datadog": None}):
            from monitoring.providers import datadog as dd_module
            import importlib
            with patch.dict(sys.modules, {"datadog": None}):
                # Force fresh import path by patching builtins import
                original = sys.modules.get("monitoring.providers.datadog")
                sys.modules.pop("monitoring.providers.datadog", None)
                try:
                    from monitoring.providers.datadog import DatadogProvider
                    p = DatadogProvider(logger=logger)
                    assert p.statsd is None
                    assert p.api is None
                    logger.warning.assert_called_once()
                finally:
                    if original:
                        sys.modules["monitoring.providers.datadog"] = original

    def test_init_with_api_key_calls_initialize(self):
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            sys.modules.pop("monitoring.providers.datadog", None)
            from monitoring.providers.datadog import DatadogProvider
            DatadogProvider(api_key="KEY", app_key="APP", logger=MagicMock())
            fake_dd.initialize.assert_called_once()
            call_kwargs = fake_dd.initialize.call_args[1]
            assert call_kwargs["api_key"] == "KEY"
            assert call_kwargs["app_key"] == "APP"

    def test_init_with_hostname_only_skips_initialize(self):
        """hostname alone is not enough — api_key is mandatory for initialize()."""
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            sys.modules.pop("monitoring.providers.datadog", None)
            from monitoring.providers.datadog import DatadogProvider
            DatadogProvider(host_name="myhost", logger=MagicMock())
            fake_dd.initialize.assert_not_called()

    def test_init_no_keys_skips_initialize(self):
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            sys.modules.pop("monitoring.providers.datadog", None)
            from monitoring.providers.datadog import DatadogProvider
            DatadogProvider(logger=MagicMock())
            fake_dd.initialize.assert_not_called()

    def test_init_library_missing_with_api_key_skips_initialize(self):
        """
        When the datadog library is NOT installed, initialize() must be skipped
        even if api_key is provided — because initialize is set to None in the
        except branch. This validates the bug fix for the original UnboundLocalError.
        """
        logger = MagicMock()
        with patch.dict(sys.modules, {"datadog": None}):
            sys.modules.pop("monitoring.providers.datadog", None)
            from monitoring.providers.datadog import DatadogProvider
            p = DatadogProvider(api_key="KEY", logger=logger)
            # Library missing → statsd/api are None
            assert p.statsd is None
            assert p.api is None
            # Warning was logged, not an exception
            logger.warning.assert_called_once()

    def test_init_api_key_only_calls_initialize_without_app_key(self):
        """api_key alone (no app_key, no hostname) is enough to trigger initialize()."""
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            sys.modules.pop("monitoring.providers.datadog", None)
            from monitoring.providers.datadog import DatadogProvider
            DatadogProvider(api_key="ONLY_KEY", logger=MagicMock())
            fake_dd.initialize.assert_called_once()
            call_kwargs = fake_dd.initialize.call_args[1]
            assert call_kwargs["api_key"] == "ONLY_KEY"
            assert "app_key" not in call_kwargs   # not present, not passed
            assert "hostname" not in call_kwargs   # not present, not passed

    def test_init_api_key_and_hostname_sets_hostname_from_config_false(self):
        """api_key + hostname → both go into options and hostname_from_config is forced False."""
        fake_dd = _make_fake_datadog_module()
        with patch.dict(sys.modules, {"datadog": fake_dd}):
            sys.modules.pop("monitoring.providers.datadog", None)
            from monitoring.providers.datadog import DatadogProvider
            DatadogProvider(api_key="KEY", host_name="myhost", logger=MagicMock())
            fake_dd.initialize.assert_called_once()
            call_kwargs = fake_dd.initialize.call_args[1]
            assert call_kwargs["api_key"] == "KEY"
            assert call_kwargs["hostname"] == "myhost"
            assert call_kwargs["hostname_from_config"] is False
            assert "app_key" not in call_kwargs   # only what was supplied

    def test_send_returns_early_if_no_statsd(self):
        p = self._make_provider()
        p.statsd = None
        p.send("metric", 1, metric_type="count")  # must not raise

    # --- count ---
    def test_send_count_value_one_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 1, metric_type="count")
        p.statsd.increment.assert_called_once()
        p.statsd.decrement.assert_called_once()

    def test_send_count_value_gt1_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 5, metric_type="count")
        p.statsd.count.assert_called_once_with("m", value=5, tags=[])

    def test_send_count_api_mode(self):
        p = self._make_provider()
        p.send("m", 3, metric_type="count", mode="api")
        p.api.Metric.send.assert_called_once_with(metric="m", points=3, tags=[], type="count")

    # --- increment ---
    def test_send_increment_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 1, metric_type="increment")
        p.statsd.increment.assert_called_once_with("m", value=1, tags=[])

    def test_send_increment_api_warns(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("m", 1, metric_type="increment", mode="api")
        logger.warning.assert_called_once()
        p.statsd.increment.assert_not_called()

    # --- decrement ---
    def test_send_decrement_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 1, metric_type="decrement")
        p.statsd.decrement.assert_called_once_with("m", value=1, tags=[])

    def test_send_decrement_api_warns(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("m", 1, metric_type="decrement", mode="api")
        logger.warning.assert_called_once()
        p.statsd.decrement.assert_not_called()

    # --- gauge ---
    def test_send_gauge_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 9.9, metric_type="gauge")
        p.statsd.gauge.assert_called_once_with("m", value=9.9, tags=[])

    def test_send_gauge_api(self):
        p = self._make_provider()
        p.send("m", 9.9, metric_type="gauge", mode="api")
        p.api.Metric.send.assert_called_once_with(metric="m", points=9.9, tags=[], type="gauge")

    # --- histogram ---
    def test_send_histogram_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 1.5, metric_type="histogram")
        p.statsd.histogram.assert_called_once_with("m", value=1.5, tags=[])

    def test_send_histogram_api_warns(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("m", 1.5, metric_type="histogram", mode="api")
        logger.warning.assert_called_once()
        p.statsd.histogram.assert_not_called()

    def test_send_histogram_lambda_env_uses_distribution(self):
        p = self._make_provider()
        with patch.dict(os.environ, {"AWS_EXECUTION_ENV": "AWS_Lambda_python3.12"}):
            p.send("m", 1.5, metric_type="histogram")
        p.statsd.distribution.assert_called_once()
        p.statsd.histogram.assert_not_called()

    # --- distribution ---
    def test_send_distribution_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 2.0, metric_type="distribution")
        p.statsd.distribution.assert_called_once_with("m", value=2.0, tags=[])

    def test_send_distribution_api_warns(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("m", 2.0, metric_type="distribution", mode="api")
        logger.warning.assert_called_once()

    # --- set ---
    def test_send_set_dogstatsd(self):
        p = self._make_provider()
        p.send("m", 42, metric_type="set")
        p.statsd.set.assert_called_once_with("m", value=42, tags=[])

    def test_send_set_api_warns(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("m", 42, metric_type="set", mode="api")
        logger.warning.assert_called_once()

    # --- rate ---
    def test_send_rate_api(self):
        p = self._make_provider()
        p.send("m", 10, metric_type="rate", mode="api")
        p.api.Metric.send.assert_called_once_with(metric="m", points=10, tags=[], type="rate")

    def test_send_rate_dogstatsd_warns_and_counts(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        p.send("m", 10, metric_type="rate")
        logger.warning.assert_called_once()
        p.statsd.count.assert_called_once_with("m", value=10, tags=[])

    # --- event ---
    def test_send_event_dogstatsd(self):
        p = self._make_provider()
        p.send(metric_type="event", event_name="deploy", message="done")
        p.statsd.event.assert_called_once()

    def test_send_event_api(self):
        p = self._make_provider()
        p.send(metric_type="event", event_name="deploy", message="done", mode="api")
        p.api.Event.create.assert_called_once()

    # --- tags conversion ---
    def test_send_with_tags(self):
        p = self._make_provider()
        p.send("m", 1, metric_type="increment", tags={"env": "test", "region": "us"})
        call_kwargs = p.statsd.increment.call_args[1]
        assert "env:test" in call_kwargs["tags"]
        assert "region:us" in call_kwargs["tags"]

    # --- fallback routing ---
    def test_send_fallback_event_via_kwargs(self):
        p = self._make_provider()
        p.send(event_name="alert", message="disk full")
        p.statsd.event.assert_called_once()

    def test_send_fallback_gauge(self):
        p = self._make_provider()
        p.send("m", 3.14)
        p.statsd.gauge.assert_called_once()


class TestSumologicProvider:
    """Testcases for Sumologic provider"""

    def _make_provider(self, url="http://sumo.example.com", logger=None, timeout=29):
        from monitoring.providers.sumologic import SumologicProvider
        return SumologicProvider(sumologic_url=url, timeout=timeout, logger=logger or MagicMock())

    def test_init_default_logger(self):
        from monitoring.providers.sumologic import SumologicProvider
        with patch("monitoring.providers.sumologic.get_logger") as mock_get_logger:
            mock_get_logger.return_value = MagicMock()
            SumologicProvider()
            mock_get_logger.assert_called_once()

    def test_init_custom_logger(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        assert p.logger is logger

    def test_init_default_timeout(self):
        """Default timeout must be 29 seconds (safely below common 30s gateway limits)."""
        from monitoring.providers.sumologic import SumologicProvider
        p = SumologicProvider()
        assert p.timeout == 29

    def test_init_custom_timeout_stored(self):
        """Custom timeout value is stored on the instance."""
        from monitoring.providers.sumologic import SumologicProvider
        p = SumologicProvider(timeout=10)
        assert p.timeout == 10

    def test_send_no_url_warns_and_returns(self):
        logger = MagicMock()
        from monitoring.providers.sumologic import SumologicProvider
        p = SumologicProvider(sumologic_url=None, logger=logger)
        with patch("httpx.post") as mock_post:
            p.send("metric", 1, metric_type="count")
            mock_post.assert_not_called()
        logger.warning.assert_called_once()

    def test_send_metric_type_event(self):
        p = self._make_provider()
        with patch.object(p, "_log_event") as mock_event:
            p.send(metric_type="event", event_name="deploy", message="done")
            mock_event.assert_called_once_with(event_name="deploy", message="done")

    def test_send_metric_type_other(self):
        p = self._make_provider()
        with patch.object(p, "_metric") as mock_metric:
            p.send("m", 5, metric_type="count")
            mock_metric.assert_called_once_with("m", 5)

    def test_log_event_success(self):
        p = self._make_provider()
        with patch("httpx.post") as mock_post:
            result = p._log_event(event_name="deploy", message="v1", tags={"env": "test"})
            assert result is True
            mock_post.assert_called_once()
            call_kwargs = mock_post.call_args
            payload = call_kwargs[1]["json"]
            assert payload["event"] == "deploy"
            assert payload["message"] == "v1"
            assert payload["env"] == "test"

    def test_log_event_http_failure(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        with patch("httpx.post", side_effect=Exception("connection refused")):
            result = p._log_event(event_name="e", message="m")
            assert result is False
            logger.error.assert_called_once()

    def test_metric_success(self):
        p = self._make_provider()
        with patch("httpx.post") as mock_post:
            result = p._metric("my.metric", 7, tags={"host": "web"})
            assert result is True
            mock_post.assert_called_once()
            payload = mock_post.call_args[1]["json"]
            assert payload["event"] == "my.metric"
            assert payload["value"] == 7
            assert payload["host"] == "web"

    def test_metric_http_failure(self):
        logger = MagicMock()
        p = self._make_provider(logger=logger)
        with patch("httpx.post", side_effect=RuntimeError("timeout")):
            result = p._metric("my.metric", 1)
            assert result is False
            logger.error.assert_called_once()

    def test_send_internal_posts_to_correct_url(self):
        p = self._make_provider(url="http://sumo.example.com/collector")
        with patch("httpx.post") as mock_post:
            p._send({"key": "val"})
            mock_post.assert_called_once_with(
                "http://sumo.example.com/collector", json={"key": "val"}, timeout=29
            )

    def test_send_internal_forwards_custom_timeout(self):
        """The timeout stored on the provider is passed to every httpx.post call."""
        p = self._make_provider(timeout=5)
        with patch("httpx.post") as mock_post:
            p._send({"key": "val"})
            _, call_kwargs = mock_post.call_args
            assert call_kwargs["timeout"] == 5

    def test_log_event_no_tags(self):
        p = self._make_provider()
        with patch("httpx.post") as mock_post:
            result = p._log_event(event_name="e", message="m")
            assert result is True
            payload = mock_post.call_args[1]["json"]
            assert payload["type"] == "info"

    def test_metric_no_tags(self):
        p = self._make_provider()
        with patch("httpx.post") as mock_post:
            result = p._metric("m")
            assert result is True
            payload = mock_post.call_args[1]["json"]
            assert payload["value"] == 1  # default


class TestDecorators:
    """Testcases for monitoring decorators"""

    def setup_method(self):
        # Reset MonitoringFacade between decorator tests
        from monitoring.client import MonitoringFacade
        MonitoringFacade._instance = None
        MonitoringFacade._config = None

    def teardown_method(self):
        from monitoring.client import MonitoringFacade
        MonitoringFacade._instance = None
        MonitoringFacade._config = None

    # ---- trace_function ----
    def test_trace_function_with_ddtrace(self):
        """When ddtrace is importable, tracer.trace is used."""
        fake_span = MagicMock()
        fake_tracer = MagicMock()
        fake_tracer.trace.return_value.__enter__ = MagicMock(return_value=fake_span)
        fake_tracer.trace.return_value.__exit__ = MagicMock(return_value=False)

        fake_ddtrace = types.ModuleType("ddtrace")
        fake_ddtrace.tracer = fake_tracer

        with patch.dict(sys.modules, {"ddtrace": fake_ddtrace}):
            from monitoring import decorators as dec_module
            import importlib
            importlib.reload(dec_module)
            from monitoring.decorators import trace_function

            @trace_function(name="web.request", tags={"env": "test"})
            def my_func():
                return "result"

            result = my_func()
            assert result == "result"
            fake_tracer.trace.assert_called_once()

    def test_trace_function_without_ddtrace(self):
        """When ddtrace raises ImportError, function still runs via nullcontext."""
        with patch.dict(sys.modules, {"ddtrace": None}):
            from monitoring.decorators import trace_function

            @trace_function(name="web.request")
            def my_func(x):
                return x * 2

            assert my_func(3) == 6

    def test_trace_function_capture_args_with_live_span(self):
        fake_span = MagicMock()  # truthy by default
        fake_tracer = MagicMock()
        fake_tracer.trace.return_value.__enter__ = MagicMock(return_value=fake_span)
        fake_tracer.trace.return_value.__exit__ = MagicMock(return_value=False)

        fake_ddtrace = types.ModuleType("ddtrace")
        fake_ddtrace.tracer = fake_tracer

        with patch.dict(sys.modules, {"ddtrace": fake_ddtrace}):
            from monitoring.decorators import trace_function

            @trace_function(name="op", capture_args=True)
            def my_func(a, b=10):
                return a + b

            result = my_func(5, b=20)
            assert result == 25
            # Both kwargs (b) should have been set as arg.* tags
            fake_span.set_tag.assert_called_with("arg.b", "20")

    def test_trace_function_capture_args(self):
        with patch.dict(sys.modules, {"ddtrace": None}):
            from monitoring.decorators import trace_function

            @trace_function(name="op", capture_args=True)
            def my_func(a, b=10):
                return a + b

            assert my_func(5, b=20) == 25

    def test_trace_function_default_resource(self):
        fake_span = MagicMock()
        fake_tracer = MagicMock()
        fake_tracer.trace.return_value.__enter__ = MagicMock(return_value=fake_span)
        fake_tracer.trace.return_value.__exit__ = MagicMock(return_value=False)

        fake_ddtrace = types.ModuleType("ddtrace")
        fake_ddtrace.tracer = fake_tracer

        with patch.dict(sys.modules, {"ddtrace": fake_ddtrace}):
            from monitoring.decorators import trace_function

            @trace_function(name="op")
            def my_named_func():
                return "ok"

            my_named_func()
            _, call_kwargs = fake_tracer.trace.call_args
            assert call_kwargs.get("resource") == "my_named_func"

    def test_trace_function_service_from_env(self):
        with patch.dict(os.environ, {"DD_SERVICE": "my-svc"}):
            with patch.dict(sys.modules, {"ddtrace": None}):
                import importlib
                import monitoring.decorators as dec_module
                importlib.reload(dec_module)
                from monitoring.decorators import trace_function

                @trace_function(name="op")
                def my_func():
                    return True

                assert my_func() is True

    # ---- monitor_execution_time ----
    def test_monitor_execution_time_sends_histogram(self):
        mock_provider = MagicMock()
        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.decorators import monitor_execution_time

            @monitor_execution_time(metric_name="fn.time")
            def my_func():
                return 42

            result = my_func()
            assert result == 42
            mock_provider.send.assert_called_once()
            call_args, call_kwargs = mock_provider.send.call_args
            assert call_args[0] == "fn.time"
            assert call_kwargs["metric_type"] == "histogram"
            assert call_kwargs["tags"]["function"] == "my_func"

    def test_monitor_execution_time_preserves_return_value(self):
        mock_provider = MagicMock()
        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.decorators import monitor_execution_time

            @monitor_execution_time()
            def get_answer():
                return 99

            assert get_answer() == 99

    def test_monitor_execution_time_custom_tags(self):
        mock_provider = MagicMock()
        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.decorators import monitor_execution_time

            @monitor_execution_time(metric_name="fn.time", tags={"service": "api"})
            def my_func():
                pass

            my_func()
            tags_used = mock_provider.send.call_args[1]["tags"]
            assert tags_used["service"] == "api"
            assert tags_used["function"] == "my_func"

    # ---- monitor_errors ----
    def test_monitor_errors_no_exception(self):
        mock_provider = MagicMock()
        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.decorators import monitor_errors

            @monitor_errors(metric_name="fn.errors")
            def safe_func():
                return "safe"

            result = safe_func()
            assert result == "safe"
            mock_provider.send.assert_not_called()

    def test_monitor_errors_on_exception(self):
        mock_provider = MagicMock()
        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.decorators import monitor_errors

            @monitor_errors(metric_name="fn.errors")
            def boom():
                raise ValueError("oops")

            with pytest.raises(ValueError, match="oops"):
                boom()

            mock_provider.send.assert_called_once()
            tags = mock_provider.send.call_args[1]["tags"]
            assert tags["error"] == "ValueError"
            assert tags["function"] == "boom"

    def test_monitor_errors_custom_tags(self):
        mock_provider = MagicMock()
        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.decorators import monitor_errors

            @monitor_errors(tags={"env": "staging"})
            def boom():
                raise RuntimeError("fail")

            with pytest.raises(RuntimeError):
                boom()

            tags = mock_provider.send.call_args[1]["tags"]
            assert tags["env"] == "staging"
            assert tags["error"] == "RuntimeError"


class TestMonitoringMiddleware:
    """Testcases for monitoring middlewares"""

    def _make_request(self, method="GET", path="/api/test", status_code=200):
        req = MagicMock()
        req.method = method
        req.path = path
        resp = MagicMock()
        resp.status_code = status_code
        return req, resp

    def test_calls_get_response_and_returns_response(self):
        req, resp = self._make_request()
        get_response = MagicMock(return_value=resp)
        mock_provider = MagicMock()

        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.middleware import MonitoringMiddleware
            mw = MonitoringMiddleware(get_response)
            result = mw(req)

        assert result is resp
        get_response.assert_called_once_with(req)

    def test_sends_request_count_metric(self):
        req, resp = self._make_request()
        get_response = MagicMock(return_value=resp)
        mock_provider = MagicMock()

        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.middleware import MonitoringMiddleware
            mw = MonitoringMiddleware(get_response)
            mw(req)

        calls = [c[0][0] for c in mock_provider.send.call_args_list]
        assert "rx.api.request_count" in calls

    def test_sends_request_duration_metric(self):
        req, resp = self._make_request()
        get_response = MagicMock(return_value=resp)
        mock_provider = MagicMock()

        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.middleware import MonitoringMiddleware
            mw = MonitoringMiddleware(get_response)
            mw(req)

        calls = [c[0][0] for c in mock_provider.send.call_args_list]
        assert "rx.api.request_duration" in calls

    def test_tags_contain_method_status_path(self):
        req, resp = self._make_request(method="POST", path="/checkout", status_code=201)
        get_response = MagicMock(return_value=resp)
        mock_provider = MagicMock()

        with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
            from monitoring.middleware import MonitoringMiddleware
            mw = MonitoringMiddleware(get_response)
            mw(req)

        # Both calls use the same tags dict
        tags = mock_provider.send.call_args_list[0][1]["tags"]
        assert tags["method"] == "POST"
        assert tags["status"] == "201"
        assert tags["path"] == "/checkout"

    def test_source_tag_ec2(self):
        req, resp = self._make_request()
        get_response = MagicMock(return_value=resp)
        mock_provider = MagicMock()

        env_without_lambda = {k: v for k, v in os.environ.items() if k != "AWS_LAMBDA_FUNCTION_NAME"}
        with patch.dict(os.environ, env_without_lambda, clear=True):
            with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
                from monitoring.middleware import MonitoringMiddleware
                mw = MonitoringMiddleware(get_response)
                mw(req)

        tags = mock_provider.send.call_args_list[0][1]["tags"]
        assert tags["source"] == "ec2"

    def test_source_tag_lambda(self):
        req, resp = self._make_request()
        get_response = MagicMock(return_value=resp)
        mock_provider = MagicMock()

        with patch.dict(os.environ, {"AWS_LAMBDA_FUNCTION_NAME": "my-lambda"}):
            with patch("monitoring.client.MonitoringFacade.get_instance", return_value=mock_provider):
                from monitoring.middleware import MonitoringMiddleware
                mw = MonitoringMiddleware(get_response)
                mw(req)

        tags = mock_provider.send.call_args_list[0][1]["tags"]
        assert tags["source"] == "lambda"


class TestUtils:
    """Testcases for pure utility functions."""

    def setup_method(self):
        from monitoring.utils import convert_tags, prepare_json_payload, get_logger
        self.convert_tags = convert_tags
        self.prepare_json_payload = prepare_json_payload
        self.get_logger = get_logger

    def test_convert_tags_none(self):
        assert self.convert_tags(None) == []

    def test_convert_tags_empty_dict(self):
        assert self.convert_tags({}) == []

    def test_convert_tags_single(self):
        result = self.convert_tags({"env": "test"})
        assert result == ["env:test"]

    def test_convert_tags_multiple(self):
        result = self.convert_tags({"env": "test", "region": "eu-west-1"})
        assert "env:test" in result
        assert "region:eu-west-1" in result
        assert len(result) == 2

    def test_prepare_json_payload_returns_valid_json(self):
        payload = {"event": "test", "value": 42}
        result = self.prepare_json_payload(payload)
        parsed = json.loads(result)
        assert parsed["event"] == "test"
        assert parsed["value"] == 42

    def test_prepare_json_payload_nested(self):
        payload = {"a": {"b": [1, 2, 3]}}
        result = self.prepare_json_payload(payload)
        assert json.loads(result)["a"]["b"] == [1, 2, 3]

    def test_get_logger_name(self):
        logger = self.get_logger("providers.datadog")
        assert logger.name == "ddog_monitoring.providers.datadog"

    def test_get_logger_no_handlers(self):
        logger = self.get_logger("some.module")
        assert len(logger.handlers) == 0

    def test_get_logger_returns_logger_instance(self):
        logger = self.get_logger("test")
        assert isinstance(logger, logging.Logger)


class TestDatadogJSONFormatter:
    """Testcases for Datadog JSON Formatter"""

    def _make_record(self, message="hello", level=logging.INFO, **extra):
        logger = logging.getLogger("test.formatter")
        record = logger.makeRecord(
            name="test.formatter",
            level=level,
            fn="test_file.py",
            lno=42,
            msg=message,
            args=(),
            exc_info=None,
        )
        for k, v in extra.items():
            setattr(record, k, v)
        return record

    def _format(self, record):
        from monitoring.json_formatter import DatadogJSONFormatter
        formatter = DatadogJSONFormatter()
        raw = formatter.format(record)
        return json.loads(raw)

    def test_format_basic_fields(self):
        record = self._make_record("test message")
        result = self._format(record)
        assert "timestamp" in result
        assert result["level"] == "INFO"
        assert result["message"] == "test message"
        assert result["logger"] == "test.formatter"
        assert "module" in result
        assert "function" in result
        assert result["line"] == 42

    def test_format_with_ddtrace(self):
        """When ddtrace is present with an active span, dd.trace_id/span_id are added."""
        fake_span = MagicMock()
        fake_span.trace_id = 123456
        fake_span.span_id = 789012

        fake_tracer = MagicMock()
        fake_tracer.current_span.return_value = fake_span

        fake_ddtrace = types.ModuleType("ddtrace")
        fake_ddtrace.tracer = fake_tracer

        with patch.dict(sys.modules, {"ddtrace": fake_ddtrace}):
            import importlib
            import monitoring.json_formatter as jf_module
            importlib.reload(jf_module)
            from monitoring.json_formatter import DatadogJSONFormatter

            formatter = DatadogJSONFormatter()
            record = self._make_record("trace test")
            raw = formatter.format(record)
            result = json.loads(raw)

        assert "dd.trace_id" in result
        assert "dd.span_id" in result
        assert result["dd.trace_id"] == "123456"
        assert result["dd.span_id"] == "789012"

    def test_format_without_ddtrace(self):
        """When ddtrace is not installed, no dd fields and no crash."""
        with patch.dict(sys.modules, {"ddtrace": None}):
            import importlib
            import monitoring.json_formatter as jf_module
            importlib.reload(jf_module)
            from monitoring.json_formatter import DatadogJSONFormatter

            formatter = DatadogJSONFormatter()
            record = self._make_record("no trace")
            raw = formatter.format(record)
            result = json.loads(raw)

        assert "dd.trace_id" not in result
        assert "dd.span_id" not in result
        assert result["message"] == "no trace"

    def test_format_ddtrace_no_active_span(self):
        """ddtrace present but no active span → no dd fields."""
        fake_tracer = MagicMock()
        fake_tracer.current_span.return_value = None
        fake_ddtrace = types.ModuleType("ddtrace")
        fake_ddtrace.tracer = fake_tracer

        with patch.dict(sys.modules, {"ddtrace": fake_ddtrace}):
            import importlib
            import monitoring.json_formatter as jf_module
            importlib.reload(jf_module)
            from monitoring.json_formatter import DatadogJSONFormatter

            formatter = DatadogJSONFormatter()
            record = self._make_record("no span")
            raw = formatter.format(record)
            result = json.loads(raw)

        assert "dd.trace_id" not in result

    def test_format_tags_list(self):
        record = self._make_record("tagged", tags=["env:test", "region:us"])
        result = self._format(record)
        assert result["ddtags"] == "env:test,region:us"

    def test_format_tags_string(self):
        record = self._make_record("tagged", tags="env:test")
        result = self._format(record)
        assert result["ddtags"] == "env:test"

    def test_format_exception_info(self):
        try:
            raise ValueError("boom")
        except ValueError:
            exc_info = sys.exc_info()

        logger = logging.getLogger("test.formatter")
        record = logger.makeRecord(
            name="test.formatter",
            level=logging.ERROR,
            fn="test_file.py",
            lno=10,
            msg="error occurred",
            args=(),
            exc_info=exc_info,
        )
        result = self._format(record)
        assert "exception" in result
        assert "ValueError" in result["exception"]

    def test_format_no_tags_no_exception(self):
        record = self._make_record("clean")
        result = self._format(record)
        assert "ddtags" not in result
        assert "exception" not in result
