import sys
import weakref
from functools import wraps

import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS
from sentry_sdk.integrations import (
    _DEFAULT_FAILED_REQUEST_STATUS_CODES,
    DidNotEnable,
    Integration,
    _check_minimum_version,
)
from sentry_sdk.integrations._wsgi_common import (
    _filter_headers,
    request_body_within_bounds,
)
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.scope import Scope, should_send_default_pii
from sentry_sdk.sessions import track_session
from sentry_sdk.traces import (
    SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE,
)
from sentry_sdk.traces import (
    NoOpStreamedSpan,
    SegmentSource,
    SpanStatus,
    StreamedSpan,
)
from sentry_sdk.tracing import (
    BAGGAGE_HEADER_NAME,
    SOURCE_FOR_STYLE,
    TransactionSource,
)
from sentry_sdk.tracing_utils import (
    add_http_request_source,
    has_span_streaming_enabled,
    should_propagate_trace,
)
from sentry_sdk.utils import (
    CONTEXTVARS_ERROR_MESSAGE,
    HAS_REAL_CONTEXTVARS,
    SENSITIVE_DATA_SUBSTITUTE,
    AnnotatedValue,
    _register_control_flow_exception,
    capture_internal_exceptions,
    ensure_integration_enabled,
    event_from_exception,
    logger,
    parse_url,
    parse_version,
    reraise,
    transaction_from_function,
)

try:
    import asyncio

    from aiohttp import ClientSession, TraceConfig
    from aiohttp import __version__ as AIOHTTP_VERSION
    from aiohttp.web import Application, HTTPException, UrlDispatcher
except ImportError:
    raise DidNotEnable("AIOHTTP not installed")

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Set
    from types import SimpleNamespace
    from typing import Any, ContextManager, Optional, Tuple, Union

    from aiohttp import TraceRequestEndParams, TraceRequestStartParams
    from aiohttp.web_request import Request
    from aiohttp.web_urldispatcher import UrlMappingMatchInfo

    from sentry_sdk._types import Attributes, Event, EventProcessor
    from sentry_sdk.tracing import Span
    from sentry_sdk.utils import ExcInfo


TRANSACTION_STYLE_VALUES = ("handler_name", "method_and_path_pattern")


