import contextlib
import weakref
from inspect import iscoroutinefunction

import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
from sentry_sdk.integrations._wsgi_common import (
    RequestExtractor,
    _filter_headers,
    _is_json_content_type,
    request_body_within_bounds,
)
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.traces import SegmentSource, StreamedSpan
from sentry_sdk.tracing import TransactionSource
from sentry_sdk.tracing_utils import has_span_streaming_enabled
from sentry_sdk.utils import (
    CONTEXTVARS_ERROR_MESSAGE,
    HAS_REAL_CONTEXTVARS,
    AnnotatedValue,
    capture_internal_exceptions,
    ensure_integration_enabled,
    event_from_exception,
    transaction_from_function,
)

try:
    from tornado import version_info as TORNADO_VERSION
    from tornado.gen import coroutine
    from tornado.web import HTTPError, RequestHandler
except ImportError:
    raise DidNotEnable("Tornado not installed")

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from typing import Any, Callable, ContextManager, Dict, Generator, Optional, Union

    from sentry_sdk._types import Event, EventProcessor
    from sentry_sdk.tracing import Span


class TornadoIntegration(Integration):
    identifier = "tornado"
    origin = f"auto.http.{identifier}"

    @staticmethod
    def setup_once() -> None:
        _check_minimum_version(TornadoIntegration, TORNADO_VERSION)

        if not HAS_REAL_CONTEXTVARS:
            # Tornado is async. We better have contextvars or we're going to leak
            # state between requests.
            raise DidNotEnable(
                "The tornado integration for Sentry requires Python 3.7+ or the aiocontextvars package"
                + CONTEXTVARS_ERROR_MESSAGE
            )

        ignore_logger("tornado.access")

        old_execute = RequestHandler._execute

        awaitable = iscoroutinefunction(old_execute)

        if awaitable:
            # Starting Tornado 6 RequestHandler._execute method is a standard Python coroutine (async/await)
            # In that case our method should be a coroutine function too
            async def sentry_execute_request_handler(
                self: "RequestHandler", *args: "Any", **kwargs: "Any"
            ) -> "Any":
                with _handle_request_impl(self):
                    return await old_execute(self, *args, **kwargs)

        else:

            @coroutine  # type: ignore
            def sentry_execute_request_handler(
                self: "RequestHandler", *args: "Any", **kwargs: "Any"
            ) -> "Any":
                with _handle_request_impl(self):
                    result = yield from old_execute(self, *args, **kwargs)
                    return result

        RequestHandler._execute = sentry_execute_request_handler

        old_log_exception = RequestHandler.log_exception

        def sentry_log_exception(
            self: "Any",
            ty: type,
            value: BaseException,
            tb: "Any",
            *args: "Any",
            **kwargs: "Any",
        ) -> "Optional[Any]":
            _capture_exception(ty, value, tb)
            return old_log_exception(self, ty, value, tb, *args, **kwargs)

        RequestHandler.log_exception = sentry_log_exception


_DEFAULT_ROOT_SPAN_NAME = "generic Tornado request"


@contextlib.contextmanager
def _handle_request_impl(self: "RequestHandler") -> "Generator[None, None, None]":
    integration = sentry_sdk.get_client().get_integration(TornadoIntegration)

    if integration is None:
        yield
        return

    weak_handler = weakref.ref(self)
    client = sentry_sdk.get_client()
    is_span_streaming_enabled = has_span_streaming_enabled(client.options)

    with sentry_sdk.isolation_scope() as scope:
        headers = self.request.headers

        scope.clear_breadcrumbs()
        processor = _make_event_processor(weak_handler)
        scope.add_event_processor(processor)

        span_ctx: "ContextManager[Union[Span, StreamedSpan, None]]"

        if is_span_streaming_enabled:
            sentry_sdk.traces.continue_trace(dict(headers))
            scope.set_custom_sampling_context({"tornado_request": self.request})

            if should_send_default_pii() and self.request.remote_ip:
                scope.set_attribute(SPANDATA.USER_IP_ADDRESS, self.request.remote_ip)

            span_ctx = sentry_sdk.traces.start_span(
                name=_DEFAULT_ROOT_SPAN_NAME,
                attributes={
                    "sentry.op": OP.HTTP_SERVER,
                    "sentry.origin": TornadoIntegration.origin,
                    "sentry.span.source": SegmentSource.ROUTE,
                },
                parent_span=None,
            )
        else:
            transaction = continue_trace(
                headers,
                op=OP.HTTP_SERVER,
                # Like with all other integrations, this is our
                # fallback transaction in case there is no route.
                # sentry_urldispatcher_resolve is responsible for
                # setting a transaction name later.
                name=_DEFAULT_ROOT_SPAN_NAME,
                source=TransactionSource.ROUTE,
                origin=TornadoIntegration.origin,
            )
            span_ctx = sentry_sdk.start_transaction(
                transaction,
                custom_sampling_context={"tornado_request": self.request},
            )

        with span_ctx as span:
            try:
                yield
            finally:
                if type(span) is StreamedSpan:
                    with capture_internal_exceptions():
                        for attr, value in _get_request_attributes(
                            self.request
                        ).items():
                            span.set_attribute(attr, value)

                    with capture_internal_exceptions():
                        method = getattr(self, self.request.method.lower(), None)
                        if method is not None:
                            span_name = transaction_from_function(method)
                            if span_name:
                                span.name = span_name
                                span.set_attribute(
                                    "sentry.span.source",
                                    SegmentSource.COMPONENT,
                                )

                    with capture_internal_exceptions():
                        status_int = self.get_status()
                        span.set_attribute(SPANDATA.HTTP_STATUS_CODE, status_int)
                        span.status = "error" if status_int >= 400 else "ok"


