# frozen_string_literal: true

require_relative '../../../../puppet/network/http/api/indirection_type'

class Puppet::Network::HTTP::API::IndirectedRoutes
  # How we map http methods and the indirection name in the URI
  # to an indirection method.
  METHOD_MAP = {
    "GET" => {
      :plural => :search,
      :singular => :find
    },
    "POST" => {
      :singular => :find,
    },
    "PUT" => {
      :singular => :save
    },
    "DELETE" => {
      :singular => :destroy
    },
    "HEAD" => {
      :singular => :head
    }
  }

  IndirectionType = Puppet::Network::HTTP::API::IndirectionType

  def self.routes
    Puppet::Network::HTTP::Route.path(/.*/).any(new)
  end

  # Handle an HTTP request. The request has already been authenticated prior
  # to calling this method.
  def call(request, response)
    indirection, method, key, params = uri2indirection(request.method, request.path, request.params)
    certificate = request.client_cert

    unless indirection.allow_remote_requests?
      # TODO: should we tell the user we found an indirection but it doesn't
      # allow remote requests, or just pretend there's no handler at all? what
      # are the security implications for the former?
      raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new(_("No handler for %{indirection}") % { indirection: indirection.name }, :NO_INDIRECTION_REMOTE_REQUESTS)
    end

    overrides = {
      trusted_information: Puppet::Context::TrustedInformation.remote(params[:authenticated], params[:node], certificate),
    }
    if params[:environment]
      overrides[:current_environment] = params[:environment]
    end

    Puppet.override(overrides) do
      send("do_#{method}", indirection, key, params, request, response)
    end
  end

  def uri2indirection(http_method, uri, params)
    # the first field is always nil because of the leading slash,
    indirection_type, version, indirection_name, key = uri.split("/", 5)[1..]
    url_prefix = "/#{indirection_type}/#{version}"
    environment = params.delete(:environment)

    if indirection_name !~ /^\w+$/
      raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("The indirection name must be purely alphanumeric, not '%{indirection_name}'") % { indirection_name: indirection_name }
    end

    # this also depluralizes the indirection_name if it is a search
    method = indirection_method(http_method, indirection_name)

    # check whether this indirection matches the prefix and version in the
    # request
    if url_prefix != IndirectionType.url_prefix_for(indirection_name)
      raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("Indirection '%{indirection_name}' does not match url prefix '%{url_prefix}'") % { indirection_name: indirection_name, url_prefix: url_prefix }
    end

    indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym)
    unless indirection
      raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new(
        _("Could not find indirection '%{indirection_name}'") % { indirection_name: indirection_name },
        Puppet::Network::HTTP::Issues::HANDLER_NOT_FOUND
      )
    end

    unless environment
      raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("An environment parameter must be specified")
    end

    unless Puppet::Node::Environment.valid_name?(environment)
      raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("The environment must be purely alphanumeric, not '%{environment}'") % { environment: environment }
    end

    configured_environment = Puppet.lookup(:environments).get(environment)
    unless configured_environment.nil?
      configured_environment = configured_environment.override_from_commandline(Puppet.settings)
      params[:environment] = configured_environment
    end

    if configured_environment.nil? && indirection.terminus.require_environment?
      raise Puppet::Network::HTTP::Error::HTTPNotFoundError, _("Could not find environment '%{environment}'") % { environment: environment }
    end

    params.delete(:bucket_path)

    if key == "" or key.nil?
      raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("No request key specified in %{uri}") % { uri: uri }
    end

    [indirection, method, key, params]
  end

  private

  # Execute our find.
  def do_find(indirection, key, params, request, response)
    result = indirection.find(key, params)
    unless result
      raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new(_("Could not find %{value0} %{key}") % { value0: indirection.name, key: key }, Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND)
    end

    rendered_result = result

    rendered_format = first_response_formatter_for(indirection.model, request, key) do |format|
      if result.respond_to?(:render)
        Puppet::Util::Profiler.profile(_("Rendered result in %{format}") % { format: format }, [:http, :v3_render, format]) do
          rendered_result = result.render(format)
        end
      end
    end

    Puppet::Util::Profiler.profile(_("Sent response"), [:http, :v3_response]) do
      response.respond_with(200, rendered_format, rendered_result)
    end
  end

  # Execute our head.
  def do_head(indirection, key, params, request, response)
    unless indirection.head(key, params)
      raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new(_("Could not find %{indirection} %{key}") % { indirection: indirection.name, key: key }, Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND)
    end

    # No need to set a response because no response is expected from a
    # HEAD request.  All we need to do is not die.
  end

  # Execute our search.
  def do_search(indirection, key, params, request, response)
    result = indirection.search(key, params)

    if result.nil?
      raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new(_("Could not find instances in %{indirection} with '%{key}'") % { indirection: indirection.name, key: key }, Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND)
    end

    rendered_result = nil

    rendered_format = first_response_formatter_for(indirection.model, request, key) do |format|
      rendered_result = indirection.model.render_multiple(format, result)
    end

    response.respond_with(200, rendered_format, rendered_result)
  end

  # Execute our destroy.
  def do_destroy(indirection, key, params, request, response)
    formatter = accepted_response_formatter_or_json_for(indirection.model, request)

    result = indirection.destroy(key, params)

    response.respond_with(200, formatter, formatter.render(result))
  end

  # Execute our save.
  def do_save(indirection, key, params, request, response)
    formatter = accepted_response_formatter_or_json_for(indirection.model, request)
    sent_object = read_body_into_model(indirection.model, request)

    result = indirection.save(sent_object, key)

    response.respond_with(200, formatter, formatter.render(result))
  end

  # Return the first response formatter that didn't cause the yielded
  # block to raise a FormatError.
  def first_response_formatter_for(model, request, key, &block)
    formats = accepted_response_formatters_for(model, request)
    formatter = formats.find do |format|
      yield format
      true
    rescue Puppet::Network::FormatHandler::FormatError => err
      msg = _("Failed to serialize %{model} for '%{key}': %{detail}") %
            { model: model, key: key, detail: err }
      if Puppet[:allow_pson_serialization]
        Puppet.warning(msg)
      else
        raise Puppet::Network::FormatHandler::FormatError, msg
      end
      false
    end

    return formatter if formatter

    raise Puppet::Network::HTTP::Error::HTTPNotAcceptableError.new(
      _("No supported formats are acceptable (Accept: %{accepted_formats})") % { accepted_formats: formats.map(&:mime).join(', ') },
      Puppet::Network::HTTP::Issues::UNSUPPORTED_FORMAT
    )
  end

  # Return an array of response formatters that the client accepts and
  # the server supports.
  def accepted_response_formatters_for(model_class, request)
    request.response_formatters_for(model_class.supported_formats)
  end

  # Return the first response formatter that the client accepts and
  # the server supports, or default to 'application/json'.
  def accepted_response_formatter_or_json_for(model_class, request)
    request.response_formatters_for(model_class.supported_formats, "application/json").first
  end

  def read_body_into_model(model_class, request)
    data = request.body.to_s
    formatter = request.formatter

    if formatter.supported?(model_class)
      begin
        return model_class.convert_from(formatter.name.to_s, data)
      rescue => e
        raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("The request body is invalid: %{message}") % { message: e.message }
      end
    end

    # TRANSLATORS "mime-type" is a keyword and should not be translated
    raise Puppet::Network::HTTP::Error::HTTPUnsupportedMediaTypeError.new(
      _("Client sent a mime-type (%{header}) that doesn't correspond to a format we support") % { header: request.headers['content-type'] },
      Puppet::Network::HTTP::Issues::UNSUPPORTED_MEDIA_TYPE
    )
  end

  def indirection_method(http_method, indirection)
    raise Puppet::Network::HTTP::Error::HTTPMethodNotAllowedError, _("No support for http method %{http_method}") % { http_method: http_method } unless METHOD_MAP[http_method]

    method = METHOD_MAP[http_method][plurality(indirection)]
    unless method
      raise Puppet::Network::HTTP::Error::HTTPBadRequestError, _("No support for plurality %{indirection} for %{http_method} operations") % { indirection: plurality(indirection), http_method: http_method }
    end

    method
  end

  def self.pluralize(indirection)
    (indirection == "status" ? "statuses" : indirection + "s")
  end
  private_class_method :pluralize

  def plurality(indirection)
    # NOTE These specific hooks for paths are ridiculous, but it's a *many*-line
    # fix to not need this, and our goal is to move away from the complication
    # that leads to the fix being too long.
    return :singular if indirection == "facts"
    return :singular if indirection == "status"
    return :singular if indirection == "certificate_status"

    result = (indirection =~ /s$|_search$/) ? :plural : :singular

    indirection.sub!(/s$|_search$/, '')
    indirection.sub!(/statuse$/, 'status')

    result
  end
end