class AioHttpIntegration(Integration):
    identifier = "aiohttp"
    origin = f"auto.http.{identifier}"

    def __init__(
        self,
        transaction_style: str = "handler_name",
        *,
        failed_request_status_codes: "Set[int]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES,
    ) -> None:
        if transaction_style not in TRANSACTION_STYLE_VALUES:
            raise ValueError(
                "Invalid value for transaction_style: %s (must be in %s)"
                % (transaction_style, TRANSACTION_STYLE_VALUES)
            )
        self.transaction_style = transaction_style
        self._failed_request_status_codes = failed_request_status_codes

    @staticmethod
    def setup_once() -> None:
        version = parse_version(AIOHTTP_VERSION)
        _check_minimum_version(AioHttpIntegration, version)

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

        # In the aiohttp integration, all of their HTTP responses are Exceptions.
        # Because they have to be raised and handled by the framework, we need to
        # register the exceptions as control flow exceptions so that we don't
        # accidentally overwrite a status of "ok" with "error".
        _register_control_flow_exception(HTTPException)

        ignore_logger("aiohttp.server")

        old_handle = Application._handle

        async def sentry_app_handle(
            self: "Any", request: "Request", *args: "Any", **kwargs: "Any"
        ) -> "Any":
            client = sentry_sdk.get_client()
            integration = client.get_integration(AioHttpIntegration)
            if integration is None:
                return await old_handle(self, request, *args, **kwargs)

            weak_request = weakref.ref(request)
            is_span_streaming_enabled = has_span_streaming_enabled(client.options)

            with sentry_sdk.isolation_scope() as scope:
                with track_session(scope, session_mode="request"):
                    # Scope data will not leak between requests because aiohttp
                    # create a task to wrap each request.
                    scope.generate_propagation_context()
                    scope.clear_breadcrumbs()
                    scope.add_event_processor(_make_request_processor(weak_request))

                    headers = dict(request.headers)

                    span_ctx: "ContextManager[Union[Span, StreamedSpan]]"
                    if is_span_streaming_enabled:
                        sentry_sdk.traces.continue_trace(headers)
                        Scope.set_custom_sampling_context({"aiohttp_request": request})

                        header_attributes: "dict[str, Any]" = {}
                        for header, header_value in _filter_headers(
                            headers, use_annotated_value=False
                        ).items():
                            header_attributes[
                                f"http.request.header.{header.lower()}"
                            ] = (
                                # header_value will always be a string because we set `use_annotated_value` to false above
                                header_value
                            )

                        url_attributes = {}
                        if should_send_default_pii():
                            url_attributes["url.full"] = "%s://%s%s" % (
                                request.scheme,
                                request.host,
                                request.path,
                            )
                            url_attributes["url.path"] = request.path

                            if request.query_string:
                                url_attributes["url.query"] = request.query_string

                        client_address_attributes = {}
                        if should_send_default_pii() and request.remote:
                            client_address_attributes["client.address"] = request.remote
                            scope.set_attribute(
                                SPANDATA.USER_IP_ADDRESS, request.remote
                            )

                        span_ctx = sentry_sdk.traces.start_span(
                            # If this name makes it to the UI, AIOHTTP's URL
                            # resolver did not find a route or died trying.
                            name="generic AIOHTTP request",
                            attributes={
                                "sentry.op": OP.HTTP_SERVER,
                                "sentry.origin": AioHttpIntegration.origin,
                                "sentry.span.source": SegmentSource.ROUTE.value,
                                "http.request.method": request.method,
                                **url_attributes,
                                **client_address_attributes,
                                **header_attributes,
                            },
                            parent_span=None,
                        )
                    else:
                        transaction = continue_trace(
                            headers,
                            op=OP.HTTP_SERVER,
                            # If this transaction name makes it to the UI, AIOHTTP's
                            # URL resolver did not find a route or died trying.
                            name="generic AIOHTTP request",
                            source=TransactionSource.ROUTE,
                            origin=AioHttpIntegration.origin,
                        )
                        span_ctx = sentry_sdk.start_transaction(
                            transaction,
                            custom_sampling_context={"aiohttp_request": request},
                        )

                    with span_ctx as span:
                        try:
                            response = await old_handle(self, request)
                        except HTTPException as e:
                            if isinstance(span, StreamedSpan) and not isinstance(
                                span, NoOpStreamedSpan
                            ):
                                span.set_attribute(
                                    "http.response.status_code", e.status_code
                                )

                                if e.status_code >= 400:
                                    span.status = SpanStatus.ERROR.value
                                else:
                                    span.status = SpanStatus.OK.value
                            else:
                                # Since a NoOpStreamedSpan can end up here, we have to guard against it
                                # so this only gets set in the legacy transaction approach.
                                if not isinstance(span, NoOpStreamedSpan):
                                    span.set_http_status(e.status_code)

                            if (
                                e.status_code
                                in integration._failed_request_status_codes
                            ):
                                _capture_exception()
                            raise
                        except (asyncio.CancelledError, ConnectionResetError):
                            if isinstance(span, StreamedSpan):
                                span.status = SpanStatus.ERROR.value
                            else:
                                span.set_status(SPANSTATUS.CANCELLED)
                            raise
                        except Exception:
                            # This will probably map to a 500 but seems like we
                            # have no way to tell. Do not set span status.
                            reraise(*_capture_exception())

                        try:
                            # A valid response handler will return a valid response with a status. But, if the handler
                            # returns an invalid response (e.g. None), the line below will raise an AttributeError.
                            # Even though this is likely invalid, we need to handle this case to ensure we don't break
                            # the application.
                            response_status = response.status
                        except AttributeError:
                            pass
                        else:
                            if isinstance(span, StreamedSpan):
                                span.set_attribute(
                                    "http.response.status_code", response_status
                                )
                                span.status = (
                                    SpanStatus.ERROR.value
                                    if response_status >= 400
                                    else SpanStatus.OK.value
                                )
                            else:
                                span.set_http_status(response_status)

                        return response

        Application._handle = sentry_app_handle

        old_urldispatcher_resolve = UrlDispatcher.resolve

        @wraps(old_urldispatcher_resolve)
        async def sentry_urldispatcher_resolve(
            self: "UrlDispatcher", request: "Request"
        ) -> "UrlMappingMatchInfo":
            rv = await old_urldispatcher_resolve(self, request)

            integration = sentry_sdk.get_client().get_integration(AioHttpIntegration)
            if integration is None:
                return rv

            name = None

            try:
                if integration.transaction_style == "handler_name":
                    name = transaction_from_function(rv.handler)
                elif integration.transaction_style == "method_and_path_pattern":
                    route_info = rv.get_info()
                    pattern = route_info.get("path") or route_info.get("formatter")
                    name = "{} {}".format(request.method, pattern)
            except Exception:
                pass

            if name is not None:
                current_span = sentry_sdk.get_current_span()
                if isinstance(current_span, StreamedSpan) and not isinstance(
                    current_span, NoOpStreamedSpan
                ):
                    current_span._segment.name = name
                    current_span._segment.set_attribute(
                        "sentry.span.source",
                        SEGMENT_SOURCE_FOR_STYLE[integration.transaction_style].value,
                    )
                else:
                    current_scope = sentry_sdk.get_current_scope()
                    current_scope.set_transaction_name(
                        name,
                        source=SOURCE_FOR_STYLE[integration.transaction_style],
                    )

            return rv

        UrlDispatcher.resolve = sentry_urldispatcher_resolve

        old_client_session_init = ClientSession.__init__

        @ensure_integration_enabled(AioHttpIntegration, old_client_session_init)
        def init(*args: "Any", **kwargs: "Any") -> None:
            client_trace_configs = list(kwargs.get("trace_configs") or ())
            trace_config = create_trace_config()
            client_trace_configs.append(trace_config)

            kwargs["trace_configs"] = client_trace_configs
            return old_client_session_init(*args, **kwargs)

        ClientSession.__init__ = init