def _get_request_attributes(request: "Any") -> "Dict[str, Any]":
    attributes = {}  # type: Dict[str, Any]

    if request.method:
        attributes[SPANDATA.HTTP_REQUEST_METHOD] = request.method.upper()

    headers = _filter_headers(dict(request.headers), use_annotated_value=False)
    for header, value in headers.items():
        attributes[f"{SPANDATA.HTTP_REQUEST_HEADER}.{header.lower()}"] = value

    if should_send_default_pii():
        attributes[SPANDATA.URL_FULL] = request.full_url()
        attributes["url.path"] = request.path

        if request.query:
            attributes[SPANDATA.URL_QUERY] = request.query

    if request.protocol:
        attributes[SPANDATA.NETWORK_PROTOCOL_NAME] = request.protocol

    if should_send_default_pii() and request.remote_ip:
        attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_ip

    with capture_internal_exceptions():
        raw_data = _get_tornado_request_data(request)
        body_data = raw_data.value if isinstance(raw_data, AnnotatedValue) else raw_data
        if body_data is not None:
            attributes[SPANDATA.HTTP_REQUEST_BODY_DATA] = body_data

    return attributes


def _get_tornado_request_data(
    request: "Any",
) -> "Union[Optional[str], AnnotatedValue]":
    body = request.body
    if not body:
        return None

    if not request_body_within_bounds(sentry_sdk.get_client(), len(body)):
        return AnnotatedValue.substituted_because_over_size_limit()

    return body.decode("utf-8", "replace")


@ensure_integration_enabled(TornadoIntegration)
def _capture_exception(ty: type, value: BaseException, tb: "Any") -> None:
    if isinstance(value, HTTPError):
        return

    event, hint = event_from_exception(
        (ty, value, tb),
        client_options=sentry_sdk.get_client().options,
        mechanism={"type": "tornado", "handled": False},
    )

    sentry_sdk.capture_event(event, hint=hint)


def _make_event_processor(
    weak_handler: "Callable[[], RequestHandler]",
) -> "EventProcessor":
    def tornado_processor(event: "Event", hint: "dict[str, Any]") -> "Event":
        handler = weak_handler()
        if handler is None:
            return event

        request = handler.request

        with capture_internal_exceptions():
            method = getattr(handler, handler.request.method.lower())
            event["transaction"] = transaction_from_function(method) or ""
            event["transaction_info"] = {"source": TransactionSource.COMPONENT}

        with capture_internal_exceptions():
            extractor = TornadoRequestExtractor(request)
            extractor.extract_into_event(event)

            request_info = event["request"]

            request_info["url"] = "%s://%s%s" % (
                request.protocol,
                request.host,
                request.path,
            )

            request_info["query_string"] = request.query
            request_info["method"] = request.method
            request_info["env"] = {"REMOTE_ADDR": request.remote_ip}
            request_info["headers"] = _filter_headers(dict(request.headers))

        if should_send_default_pii():
            try:
                current_user = handler.current_user
            except Exception:
                current_user = None

            if current_user:
                event.setdefault("user", {}).setdefault("is_authenticated", True)

        return event

    return tornado_processor


class TornadoRequestExtractor(RequestExtractor):
    def content_length(self) -> int:
        if self.request.body is None:
            return 0
        return len(self.request.body)

    def cookies(self) -> "Dict[str, str]":
        return {k: v.value for k, v in self.request.cookies.items()}

    def raw_data(self) -> bytes:
        return self.request.body

    def form(self) -> "Dict[str, Any]":
        return {
            k: [v.decode("latin1", "replace") for v in vs]
            for k, vs in self.request.body_arguments.items()
        }

    def is_json(self) -> bool:
        return _is_json_content_type(self.request.headers.get("content-type"))

    def files(self) -> "Dict[str, Any]":
        return {k: v[0] for k, v in self.request.files.items() if v}

    def size_of_file(self, file: "Any") -> int:
        return len(file.body or ())
