# frozen_string_literal: true

require "singleton"

module Sentry
  module OpenTelemetry
    TraceData = Struct.new(:trace_id, :span_id, :parent_span_id, :parent_sampled, :baggage)

    class SpanProcessor < ::OpenTelemetry::SDK::Trace::SpanProcessor
      include Singleton

      SEMANTIC_CONVENTIONS = ::OpenTelemetry::SemanticConventions::Trace
      INTERNAL_SPAN_KINDS = %i[client internal]
      SPAN_ORIGIN = "auto.otel"

      # The mapping from otel span ids to sentry spans
      # @return [Hash]
      attr_reader :span_map

      def initialize
        @span_map = {}
        setup_event_processor
      end

      def on_start(otel_span, parent_context)
        return unless Sentry.initialized? && Sentry.configuration.instrumenter == :otel
        return unless otel_span.context.valid?
        return if from_sentry_sdk?(otel_span)

        trace_data = get_trace_data(otel_span, parent_context)

        sentry_parent_span = @span_map[trace_data.parent_span_id] if trace_data.parent_span_id

        sentry_span = if sentry_parent_span
          sentry_parent_span.start_child(
            span_id: trace_data.span_id,
            description: otel_span.name,
            start_timestamp: otel_span.start_timestamp / 1e9,
            origin: SPAN_ORIGIN
          )
        else
          options = {
            instrumenter: :otel,
            name: otel_span.name,
            span_id: trace_data.span_id,
            trace_id: trace_data.trace_id,
            parent_span_id: trace_data.parent_span_id,
            parent_sampled: trace_data.parent_sampled,
            baggage: trace_data.baggage,
            start_timestamp: otel_span.start_timestamp / 1e9,
            origin: SPAN_ORIGIN
          }

          Sentry.start_transaction(**options)
        end

        @span_map[trace_data.span_id] = sentry_span
      end

      def on_finish(otel_span)
        return unless Sentry.initialized? && Sentry.configuration.instrumenter == :otel
        return unless otel_span.context.valid?

        sentry_span = @span_map.delete(otel_span.context.hex_span_id)
        return unless sentry_span

        if sentry_span.is_a?(Sentry::Transaction)
          update_transaction_with_otel_data(sentry_span, otel_span)
        else
          update_span_with_otel_data(sentry_span, otel_span)
        end

        sentry_span.finish(end_timestamp: otel_span.end_timestamp / 1e9)
      end

      def clear
        @span_map = {}
      end

      private

      def from_sentry_sdk?(otel_span)
        dsn = Sentry.configuration.dsn
        return false unless dsn

        if otel_span.name.start_with?("HTTP") || otel_span.name == "connect"
          # only check client requests, connects are sometimes internal
          return false unless INTERNAL_SPAN_KINDS.include?(otel_span.kind)

          address = otel_span.attributes[SEMANTIC_CONVENTIONS::NET_PEER_NAME]

          # if no address drop it, just noise
          return true unless address
          return true if dsn.host == address
        end

        false
      end

      def get_trace_data(otel_span, parent_context)
        trace_data = TraceData.new
        trace_data.span_id = otel_span.context.hex_span_id
        trace_data.trace_id = otel_span.context.hex_trace_id

        unless otel_span.parent_span_id == ::OpenTelemetry::Trace::INVALID_SPAN_ID
          trace_data.parent_span_id = otel_span.parent_span_id.unpack1("H*")
        end

        sentry_trace_data = parent_context[Propagator::SENTRY_TRACE_KEY]
        trace_data.parent_sampled = sentry_trace_data[2] if sentry_trace_data

        trace_data.baggage = parent_context[Propagator::SENTRY_BAGGAGE_KEY]

        trace_data
      end

      def otel_context_hash(otel_span)
        otel_context = {}
        otel_context[:attributes] = otel_span.attributes unless otel_span.attributes.empty?

        resource_attributes = otel_span.resource.attribute_enumerator.to_h
        otel_context[:resource] = resource_attributes unless resource_attributes.empty?

        otel_context
      end

      def parse_span_description(otel_span)
        op = otel_span.name
        description = otel_span.name

        if (http_method = otel_span.attributes[SEMANTIC_CONVENTIONS::HTTP_METHOD])
          op = "http.#{otel_span.kind}"
          description = http_method

          peer_name = otel_span.attributes[SEMANTIC_CONVENTIONS::NET_PEER_NAME]
          description += " #{peer_name}" if peer_name

          target = otel_span.attributes[SEMANTIC_CONVENTIONS::HTTP_TARGET]
          description += target if target
        elsif otel_span.attributes[SEMANTIC_CONVENTIONS::DB_SYSTEM]
          op = "db"

          statement = otel_span.attributes[SEMANTIC_CONVENTIONS::DB_STATEMENT]
          description = statement if statement
        end

        [op, description]
      end

      def update_span_status(sentry_span, otel_span)
        if (http_status_code = otel_span.attributes[SEMANTIC_CONVENTIONS::HTTP_STATUS_CODE])
          sentry_span.set_http_status(http_status_code)
        elsif (status_code = otel_span.status.code)
          status = [0, 1].include?(status_code) ? "ok" : "internal_error"
          sentry_span.set_status(status)
        end
      end

      def update_span_with_otel_data(sentry_span, otel_span)
        update_span_status(sentry_span, otel_span)
        sentry_span.set_data("otel.kind", otel_span.kind)
        otel_span.attributes&.each { |k, v| sentry_span.set_data(k, v) }

        op, description = parse_span_description(otel_span)
        sentry_span.set_op(op)
        sentry_span.set_description(description)
      end

      def update_transaction_with_otel_data(transaction, otel_span)
        update_span_status(transaction, otel_span)
        transaction.set_context(:otel, otel_context_hash(otel_span))

        op, _ = parse_span_description(otel_span)
        transaction.set_op(op)
        transaction.set_name(otel_span.name)
      end

      def setup_event_processor
        Sentry.add_global_event_processor do |event, _hint|
          span_context = ::OpenTelemetry::Trace.current_span.context
          next event unless span_context.valid?

          sentry_span = @span_map[span_context.hex_span_id]
          next event unless sentry_span

          event.contexts[:trace] ||= sentry_span.get_trace_context
          event
        end
      end
    end
  end
end