def create_trace_config() -> "TraceConfig":
    async def on_request_start(
        session: "ClientSession",
        trace_config_ctx: "SimpleNamespace",
        params: "TraceRequestStartParams",
    ) -> None:
        client = sentry_sdk.get_client()
        if client.get_integration(AioHttpIntegration) is None:
            return

        method = params.method.upper()

        parsed_url = None
        with capture_internal_exceptions():
            parsed_url = parse_url(str(params.url), sanitize=False)

        span_name = "%s %s" % (
            method,
            parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
        )

        span: "Union[Span, StreamedSpan]"
        if has_span_streaming_enabled(client.options):
            attributes: "Attributes" = {
                "sentry.op": OP.HTTP_CLIENT,
                "sentry.origin": AioHttpIntegration.origin,
                "http.request.method": method,
            }
            if parsed_url is not None and should_send_default_pii():
                attributes["url.full"] = parsed_url.url
                attributes["url.path"] = params.url.path

                if parsed_url.query:
                    attributes["url.query"] = parsed_url.query
                if parsed_url.fragment:
                    attributes["url.fragment"] = parsed_url.fragment

            span = sentry_sdk.traces.start_span(name=span_name, attributes=attributes)
        else:
            legacy_span = sentry_sdk.start_span(
                op=OP.HTTP_CLIENT,
                name=span_name,
                origin=AioHttpIntegration.origin,
            )
            legacy_span.set_data(SPANDATA.HTTP_METHOD, method)
            if parsed_url is not None:
                legacy_span.set_data("url", parsed_url.url)
                legacy_span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
                legacy_span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
            span = legacy_span

        if should_propagate_trace(client, str(params.url)):
            for (
                key,
                value,
            ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
                span=span
            ):
                logger.debug(
                    "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
                        key=key, value=value, url=params.url
                    )
                )
                if key == BAGGAGE_HEADER_NAME and params.headers.get(
                    BAGGAGE_HEADER_NAME
                ):
                    # do not overwrite any existing baggage, just append to it
                    params.headers[key] += "," + value
                else:
                    params.headers[key] = value

        trace_config_ctx.span = span

    async def on_request_end(
        session: "ClientSession",
        trace_config_ctx: "SimpleNamespace",
        params: "TraceRequestEndParams",
    ) -> None:
        if trace_config_ctx.span is None:
            return

        span = trace_config_ctx.span
        status = int(params.response.status)

        if isinstance(span, StreamedSpan):
            span.set_attribute("http.response.status_code", status)
            span.status = (
                SpanStatus.ERROR.value if status >= 400 else SpanStatus.OK.value
            )

            with capture_internal_exceptions():
                add_http_request_source(span)
            span.end()
        else:
            span.set_http_status(status)
            span.set_data("reason", params.response.reason)
            span.finish()
            with capture_internal_exceptions():
                add_http_request_source(span)

    trace_config = TraceConfig()

    trace_config.on_request_start.append(on_request_start)
    trace_config.on_request_end.append(on_request_end)

    return trace_config


def _make_request_processor(
    weak_request: "weakref.ReferenceType[Request]",
) -> "EventProcessor":
    def aiohttp_processor(
        event: "Event",
        hint: "dict[str, Tuple[type, BaseException, Any]]",
    ) -> "Event":
        request = weak_request()
        if request is None:
            return event

        with capture_internal_exceptions():
            request_info = event.setdefault("request", {})

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

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

            # Just attach raw data here if it is within bounds, if available.
            # Unfortunately there's no way to get structured data from aiohttp
            # without awaiting on some coroutine.
            request_info["data"] = get_aiohttp_request_data(request)

        return event

    return aiohttp_processor


def _capture_exception() -> "ExcInfo":
    exc_info = sys.exc_info()
    event, hint = event_from_exception(
        exc_info,
        client_options=sentry_sdk.get_client().options,
        mechanism={"type": "aiohttp", "handled": False},
    )
    sentry_sdk.capture_event(event, hint=hint)
    return exc_info


BODY_NOT_READ_MESSAGE = "[Can't show request body due to implementation details.]"


def get_aiohttp_request_data(
    request: "Request",
) -> "Union[Optional[str], AnnotatedValue]":
    bytes_body = request._read_bytes

    if bytes_body is not None:
        # we have body to show
        if not request_body_within_bounds(sentry_sdk.get_client(), len(bytes_body)):
            return AnnotatedValue.substituted_because_over_size_limit()

        encoding = request.charset or "utf-8"
        return bytes_body.decode(encoding, "replace")

    if request.can_read_body:
        # body exists but we can't show it
        return BODY_NOT_READ_MESSAGE

    # request has no body
    return None
