# frozen_string_literal: true

# Copyright The OpenTelemetry Authors
#
# SPDX-License-Identifier: Apache-2.0

require 'opentelemetry/trace/status'

require_relative '../util/queue_time'

module OpenTelemetry
  module Instrumentation
    module Rack
      module Middlewares
        # TracerMiddleware propagates context and instruments Rack requests
        # by way of its middleware system
        class TracerMiddleware # rubocop:disable Metrics/ClassLength
          class << self
            def allowed_rack_request_headers
              @allowed_rack_request_headers ||= Array(config[:allowed_request_headers]).each_with_object({}) do |header, memo|
                memo["HTTP_#{header.to_s.upcase.gsub(/[-\s]/, '_')}"] = build_attribute_name('http.request.headers.', header)
              end
            end

            def allowed_response_headers
              @allowed_response_headers ||= Array(config[:allowed_response_headers]).each_with_object({}) do |header, memo|
                memo[header] = build_attribute_name('http.response.headers.', header)
                memo[header.to_s.upcase] = build_attribute_name('http.response.headers.', header)
              end
            end

            def build_attribute_name(prefix, suffix)
              prefix + suffix.to_s.downcase.gsub(/[-\s]/, '_')
            end

            def config
              Rack::Instrumentation.instance.config
            end

            private

            def clear_cached_config
              @allowed_rack_request_headers = nil
              @allowed_response_headers = nil
            end
          end

          EMPTY_HASH = {}.freeze

          def initialize(app)
            @app = app
            @untraced_endpoints = config[:untraced_endpoints]
          end

          def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
            if untraced_request?(env)
              OpenTelemetry::Common::Utilities.untraced do
                return @app.call(env)
              end
            end
            original_env = env.dup
            extracted_context = OpenTelemetry.propagation.extract(
              env,
              getter: OpenTelemetry::Context::Propagation.rack_env_getter
            )
            frontend_context = create_frontend_span(env, extracted_context)

            # restore extracted context in this process:
            OpenTelemetry::Context.with_current(frontend_context || extracted_context) do
              request_span_name = create_request_span_name(env['REQUEST_URI'] || original_env['PATH_INFO'], env)
              request_span_kind = frontend_context.nil? ? :server : :internal
              tracer.in_span(request_span_name,
                             attributes: request_span_attributes(env: env),
                             kind: request_span_kind) do |request_span|
                OpenTelemetry::Instrumentation::Rack.with_span(request_span) do
                  @app.call(env).tap do |status, headers, response|
                    set_attributes_after_request(request_span, status, headers, response)
                  end
                end
              end
            end
          ensure
            finish_span(frontend_context)
          end

          private

          def untraced_request?(env)
            return true if @untraced_endpoints.include?(env['PATH_INFO'])
            return true if config[:untraced_requests]&.call(env)

            false
          end

          # return Context with the frontend span as the current span
          def create_frontend_span(env, extracted_context)
            request_start_time = OpenTelemetry::Instrumentation::Rack::Util::QueueTime.get_request_start(env)

            return unless config[:record_frontend_span] && !request_start_time.nil?

            span = tracer.start_span('http_server.proxy',
                                     with_parent: extracted_context,
                                     attributes: {
                                       'start_time' => request_start_time.to_f
                                     },
                                     kind: :server)

            OpenTelemetry::Trace.context_with_span(span, parent_context: extracted_context)
          end

          def finish_span(context)
            OpenTelemetry::Trace.current_span(context).finish if context
          end

          def tracer
            OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.tracer
          end

          def request_span_attributes(env:)
            attributes = {
              'http.method' => env['REQUEST_METHOD'],
              'http.host' => env['HTTP_HOST'] || 'unknown',
              'http.scheme' => env['rack.url_scheme'],
              'http.target' => env['QUERY_STRING'].empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{env['QUERY_STRING']}"
            }

            attributes['http.user_agent'] = env['HTTP_USER_AGENT'] if env['HTTP_USER_AGENT']
            attributes.merge!(allowed_request_headers(env))
          end

          # https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name
          #
          # recommendation: span.name(s) should be low-cardinality (e.g.,
          # strip off query param value, keep param name)
          #
          # see http://github.com/open-telemetry/opentelemetry-specification/pull/416/files
          def create_request_span_name(request_uri_or_path_info, env)
            # NOTE: dd-trace-rb has implemented 'quantization' (which lowers url cardinality)
            #       see Datadog::Quantization::HTTP.url

            if (implementation = config[:url_quantization])
              implementation.call(request_uri_or_path_info, env)
            else
              request_uri_or_path_info
            end
          end

          def set_attributes_after_request(span, status, headers, _response)
            span.status = OpenTelemetry::Trace::Status.error unless (100..399).include?(status.to_i)
            span.set_attribute('http.status_code', status)

            # NOTE: if data is available, it would be good to do this:
            # set_attribute('http.route', ...
            # e.g., "/users/:userID?

            allowed_response_headers(headers).each { |k, v| span.set_attribute(k, v) }
          end

          def allowed_request_headers(env)
            return EMPTY_HASH if self.class.allowed_rack_request_headers.empty?

            {}.tap do |result|
              self.class.allowed_rack_request_headers.each do |key, value|
                result[value] = env[key] if env.key?(key)
              end
            end
          end

          def allowed_response_headers(headers)
            return EMPTY_HASH if headers.nil?
            return EMPTY_HASH if self.class.allowed_response_headers.empty?

            {}.tap do |result|
              self.class.allowed_response_headers.each do |key, value|
                if headers.key?(key)
                  result[value] = headers[key]
                else
                  # do case-insensitive match:
                  headers.each do |k, v|
                    if k.upcase == key
                      result[value] = v
                      break
                    end
                  end
                end
              end
            end
          end

          def config
            Rack::Instrumentation.instance.config
          end
        end
      end
    end
  end
end
