# frozen_string_literal: true

require 'opentracing/instrumentation/faraday/response_logger'

module OpenTracing
  module Instrumentation
    module Faraday
      # TraceMiddleware inject tracing header into request and trace request
      #
      # Usage with default config:
      #   Faraday.new(url) do |connection|
      #     connection.use \
      #       OpenTracing::Instrumentation::Faraday::TraceMiddleware
      #   end
      #
      # Usage with config block:
      #   Faraday.new(url) do |connection|
      #     connection.use \
      #       OpenTracing::Instrumentation::Faraday::TraceMiddleware do |c|
      #         # c is instance of Config
      #         c.tracer = tracer
      #       end
      #   end
      class TraceMiddleware
        extend Forwardable

        # Config for TraceMiddleware
        class Config
          DEFAULT_COMMAND_NAME = 'faraday_request'
          DEFAULT_COMPONENT = 'faraday'
          DEFAULT_EXPECTED_ERRORS = [StandardError].freeze

          def initialize
            @tracer = OpenTracing.global_tracer
            @operation_name = DEFAULT_COMMAND_NAME
            @component = DEFAULT_COMPONENT
            @expected_errors = DEFAULT_EXPECTED_ERRORS
            @service_name = nil
            @inject = true
            @response_logger = ResponseLogger.new
          end

          # Instance of tracer. Should implement OpenTracing::Tracer API.
          #
          # @return [OpenTracing::Tracer]
          attr_accessor :tracer

          # Operation name of tracing span.
          #
          # @return [String]
          attr_accessor :operation_name

          # List of handled errors.
          #
          # @return [Array<Class>]
          attr_accessor :expected_errors

          # Value for component tag
          #
          # @return [String]
          attr_accessor :component

          # Value for service_name tag.
          #
          # @return [String]
          attr_accessor :service_name

          # Instance of response logger
          #
          # @return [ResponseLogger]
          attr_accessor :response_logger

          # Inject trace headers to response
          #
          # @return [Boolean]
          attr_accessor :inject
        end

        # @param config [Config]
        #
        # @yieldparam config [Config]
        def initialize(
          app,
          config = Config.new
        )
          @app = app
          @config = config
          yield(config) if block_given?
        end

        # Wrap Faraday request to trace it with OpenTracing
        # @param env [Faraday::Env]
        # @return [Faraday::Response]
        def call(env)
          trace_request(env) do |span|
            inject_tracing(span, env) if inject
            response_logger&.log_request(span, env.request_headers)
            @app.call(env).on_complete do |response|
              set_response_tags(span, response)
              response_logger&.log_response(span, response.response_headers)
            end
          end
        end

        private

        def_delegators :@config,
                       :tracer,
                       :operation_name,
                       :component,
                       :expected_errors,
                       :inject,
                       :response_logger,
                       :service_name

        def trace_request(env)
          scope = build_scope(env)
          span = scope.span

          yield(span)
        rescue *expected_errors => e
          set_exception_tags(span, e)
          raise
        ensure
          scope.close
        end

        def build_scope(env)
          tracer.start_active_span(
            operation_name,
            tags: request_tags(env),
          )
        end

        def inject_tracing(span, env)
          tracer.inject(
            span.context,
            OpenTracing::FORMAT_RACK,
            env[:request_headers],
          )
        end

        def request_tags(env)
          base_tags
            .merge(http_tags(env))
            .merge(faraday_tags(env))
        end

        def base_tags
          {
            'span.kind' => 'client',
            'component' => component,
            'peer.service_name' => service_name,
          }.compact
        end

        def http_tags(env)
          {
            'http.method' => env[:method],
            'http.url' => env[:url].to_s,
          }
        end

        def faraday_tags(env)
          {
            'faraday.adapter' => @app.class.to_s,
            'faraday.parallel' => env.parallel?,
            'faraday.parse_body' => env.parse_body?,
            'faraday.ssl_verify' => env.ssl.verify?,
          }
        end

        def set_response_tags(span, response)
          span.set_tag('http.status_code', response.status)

          return if response.success?

          set_http_error_tags(span, response)
        end

        def set_http_error_tags(span, response)
          span.set_tag('error', true)
          span.log_kv(
            event: 'error',
            message: response.body.to_s,
            'error.kind': 'http',
          )
        end

        def set_exception_tags(span, error)
          span.set_tag('error', true)
          span.log_kv(
            event: 'error',
            message: error.message,
            'error.kind': error.class.to_s,
            stack: error.backtrace,
          )
        end
      end
    end
  end
end