# frozen_string_literal: true

require_relative '../../../puppet/http'

# This will be raised if too many redirects happen for a given HTTP request
class Puppet::Network::HTTP::RedirectionLimitExceededException < Puppet::Error; end

# This class provides simple methods for issuing various types of HTTP
# requests.  It's interface is intended to mirror Ruby's Net::HTTP
# object, but it provides a few important bits of additional
# functionality.  Notably:
#
# * Any HTTPS requests made using this class will use Puppet's SSL
#   certificate configuration for their authentication, and
# * Provides some useful error handling for any SSL errors that occur
#   during a request.
#
# @deprecated Use {Puppet.runtime[:http]}
# @api public
class Puppet::Network::HTTP::Connection
  include Puppet::HTTP::ResponseConverter

  OPTION_DEFAULTS = {
    :use_ssl => true,
    :verifier => nil,
    :redirect_limit => 10,
  }

  # Creates a new HTTP client connection to `host`:`port`.
  # @param host [String] the host to which this client will connect to
  # @param port [Integer] the port to which this client will connect to
  # @param options [Hash] options influencing the properties of the created
  #   connection,
  # @option options [Boolean] :use_ssl true to connect with SSL, false
  #   otherwise, defaults to true
  # @option options [Puppet::SSL::Verifier] :verifier An object that will configure
  #   any verification to do on the connection
  # @option options [Integer] :redirect_limit the number of allowed
  #   redirections, defaults to 10 passing any other option in the options
  #   hash results in a Puppet::Error exception
  #
  # @note the HTTP connection itself happens lazily only when {#request}, or
  #   one of the {#get}, {#post}, {#delete}, {#head} or {#put} is called
  # @note The correct way to obtain a connection is to use one of the factory
  #   methods on {Puppet::Network::HttpPool}
  # @api private
  def initialize(host, port, options = {})
    unknown_options = options.keys - OPTION_DEFAULTS.keys
    raise Puppet::Error, _("Unrecognized option(s): %{opts}") % { opts: unknown_options.map(&:inspect).sort.join(', ') } unless unknown_options.empty?

    options = OPTION_DEFAULTS.merge(options)
    @use_ssl = options[:use_ssl]
    if @use_ssl
      unless options[:verifier].is_a?(Puppet::SSL::Verifier)
        raise ArgumentError, _("Expected an instance of Puppet::SSL::Verifier but was passed a %{klass}") % { klass: options[:verifier].class }
      end

      @verifier = options[:verifier]
    end
    @redirect_limit = options[:redirect_limit]
    @site = Puppet::HTTP::Site.new(@use_ssl ? 'https' : 'http', host, port)
    @client = Puppet.runtime[:http]
  end

  # The address to connect to.
  def address
    @site.host
  end

  # The port to connect to.
  def port
    @site.port
  end

  # Whether to use ssl
  def use_ssl?
    @site.use_ssl?
  end

  # @api private
  def verifier
    @verifier
  end

  # @!macro [new] common_options
  #   @param options [Hash] options influencing the request made. Any
  #   options not recognized by this class will be ignored - no error will
  #   be thrown.
  #   @option options [Hash{Symbol => String}] :basic_auth The basic auth
  #     :username and :password to use for the request, :metric_id Ignored
  #     by this class - used by Puppet Server only. The metric id by which
  #     to track metrics on requests.

  # @param path [String]
  # @param headers [Hash{String => String}]
  # @!macro common_options
  # @api public
  def get(path, headers = {}, options = {})
    headers ||= {}
    options[:ssl_context] ||= resolve_ssl_context
    options[:redirect_limit] ||= @redirect_limit

    with_error_handling do
      to_ruby_response(@client.get(to_url(path), headers: headers, options: options))
    end
  end

  # @param path [String]
  # @param data [String]
  # @param headers [Hash{String => String}]
  # @!macro common_options
  # @api public
  def post(path, data, headers = nil, options = {})
    headers ||= {}
    headers['Content-Type'] ||= "application/x-www-form-urlencoded"
    data ||= ''
    options[:ssl_context] ||= resolve_ssl_context
    options[:redirect_limit] ||= @redirect_limit

    with_error_handling do
      to_ruby_response(@client.post(to_url(path), data, headers: headers, options: options))
    end
  end

  # @param path [String]
  # @param headers [Hash{String => String}]
  # @!macro common_options
  # @api public
  def head(path, headers = {}, options = {})
    headers ||= {}
    options[:ssl_context] ||= resolve_ssl_context
    options[:redirect_limit] ||= @redirect_limit

    with_error_handling do
      to_ruby_response(@client.head(to_url(path), headers: headers, options: options))
    end
  end

  # @param path [String]
  # @param headers [Hash{String => String}]
  # @!macro common_options
  # @api public
  def delete(path, headers = { 'Depth' => 'Infinity' }, options = {})
    headers ||= {}
    options[:ssl_context] ||= resolve_ssl_context
    options[:redirect_limit] ||= @redirect_limit

    with_error_handling do
      to_ruby_response(@client.delete(to_url(path), headers: headers, options: options))
    end
  end

  # @param path [String]
  # @param data [String]
  # @param headers [Hash{String => String}]
  # @!macro common_options
  # @api public
  def put(path, data, headers = nil, options = {})
    headers ||= {}
    headers['Content-Type'] ||= "application/x-www-form-urlencoded"
    data ||= ''
    options[:ssl_context] ||= resolve_ssl_context
    options[:redirect_limit] ||= @redirect_limit

    with_error_handling do
      to_ruby_response(@client.put(to_url(path), data, headers: headers, options: options))
    end
  end

  def request_get(*args, &block)
    path, headers = *args
    headers ||= {}
    options = {
      ssl_context: resolve_ssl_context,
      redirect_limit: @redirect_limit
    }

    ruby_response = nil
    @client.get(to_url(path), headers: headers, options: options) do |response|
      ruby_response = to_ruby_response(response)
      yield ruby_response if block_given?
    end
    ruby_response
  end

  def request_head(*args, &block)
    path, headers = *args
    headers ||= {}
    options = {
      ssl_context: resolve_ssl_context,
      redirect_limit: @redirect_limit
    }

    response = @client.head(to_url(path), headers: headers, options: options)
    ruby_response = to_ruby_response(response)
    yield ruby_response if block_given?
    ruby_response
  end

  def request_post(*args, &block)
    path, data, headers = *args
    headers ||= {}
    headers['Content-Type'] ||= "application/x-www-form-urlencoded"
    options = {
      ssl_context: resolve_ssl_context,
      redirect_limit: @redirect_limit
    }

    ruby_response = nil
    @client.post(to_url(path), data, headers: headers, options: options) do |response|
      ruby_response = to_ruby_response(response)
      yield ruby_response if block_given?
    end
    ruby_response
  end

  private

  # Resolve the ssl_context based on the verifier associated with this
  # connection or load the available set of certs and key on disk.
  # Don't try to bootstrap the agent, as we only want that to be triggered
  # when running `puppet ssl` or `puppet agent`.
  def resolve_ssl_context
    # don't need an ssl context for http connections
    return nil unless @site.use_ssl?

    # if our verifier has an ssl_context, use that
    ctx = @verifier.ssl_context
    return ctx if ctx

    # load available certs
    cert = Puppet::X509::CertProvider.new
    ssl = Puppet::SSL::SSLProvider.new
    begin
      password = cert.load_private_key_password
      ssl.load_context(certname: Puppet[:certname], password: password)
    rescue Puppet::SSL::SSLError => e
      Puppet.log_exception(e)

      # if we don't have cacerts, then create a root context that doesn't
      # trust anything. The old code used to fallback to VERIFY_NONE,
      # which we don't want to emulate.
      ssl.create_root_context(cacerts: [])
    end
  end

  def to_url(path)
    if path =~ %r{^https?://}
      # The old Connection class accepts a URL as the request path, and sends
      # it in "absolute-form" in the request line, e.g. GET https://puppet:8140/.
      # See https://httpwg.org/specs/rfc7230.html#absolute-form. It just so happens
      # to work because HTTP 1.1 servers are required to accept absolute-form even
      # though clients are only supposed to send them to proxies, so the proxy knows
      # what upstream server to CONNECT to. This method creates a URL using the
      # scheme/host/port that the connection was created with, and appends the path
      # and query portions of the absolute-form. The resulting request will use "origin-form"
      # as it should have done all along.
      abs_form = URI(path)
      url = URI("#{@site.addr}/#{normalize_path(abs_form.path)}")
      url.query = abs_form.query if abs_form.query
      url
    else
      URI("#{@site.addr}/#{normalize_path(path)}")
    end
  end

  def normalize_path(path)
    if path[0] == '/'
      path[1..]
    else
      path
    end
  end

  def with_error_handling(&block)
    yield
  rescue Puppet::HTTP::TooManyRedirects => e
    raise Puppet::Network::HTTP::RedirectionLimitExceededException.new(_("Too many HTTP redirections for %{host}:%{port}") % { host: @host, port: @port }, e)
  rescue Puppet::HTTP::HTTPError => e
    Puppet.log_exception(e, e.message)
    case e.cause
    when Net::OpenTimeout, Net::ReadTimeout, Net::HTTPError, EOFError
      raise e.cause
    else
      raise e
    end
  end
end
