# frozen_string_literal: true

# The HTTP client provides methods for making `GET`, `POST`, etc requests to
# HTTP(S) servers. It also provides methods for resolving Puppetserver REST
# service endpoints using SRV records and settings (such as `server_list`,
# `server`, `ca_server`, etc). Once a service endpoint has been resolved, there
# are methods for making REST requests (such as getting a node, sending facts,
# etc).
#
# The client uses persistent HTTP connections by default unless the `Connection:
# close` header is specified and supports streaming response bodies.
#
# By default the client only trusts the Puppet CA for HTTPS connections. However,
# if the `include_system_store` request option is set to true, then Puppet will
# trust certificates in the puppet-agent CA bundle.
#
# @example To access the HTTP client:
#   client = Puppet.runtime[:http]
#
# @example To make an HTTP GET request:
#   response = client.get(URI("http://www.example.com"))
#
# @example To make an HTTPS GET request, trusting the puppet CA and certs in Puppet's CA bundle:
#   response = client.get(URI("https://www.example.com"), options: { include_system_store: true })
#
# @example To use a URL containing special characters, such as spaces:
#  response = client.get(URI(Puppet::Util.uri_encode("https://www.example.com/path to file")))
#
# @example To pass query parameters:
#   response = client.get(URI("https://www.example.com"), query: {'q' => 'puppet'})
#
# @example To pass custom headers:
#   response = client.get(URI("https://www.example.com"), headers: {'Accept-Content' => 'application/json'})
#
# @example To check if the response is successful (2xx):
#   response = client.get(URI("http://www.example.com"))
#   puts response.success?
#
# @example To get the response code and reason:
#   response = client.get(URI("http://www.example.com"))
#   unless response.success?
#     puts "HTTP #{response.code} #{response.reason}"
#    end
#
# @example To read response headers:
#   response = client.get(URI("http://www.example.com"))
#   puts response['Content-Type']
#
# @example To stream the response body:
#   client.get(URI("http://www.example.com")) do |response|
#     if response.success?
#       response.read_body do |data|
#         puts data
#       end
#     end
#   end
#
# @example To handle exceptions:
#   begin
#     client.get(URI("https://www.example.com"))
#   rescue Puppet::HTTP::ResponseError => e
#     puts "HTTP #{e.response.code} #{e.response.reason}"
#   rescue Puppet::HTTP::ConnectionError => e
#     puts "Connection error #{e.message}"
#   rescue Puppet::SSL::SSLError => e
#     puts "SSL error #{e.message}"
#   rescue Puppet::HTTP::HTTPError => e
#     puts "General HTTP error #{e.message}"
#   end
#
# @example To route to the `:puppet` service:
#   session = client.create_session
#   service = session.route_to(:puppet)
#
# @example To make a node request:
#   node = service.get_node(Puppet[:certname], environment: 'production')
#
# @example To submit facts:
#   facts = Puppet::Indirection::Facts.indirection.find(Puppet[:certname])
#   service.put_facts(Puppet[:certname], environment: 'production', facts: facts)
#
# @example To submit a report to the `:report` service:
#   report = Puppet::Transaction::Report.new
#   service = session.route_to(:report)
#   service.put_report(Puppet[:certname], report, environment: 'production')
#
# @api public
class Puppet::HTTP::Client
  attr_reader :pool

  # Create a new http client instance. Use `Puppet.runtime[:http]` to get
  # the current client instead of creating an instance of this class.
  #
  # @param [Puppet::HTTP::Pool] pool pool of persistent Net::HTTP
  #   connections
  # @param [Puppet::SSL::SSLContext] ssl_context ssl context to be used for
  #   connections
  # @param [Puppet::SSL::SSLContext] system_ssl_context the system ssl context
  #   used if :include_system_store is set to true
  # @param [Integer] redirect_limit default number of HTTP redirections to allow
  #   in a given request. Can also be specified per-request.
  # @param [Integer] retry_limit number of HTTP retries allowed in a given
  #   request
  #
  def initialize(pool: Puppet::HTTP::Pool.new(Puppet[:http_keepalive_timeout]), ssl_context: nil, system_ssl_context: nil, redirect_limit: 10, retry_limit: 100)
    @pool = pool
    @default_headers = {
      'X-Puppet-Version' => Puppet.version,
      'User-Agent' => Puppet[:http_user_agent],
    }.freeze
    @default_ssl_context = ssl_context
    @default_system_ssl_context = system_ssl_context
    @default_redirect_limit = redirect_limit
    @retry_after_handler = Puppet::HTTP::RetryAfterHandler.new(retry_limit, Puppet[:runinterval])
  end

  # Create a new HTTP session. A session is the object through which services
  # may be connected to and accessed.
  #
  # @return [Puppet::HTTP::Session] the newly created HTTP session
  #
  # @api public
  def create_session
    Puppet::HTTP::Session.new(self, build_resolvers)
  end

  # Open a connection to the given URI. It is typically not necessary to call
  # this method as the client will create connections as needed when a request
  # is made.
  #
  # @param [URI] uri the connection destination
  # @param [Hash] options
  # @option options [Puppet::SSL::SSLContext] :ssl_context (nil) ssl context to
  #   be used for connections
  # @option options [Boolean] :include_system_store (false) if we should include
  #   the system store for connection
  def connect(uri, options: {}, &block)
    start = Time.now
    verifier = nil
    connected = false

    site = Puppet::HTTP::Site.from_uri(uri)
    if site.use_ssl?
      ssl_context = options.fetch(:ssl_context, nil)
      include_system_store = options.fetch(:include_system_store, false)
      ctx = resolve_ssl_context(ssl_context, include_system_store)
      verifier = Puppet::SSL::Verifier.new(site.host, ctx)
    end

    @pool.with_connection(site, verifier) do |http|
      connected = true
      if block_given?
        yield http
      end
    end
  rescue Net::OpenTimeout => e
    raise_error(_("Request to %{uri} timed out connect operation after %{elapsed} seconds") % { uri: uri, elapsed: elapsed(start) }, e, connected)
  rescue Net::ReadTimeout => e
    raise_error(_("Request to %{uri} timed out read operation after %{elapsed} seconds") % { uri: uri, elapsed: elapsed(start) }, e, connected)
  rescue EOFError => e
    raise_error(_("Request to %{uri} interrupted after %{elapsed} seconds") % { uri: uri, elapsed: elapsed(start) }, e, connected)
  rescue Puppet::SSL::SSLError
    raise
  rescue Puppet::HTTP::HTTPError
    raise
  rescue => e
    raise_error(_("Request to %{uri} failed after %{elapsed} seconds: %{message}") %
                { uri: uri, elapsed: elapsed(start), message: e.message }, e, connected)
  end

  # These options apply to all HTTP request methods
  #
  # @!macro [new] request_options
  #   @param [Hash] options HTTP request options. Options not recognized by the
  #     HTTP implementation will be ignored.
  #   @option options [Puppet::SSL::SSLContext] :ssl_context (nil) ssl context to
  #     be used for connections
  #   @option options [Boolean] :include_system_store (false) if we should include
  #     the system store for connection
  #   @option options [Integer] :redirect_limit (10) The maximum number of HTTP
  #     redirections to allow for this request.
  #   @option options [Hash] :basic_auth A map of `:username` => `String` and
  #     `:password` => `String`
  #   @option options [String] :metric_id The metric id used to track metrics
  #     on requests.

  # Submits a GET HTTP request to the given url
  #
  # @param [URI] url the location to submit the http request
  # @param [Hash] headers merged with the default headers defined by the client
  # @param [Hash] params encoded and set as the url query
  # @!macro request_options
  #
  # @yield [Puppet::HTTP::Response] if a block is given yields the response
  #
  # @return [Puppet::HTTP::Response] the response
  #
  # @api public
  def get(url, headers: {}, params: {}, options: {}, &block)
    url = encode_query(url, params)

    request = Net::HTTP::Get.new(url, @default_headers.merge(headers))

    execute_streaming(request, options: options, &block)
  end

  # Submits a HEAD HTTP request to the given url
  #
  # @param [URI] url the location to submit the http request
  # @param [Hash] headers merged with the default headers defined by the client
  # @param [Hash] params encoded and set as the url query
  # @!macro request_options
  #
  # @return [Puppet::HTTP::Response] the response
  #
  # @api public
  def head(url, headers: {}, params: {}, options: {})
    url = encode_query(url, params)

    request = Net::HTTP::Head.new(url, @default_headers.merge(headers))

    execute_streaming(request, options: options)
  end

  # Submits a PUT HTTP request to the given url
  #
  # @param [URI] url the location to submit the http request
  # @param [String] body the body of the PUT request
  # @param [Hash] headers merged with the default headers defined by the client. The
  #   `Content-Type` header is required and should correspond to the type of data passed
  #   as the `body` argument.
  # @param [Hash] params encoded and set as the url query
  # @!macro request_options
  #
  # @return [Puppet::HTTP::Response] the response
  #
  # @api public
  def put(url, body, headers: {}, params: {}, options: {})
    raise ArgumentError, "'put' requires a string 'body' argument" unless body.is_a?(String)

    url = encode_query(url, params)

    request = Net::HTTP::Put.new(url, @default_headers.merge(headers))
    request.body = body
    request.content_length = body.bytesize

    raise ArgumentError, "'put' requires a 'content-type' header" unless request['Content-Type']

    execute_streaming(request, options: options)
  end

  # Submits a POST HTTP request to the given url
  #
  # @param [URI] url the location to submit the http request
  # @param [String] body the body of the POST request
  # @param [Hash] headers merged with the default headers defined by the client. The
  #   `Content-Type` header is required and should correspond to the type of data passed
  #   as the `body` argument.
  # @param [Hash] params encoded and set as the url query
  # @!macro request_options
  #
  # @yield [Puppet::HTTP::Response] if a block is given yields the response
  #
  # @return [Puppet::HTTP::Response] the response
  #
  # @api public
  def post(url, body, headers: {}, params: {}, options: {}, &block)
    raise ArgumentError, "'post' requires a string 'body' argument" unless body.is_a?(String)

    url = encode_query(url, params)

    request = Net::HTTP::Post.new(url, @default_headers.merge(headers))
    request.body = body
    request.content_length = body.bytesize

    raise ArgumentError, "'post' requires a 'content-type' header" unless request['Content-Type']

    execute_streaming(request, options: options, &block)
  end

  # Submits a DELETE HTTP request to the given url.
  #
  # @param [URI] url the location to submit the http request
  # @param [Hash] headers merged with the default headers defined by the client
  # @param [Hash] params encoded and set as the url query
  # @!macro request_options
  #
  # @return [Puppet::HTTP::Response] the response
  #
  # @api public
  def delete(url, headers: {}, params: {}, options: {})
    url = encode_query(url, params)

    request = Net::HTTP::Delete.new(url, @default_headers.merge(headers))

    execute_streaming(request, options: options)
  end

  # Close persistent connections in the pool.
  #
  # @return [void]
  #
  # @api public
  def close
    @pool.close
    @default_ssl_context = nil
    @default_system_ssl_context = nil
  end

  def default_ssl_context
    cert = Puppet::X509::CertProvider.new
    password = cert.load_private_key_password

    ssl = Puppet::SSL::SSLProvider.new
    ctx = ssl.load_context(certname: Puppet[:certname], password: password)
    ssl.print(ctx)
    ctx
  rescue => e
    # TRANSLATORS: `message` is an already translated string of why SSL failed to initialize
    Puppet.log_exception(e, _("Failed to initialize SSL: %{message}") % { message: e.message })
    # TRANSLATORS: `puppet agent -t` is a command and should not be translated
    Puppet.err(_("Run `puppet agent -t`"))
    raise e
  end

  protected

  def encode_query(url, params)
    return url if params.empty?

    url = url.dup
    url.query = encode_params(params)
    url
  end

  private

  # Connect or borrow a connection from the pool to the host and port associated
  # with the request's URL. Then execute the HTTP request, retrying and
  # following redirects as needed, and return the HTTP response. The response
  # body will always be fully drained/consumed when this method returns.
  #
  # If a block is provided, then the response will be yielded to the caller,
  # allowing the response body to be streamed.
  #
  # If the request/response did not result in an exception and the caller did
  # not ask for the connection to be closed (via Connection: close), then the
  # connection will be returned to the pool.
  #
  # @yieldparam [Puppet::HTTP::Response] response The final response, after
  # following redirects and retrying
  # @return [Puppet::HTTP::Response]
  def execute_streaming(request, options: {}, &block)
    redirector = Puppet::HTTP::Redirector.new(options.fetch(:redirect_limit, @default_redirect_limit))

    basic_auth = options.fetch(:basic_auth, nil)
    unless basic_auth
      if request.uri.user && request.uri.password
        basic_auth = { user: request.uri.user, password: request.uri.password }
      end
    end

    redirects = 0
    retries = 0
    response = nil
    done = false

    until done
      connect(request.uri, options: options) do |http|
        apply_auth(request, basic_auth) if redirects.zero?

        # don't call return within the `request` block
        close_and_sleep = nil
        http.request(request) do |nethttp|
          response = Puppet::HTTP::ResponseNetHTTP.new(request.uri, nethttp)
          begin
            Puppet.debug("HTTP #{request.method.upcase} #{request.uri} returned #{response.code} #{response.reason}")

            if redirector.redirect?(request, response)
              request = redirector.redirect_to(request, response, redirects)
              redirects += 1
              next
            elsif @retry_after_handler.retry_after?(request, response)
              interval = @retry_after_handler.retry_after_interval(request, response, retries)
              retries += 1
              if interval
                close_and_sleep = proc do
                  if http.started?
                    Puppet.debug("Closing connection for #{Puppet::HTTP::Site.from_uri(request.uri)}")
                    http.finish
                  end
                  Puppet.warning(_("Sleeping for %{interval} seconds before retrying the request") % { interval: interval })
                  ::Kernel.sleep(interval)
                end
                next
              end
            end

            if block_given?
              yield response
            else
              response.body
            end
          ensure
            # we need to make sure the response body is fully consumed before
            # the connection is put back in the pool, otherwise the response
            # for one request could leak into a future response.
            response.drain
          end

          done = true
        end
      ensure
        # If a server responded with a retry, make sure the connection is closed and then
        # sleep the specified time.
        close_and_sleep.call if close_and_sleep
      end
    end

    response
  end

  def expand_into_parameters(data)
    data.inject([]) do |params, key_value|
      key, value = key_value

      expanded_value = case value
                       when Array
                         value.collect { |val| [key, val] }
                       else
                         [key_value]
                       end

      params.concat(expand_primitive_types_into_parameters(expanded_value))
    end
  end

  def expand_primitive_types_into_parameters(data)
    data.inject([]) do |params, key_value|
      key, value = key_value
      case value
      when nil
        params
      when true, false, String, Symbol, Integer, Float
        params << [key, value]
      else
        raise Puppet::HTTP::SerializationError, _("HTTP REST queries cannot handle values of type '%{klass}'") % { klass: value.class }
      end
    end
  end

  def encode_params(params)
    params = expand_into_parameters(params)
    params.map do |key, value|
      "#{key}=#{Puppet::Util.uri_query_encode(value.to_s)}"
    end.join('&')
  end

  def elapsed(start)
    (Time.now - start).to_f.round(3)
  end

  def raise_error(message, cause, connected)
    if connected
      raise Puppet::HTTP::HTTPError.new(message, cause)
    else
      raise Puppet::HTTP::ConnectionError.new(message, cause)
    end
  end

  def resolve_ssl_context(ssl_context, include_system_store)
    if ssl_context
      raise Puppet::HTTP::HTTPError, "The ssl_context and include_system_store parameters are mutually exclusive" if include_system_store

      ssl_context
    elsif include_system_store
      system_ssl_context
    else
      @default_ssl_context || Puppet.lookup(:ssl_context)
    end
  end

  def system_ssl_context
    return @default_system_ssl_context if @default_system_ssl_context

    cert_provider = Puppet::X509::CertProvider.new
    cacerts = cert_provider.load_cacerts || []

    ssl = Puppet::SSL::SSLProvider.new
    @default_system_ssl_context = ssl.create_system_context(cacerts: cacerts, include_client_cert: true)
    ssl.print(@default_system_ssl_context)
    @default_system_ssl_context
  end

  def apply_auth(request, basic_auth)
    if basic_auth
      request.basic_auth(basic_auth[:user], basic_auth[:password])
    end
  end

  def build_resolvers
    resolvers = []

    if Puppet[:use_srv_records]
      resolvers << Puppet::HTTP::Resolver::SRV.new(self, domain: Puppet[:srv_domain])
    end

    server_list_setting = Puppet.settings.setting(:server_list)
    if server_list_setting.value && !server_list_setting.value.empty?
      # use server list to resolve all services
      services = Puppet::HTTP::Service::SERVICE_NAMES.dup

      # except if it's been explicitly set
      if Puppet.settings.set_by_config?(:ca_server)
        services.delete(:ca)
      end

      if Puppet.settings.set_by_config?(:report_server)
        services.delete(:report)
      end

      resolvers << Puppet::HTTP::Resolver::ServerList.new(self, server_list_setting: server_list_setting, default_port: Puppet[:serverport], services: services)
    end

    resolvers << Puppet::HTTP::Resolver::Settings.new(self)

    resolvers.freeze
  end
end
