"""
IMPORTANT: The contents of this file are part of a proof of concept and as such
are experimental and not suitable for production use. They may be changed or
removed at any time without prior notice.
"""
import sys
from importlib import import_module

from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor
from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
from sentry_sdk.utils import logger, _get_installed_modules
from sentry_sdk._types import TYPE_CHECKING

try:
    from opentelemetry import trace  # type: ignore
    from opentelemetry.instrumentation.auto_instrumentation._load import (  # type: ignore
        _load_distro,
        _load_instrumentors,
    )
    from opentelemetry.propagate import set_global_textmap  # type: ignore
    from opentelemetry.sdk.trace import TracerProvider  # type: ignore
except ImportError:
    raise DidNotEnable("opentelemetry not installed")

if TYPE_CHECKING:
    from typing import Dict


CLASSES_TO_INSTRUMENT = {
    # A mapping of packages to their entry point class that will be instrumented.
    # This is used to post-instrument any classes that were imported before OTel
    # instrumentation took place.
    "fastapi": "fastapi.FastAPI",
    "flask": "flask.Flask",
}


class OpenTelemetryIntegration(Integration):
    identifier = "opentelemetry"

    @staticmethod
    def setup_once():
        # type: () -> None
        logger.warning(
            "[OTel] Initializing highly experimental OpenTelemetry support. "
            "Use at your own risk."
        )

        original_classes = _record_unpatched_classes()

        try:
            distro = _load_distro()
            distro.configure()
            _load_instrumentors(distro)
        except Exception:
            logger.exception("[OTel] Failed to auto-initialize OpenTelemetry")

        try:
            _patch_remaining_classes(original_classes)
        except Exception:
            logger.exception(
                "[OTel] Failed to post-patch instrumented classes. "
                "You might have to make sure sentry_sdk.init() is called before importing anything else."
            )

        _setup_sentry_tracing()

        logger.debug("[OTel] Finished setting up OpenTelemetry integration")


def _record_unpatched_classes():
    # type: () -> Dict[str, type]
    """
    Keep references to classes that are about to be instrumented.

    Used to search for unpatched classes after the instrumentation has run so
    that they can be patched manually.
    """
    installed_packages = _get_installed_modules()

    original_classes = {}

    for package, orig_path in CLASSES_TO_INSTRUMENT.items():
        if package in installed_packages:
            try:
                original_cls = _import_by_path(orig_path)
            except (AttributeError, ImportError):
                logger.debug("[OTel] Failed to import %s", orig_path)
                continue

            original_classes[package] = original_cls

    return original_classes


def _patch_remaining_classes(original_classes):
    # type: (Dict[str, type]) -> None
    """
    Best-effort attempt to patch any uninstrumented classes in sys.modules.

    This enables us to not care about the order of imports and sentry_sdk.init()
    in user code. If e.g. the Flask class had been imported before sentry_sdk
    was init()ed (and therefore before the OTel instrumentation ran), it would
    not be instrumented. This function goes over remaining uninstrumented
    occurrences of the class in sys.modules and replaces them with the
    instrumented class.

    Since this is looking for exact matches, it will not work in some scenarios
    (e.g. if someone is not using the specific class explicitly, but rather
    inheriting from it). In those cases it's still necessary to sentry_sdk.init()
    before importing anything that's supposed to be instrumented.
    """
    # check which classes have actually been instrumented
    instrumented_classes = {}

    for package in list(original_classes.keys()):
        original_path = CLASSES_TO_INSTRUMENT[package]

        try:
            cls = _import_by_path(original_path)
        except (AttributeError, ImportError):
            logger.debug(
                "[OTel] Failed to check if class has been instrumented: %s",
                original_path,
            )
            del original_classes[package]
            continue

        if not cls.__module__.startswith("opentelemetry."):
            del original_classes[package]
            continue

        instrumented_classes[package] = cls

    if not instrumented_classes:
        return

    # replace occurrences of the original unpatched class in sys.modules
    for module_name, module in sys.modules.copy().items():
        if (
            module_name.startswith("sentry_sdk")
            or module_name in sys.builtin_module_names
        ):
            continue

        for package, original_cls in original_classes.items():
            for var_name, var in vars(module).copy().items():
                if var == original_cls:
                    logger.debug(
                        "[OTel] Additionally patching %s from %s",
                        original_cls,
                        module_name,
                    )

                    setattr(module, var_name, instrumented_classes[package])


def _import_by_path(path):
    # type: (str) -> type
    parts = path.rsplit(".", maxsplit=1)
    return getattr(import_module(parts[0]), parts[-1])


def _setup_sentry_tracing():
    # type: () -> None
    provider = TracerProvider()

    provider.add_span_processor(SentrySpanProcessor())

    trace.set_tracer_provider(provider)

    set_global_textmap(SentryPropagator())
