defmodule HTTPoison.Base do
  @moduledoc """
  Provides a default implementation for HTTPoison functions.

  This module is meant to be `use`'d in custom modules in order to wrap the
  functionalities provided by HTTPoison. For example, this is very useful to
  build API clients around HTTPoison:

      defmodule GitHub do
        use HTTPoison.Base

        @endpoint "https://api.github.com"

        def process_request_url(url) do
          @endpoint <> url
        end
      end

  The example above shows how the `GitHub` module can wrap HTTPoison
  functionalities to work with the GitHub API in particular; this way, for
  example, all requests done through the `GitHub` module will be done to the
  GitHub API:

      GitHub.get("/users/octocat/orgs")
      #=> will issue a GET request at https://api.github.com/users/octocat/orgs

  ## Overriding functions

  `HTTPoison.Base` defines the following list of functions, all of which can be
  overridden (by redefining them). The following list also shows the typespecs
  for these functions and a short description.

      # Called in order to process the url passed to any request method before
      # actually issuing the request.
      @spec process_request_url(binary) :: binary
      def process_request_url(url)

      # Called to arbitrarily process the request body before sending it with the
      # request.
      @spec process_request_body(term) :: binary
      def process_request_body(body)

      # Called to arbitrarily process the request headers before sending them
      # with the request.
      @spec process_request_headers(term) :: [{binary, term}]
      def process_request_headers(headers)

      # Called to arbitrarily process the request options before sending them
      # with the request.
      @spec process_request_options(keyword) :: keyword
      def process_request_options(options)

      # Called before returning the response body returned by a request to the
      # caller.
      @spec process_response_body(binary) :: term
      def process_response_body(body)

      # Used when an async request is made; it's called on each chunk that gets
      # streamed before returning it to the streaming destination.
      @spec process_response_chunk(binary) :: term
      def process_response_chunk(chunk)

      # Called to process the response headers before returning them to the
      # caller.
      @spec process_headers([{binary, term}]) :: term
      def process_headers(headers)

      # Used to arbitrarily process the status code of a response before
      # returning it to the caller.
      @spec process_response_status_code(integer) :: term
      def process_response_status_code(status_code)

  """

  alias HTTPoison.Request
  alias HTTPoison.Response
  alias HTTPoison.AsyncResponse
  alias HTTPoison.MaybeRedirect
  alias HTTPoison.Error

  @callback delete(url) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback delete(url, headers) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback delete(url, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}

  @callback delete!(url) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback delete!(url, headers) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback delete!(url, headers, options) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()

  @callback get(url) :: {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
  @callback get(url, headers) :: {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
  @callback get(url, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}

  @callback get!(url) :: Response.t() | AsyncResponse.t()
  @callback get!(url, headers) :: Response.t() | AsyncResponse.t()
  @callback get!(url, headers, options) :: Response.t() | AsyncResponse.t()

  @callback head(url) :: {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
  @callback head(url, headers) :: {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
  @callback head(url, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}

  @callback head!(url) :: Response.t() | AsyncResponse.t()
  @callback head!(url, headers) :: Response.t() | AsyncResponse.t()
  @callback head!(url, headers, options) :: Response.t() | AsyncResponse.t()

  @callback options(url) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback options(url, headers) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback options(url, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}

  @callback options!(url) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback options!(url, headers) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback options!(url, headers, options) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()

  @callback patch(url, body) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback patch(url, body, headers) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback patch(url, body, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}

  @callback patch!(url, body) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback patch!(url, body, headers) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback patch!(url, body, headers, options) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()

  @callback post(url, body) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback post(url, body, headers) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback post(url, body, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}

  @callback post!(url, body) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback post!(url, body, headers) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback post!(url, body, headers, options) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()

  # deprecated: Use process_request_headers/1 instead
  @callback process_headers(list) :: term

  @callback process_request_body(body) :: body

  @callback process_request_headers(headers) :: headers

  @callback process_request_options(options) :: options

  @callback process_request_url(url) :: url

  @callback process_request_params(params) :: params

  @callback process_response(response) :: term

  @callback process_response_body(binary) :: term

  @callback process_response_chunk(binary) :: term

  @callback process_response_headers(list) :: term

  @callback process_response_status_code(integer) :: term

  # deprecated: Use process_response_status_code/1 instead
  @callback process_status_code(integer) :: term

  # deprecated: Use process_request_url/1 instead
  @callback process_url(url) :: url

  @callback put(url) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback put(url, body) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback put(url, body, headers) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback put(url, body, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}

  @callback put!(url) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback put!(url, body) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback put!(url, body, headers) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback put!(url, body, headers, options) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()

  @callback request(Request.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback request(method, url) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback request(method, url, body) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback request(method, url, body, headers) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  @callback request(method, url, body, headers, options) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}

  @callback request!(method, url) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback request!(method, url, body) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback request!(method, url, body, headers) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
  @callback request!(method, url, body, headers, options) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()

  @callback start() :: {:ok, [atom]} | {:error, term}

  @callback stream_next(AsyncResponse.t()) :: {:ok, AsyncResponse.t()} | {:error, Error.t()}

  @type response :: Response.t()
  @type request :: Request.t()
  @type method :: Request.method()
  @type url :: Request.url()
  @type headers :: Request.headers()
  @type body :: Request.body()
  @type options :: Request.options()
  @type params :: Request.params()

  defmacro __using__(_) do
    quote location: :keep do
      @behaviour HTTPoison.Base

      @type request :: HTTPoison.Base.request()
      @type method :: HTTPoison.Base.method()
      @type url :: HTTPoison.Base.url()
      @type headers :: HTTPoison.Base.headers()
      @type body :: HTTPoison.Base.body()
      @type options :: HTTPoison.Base.options()
      @type params :: HTTPoison.Base.params()

      @doc """
      Starts HTTPoison and its dependencies.
      """
      def start, do: :application.ensure_all_started(:httpoison)

      @deprecated "Use process_request_url/1 instead"
      @spec process_url(url) :: url
      def process_url(url) do
        HTTPoison.Base.default_process_request_url(url)
      end

      @spec process_request_url(url) :: url
      def process_request_url(url), do: process_url(url)

      @spec process_request_body(body) :: body
      def process_request_body(body), do: body

      @spec process_request_headers(headers) :: headers
      def process_request_headers(headers) when is_map(headers) do
        Enum.into(headers, [])
      end

      def process_request_headers(headers), do: headers

      @spec process_request_options(options) :: options
      def process_request_options(options), do: options

      @spec process_request_params(params) :: params
      def process_request_params(params), do: params

      @spec process_response(HTTPoison.Base.response()) :: any
      def process_response(%Response{} = response), do: response

      @deprecated "Use process_response_headers/1 instead"
      @spec process_headers(list) :: any
      def process_headers(headers), do: headers

      @spec process_response_headers(list) :: any
      def process_response_headers(headers), do: process_headers(headers)

      @deprecated "Use process_response_status_code/1 instead"
      @spec process_status_code(integer) :: any
      def process_status_code(status_code), do: status_code

      @spec process_response_status_code(integer) :: any
      def process_response_status_code(status_code), do: process_status_code(status_code)

      @spec process_response_body(binary) :: any
      def process_response_body(body), do: body

      @spec process_response_chunk(binary) :: any
      def process_response_chunk(chunk), do: chunk

      @doc false
      @spec transformer(pid) :: :ok
      def transformer(target) do
        # Track the target process so we can exit when it dies
        Process.monitor(target)

        HTTPoison.Base.transformer(
          __MODULE__,
          target,
          &process_response_status_code/1,
          &process_response_headers/1,
          &process_response_chunk/1
        )
      end

      @doc ~S"""
      Issues an HTTP request using an `HTTPoison.Request` struct.

      This function returns `{:ok, response}`, `{:ok, async_response}`, or `{:ok, maybe_redirect}`
      if the request is successful, `{:error, reason}` otherwise.

      ## Redirect handling

      If the option `:follow_redirect` is given, HTTP redirects are automatically follow if
      the method is set to `:get` or `:head` and the response's `status_code` is `301`, `302` or
      `307`.

      If the method is set to `:post`, then the only `status_code` that get's automatically
      followed is `303`.

      If any other method or `status_code` is returned, then this function returns a
      returns a `{:ok, %HTTPoison.MaybeRedirect{}}` containing the `redirect_url` for you to
      re-request with the method set to `:get`.

      ## Examples

          request = %HTTPoison.Request{
            method: :post,
            url: "https://my.website.com",
            body: "{\"foo\": 3}",
            headers: [{"Accept", "application/json"}]
          }

          request(request)

      """
      @spec request(Request.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def request(%Request{} = request) do
        options = process_request_options(request.options)

        params =
          request.params
          |> HTTPoison.Base.merge_params(options[:params])
          |> process_request_params()

        url =
          request.url
          |> to_string()
          |> process_request_url()
          |> HTTPoison.Base.build_request_url(params)

        body =
          request.body
          |> process_request_body()
          |> HTTPoison.Base.maybe_process_form()

        request = %Request{
          method: request.method,
          url: url,
          headers: process_request_headers(request.headers),
          body: body,
          params: params,
          options: options
        }

        HTTPoison.Base.request(
          __MODULE__,
          request,
          &process_response_status_code/1,
          &process_response_headers/1,
          &process_response_body/1,
          &process_response/1
        )
      end

      @doc ~S"""
      Issues an HTTP request with the given method to the given url.

      This function is usually used indirectly by `get/3`, `post/4`, `put/4`, etc

      Args:
        * `method` - HTTP method as an atom (`:get`, `:head`, `:post`, `:put`,
          `:delete`, etc.)
        * `url` - target url as a binary string or char list
        * `body` - request body. See more below
        * `headers` - HTTP headers as an orddict (e.g., `[{"Accept", "application/json"}]`)
        * `options` - Keyword list of options

      Body: see type `HTTPoison.Request`

      Options: see type `HTTPoison.Request`

      This function returns `{:ok, response}`, `{:ok, async_response}`, or `{:ok, maybe_redirect}`
      if the request is successful, `{:error, reason}` otherwise.

      ## Redirect handling

      If the option `:follow_redirect` is given, HTTP redirects are automatically follow if
      the method is set to `:get` or `:head` and the response's `status_code` is `301`, `302` or
      `307`.

      If the method is set to `:post`, then the only `status_code` that get's automatically
      followed is `303`.

      If any other method or `status_code` is returned, then this function returns a
      returns a `{:ok, %HTTPoison.MaybeRedirect{}}` containing the `redirect_url` for you to
      re-request with the method set to `:get`.

      ## Examples

          request(:post, "https://my.website.com", "{\"foo\": 3}", [{"Accept", "application/json"}])

      """
      @spec request(method, binary, any, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def request(method, url, body \\ "", headers \\ [], options \\ []) do
        request(%Request{
          method: method,
          url: url,
          headers: headers,
          body: body,
          options: options
        })
      end

      @doc """
      Issues an HTTP request an `HTTPoison.Request` struct.
      exception in case of failure.

      `request!/1` works exactly like `request/1` but it returns just the
      response in case of a successful request, raising an exception in case the
      request fails.
      """
      @spec request!(Request.t()) :: Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def request!(%Request{} = request) do
        case request(request) do
          {:ok, response} -> response
          {:error, %Error{reason: reason}} -> raise Error, reason: reason
        end
      end

      @doc """
      Issues an HTTP request with the given method to the given url, raising an
      exception in case of failure.

      `request!/5` works exactly like `request/5` but it returns just the
      response in case of a successful request, raising an exception in case the
      request fails.
      """
      @spec request!(method, binary, any, headers, Keyword.t()) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def request!(method, url, body \\ "", headers \\ [], options \\ []) do
        case request(method, url, body, headers, options) do
          {:ok, response} -> response
          {:error, %Error{reason: reason}} -> raise Error, reason: reason
        end
      end

      @doc """
      Issues a GET request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec get(binary, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
      def get(url, headers \\ [], options \\ []), do: request(:get, url, "", headers, options)

      @doc """
      Issues a GET request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec get!(binary, headers, Keyword.t()) :: Response.t() | AsyncResponse.t()
      def get!(url, headers \\ [], options \\ []), do: request!(:get, url, "", headers, options)

      @doc """
      Issues a PUT request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec put(binary, any, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def put(url, body \\ "", headers \\ [], options \\ []),
        do: request(:put, url, body, headers, options)

      @doc """
      Issues a PUT request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec put!(binary, any, headers, Keyword.t()) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def put!(url, body \\ "", headers \\ [], options \\ []),
        do: request!(:put, url, body, headers, options)

      @doc """
      Issues a HEAD request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec head(binary, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t()} | {:error, Error.t()}
      def head(url, headers \\ [], options \\ []), do: request(:head, url, "", headers, options)

      @doc """
      Issues a HEAD request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec head!(binary, headers, Keyword.t()) :: Response.t() | AsyncResponse.t()
      def head!(url, headers \\ [], options \\ []), do: request!(:head, url, "", headers, options)

      @doc """
      Issues a POST request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec post(binary, any, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def post(url, body, headers \\ [], options \\ []),
        do: request(:post, url, body, headers, options)

      @doc """
      Issues a POST request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec post!(binary, any, headers, Keyword.t()) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def post!(url, body, headers \\ [], options \\ []),
        do: request!(:post, url, body, headers, options)

      @doc """
      Issues a PATCH request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec patch(binary, any, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def patch(url, body, headers \\ [], options \\ []),
        do: request(:patch, url, body, headers, options)

      @doc """
      Issues a PATCH request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec patch!(binary, any, headers, Keyword.t()) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def patch!(url, body, headers \\ [], options \\ []),
        do: request!(:patch, url, body, headers, options)

      @doc """
      Issues a DELETE request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec delete(binary, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def delete(url, headers \\ [], options \\ []),
        do: request(:delete, url, "", headers, options)

      @doc """
      Issues a DELETE request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec delete!(binary, headers, Keyword.t()) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def delete!(url, headers \\ [], options \\ []),
        do: request!(:delete, url, "", headers, options)

      @doc """
      Issues an OPTIONS request to the given url.

      Returns `{:ok, response}` if the request is successful, `{:error, reason}`
      otherwise.

      See `request/5` for more detailed information.
      """
      @spec options(binary, headers, Keyword.t()) ::
              {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
      def options(url, headers \\ [], options \\ []),
        do: request(:options, url, "", headers, options)

      @doc """
      Issues a OPTIONS request to the given url, raising an exception in case of
      failure.

      If the request does not fail, the response is returned.

      See `request!/5` for more detailed information.
      """
      @spec options!(binary, headers, Keyword.t()) ::
              Response.t() | AsyncResponse.t() | MaybeRedirect.t()
      def options!(url, headers \\ [], options \\ []),
        do: request!(:options, url, "", headers, options)

      @doc """
      Requests the next message to be streamed for a given `HTTPoison.AsyncResponse`.

      See `request!/5` for more detailed information.
      """
      @spec stream_next(AsyncResponse.t()) :: {:ok, AsyncResponse.t()} | {:error, Error.t()}
      def stream_next(resp = %AsyncResponse{id: id}) do
        case :hackney.stream_next(id) do
          :ok -> {:ok, resp}
          err -> {:error, %Error{reason: "stream_next/1 failed", id: id}}
        end
      end

      defoverridable Module.definitions_in(__MODULE__)
    end
  end

  @doc false
  def transformer(
        module,
        target,
        process_response_status_code,
        process_response_headers,
        process_response_chunk
      ) do
    receive do
      {:hackney_response, id, {:status, code, _reason}} ->
        send(target, %HTTPoison.AsyncStatus{id: id, code: process_response_status_code.(code)})

        transformer(
          module,
          target,
          process_response_status_code,
          process_response_headers,
          process_response_chunk
        )

      {:hackney_response, id, {:headers, headers}} ->
        send(target, %HTTPoison.AsyncHeaders{id: id, headers: process_response_headers.(headers)})

        transformer(
          module,
          target,
          process_response_status_code,
          process_response_headers,
          process_response_chunk
        )

      {:hackney_response, id, :done} ->
        send(target, %HTTPoison.AsyncEnd{id: id})

      {:hackney_response, id, {:error, reason}} ->
        send(target, %Error{id: id, reason: reason})

      {:hackney_response, id, {redirect, to, headers}} when redirect in [:redirect, :see_other] ->
        send(target, %HTTPoison.AsyncRedirect{
          id: id,
          to: to,
          headers: process_response_headers.(headers)
        })

      {:hackney_response, id, chunk} ->
        send(target, %HTTPoison.AsyncChunk{id: id, chunk: process_response_chunk.(chunk)})

        transformer(
          module,
          target,
          process_response_status_code,
          process_response_headers,
          process_response_chunk
        )

      # Exit if the target process dies as this will be a zombie
      {:DOWN, _ref, :process, ^target, _reason} ->
        :ok
    end
  end

  @doc false
  def default_process_request_url(url) do
    case url |> String.slice(0, 12) |> String.downcase() do
      "http://" <> _ -> url
      "https://" <> _ -> url
      "http+unix://" <> _ -> url
      _ -> "http://" <> url
    end
  end

  @doc false
  def merge_params(params, nil), do: params

  def merge_params(request_params, params) when map_size(request_params) === 0, do: params

  def merge_params(request_params, options_params) do
    Map.merge(
      Enum.into(request_params, %{}),
      Enum.into(options_params, %{})
    )
  end

  @doc false
  def build_request_url(url, nil), do: url

  def build_request_url(url, params) do
    cond do
      Enum.count(params) === 0 -> url
      URI.parse(url).query -> url <> "&" <> URI.encode_query(params)
      true -> url <> "?" <> URI.encode_query(params)
    end
  end

  defp build_hackney_options(module, %Request{options: options}) do
    timeout = Keyword.get(options, :timeout)
    recv_timeout = Keyword.get(options, :recv_timeout)
    stream_to = Keyword.get(options, :stream_to)
    async = Keyword.get(options, :async)

    ssl =
      if Keyword.get(options, :ssl) do
        default_ssl_options()
        |> Keyword.merge(Keyword.get(options, :ssl))
      else
        Keyword.get(options, :ssl_override)
      end

    follow_redirect = Keyword.get(options, :follow_redirect)
    max_redirect = Keyword.get(options, :max_redirect)

    hn_options = Keyword.get(options, :hackney, [])

    hn_options = if timeout, do: [{:connect_timeout, timeout} | hn_options], else: hn_options

    hn_options =
      if recv_timeout, do: [{:recv_timeout, recv_timeout} | hn_options], else: hn_options

    hn_options = if ssl, do: [{:ssl_options, ssl} | hn_options], else: hn_options

    hn_options =
      if follow_redirect, do: [{:follow_redirect, follow_redirect} | hn_options], else: hn_options

    hn_options =
      if max_redirect, do: [{:max_redirect, max_redirect} | hn_options], else: hn_options

    hn_options =
      if stream_to do
        async_option =
          case async do
            nil -> :async
            :once -> {:async, :once}
          end

        [async_option, {:stream_to, spawn_link(module, :transformer, [stream_to])} | hn_options]
      else
        hn_options
      end

    hn_options
  end

  defp build_hackney_proxy_options(%Request{options: options, url: request_url}) do
    proxy =
      if Keyword.has_key?(options, :proxy) do
        Keyword.get(options, :proxy)
      else
        case URI.parse(request_url).scheme do
          "http" -> System.get_env("HTTP_PROXY") || System.get_env("http_proxy")
          "https" -> System.get_env("HTTPS_PROXY") || System.get_env("https_proxy")
          _ -> nil
        end
        |> check_no_proxy(request_url)
      end

    proxy_auth = Keyword.get(options, :proxy_auth)
    socks5_user = Keyword.get(options, :socks5_user)
    socks5_pass = Keyword.get(options, :socks5_pass)

    hn_proxy_options = if proxy && proxy != "", do: [{:proxy, proxy}], else: []

    hn_proxy_options =
      if proxy_auth, do: [{:proxy_auth, proxy_auth} | hn_proxy_options], else: hn_proxy_options

    hn_proxy_options =
      if socks5_user, do: [{:socks5_user, socks5_user} | hn_proxy_options], else: hn_proxy_options

    hn_proxy_options =
      if socks5_pass, do: [{:socks5_pass, socks5_pass} | hn_proxy_options], else: hn_proxy_options

    hn_proxy_options
  end

  defp default_ssl_options() do
    [
      {:versions, [:"tlsv1.2", :"tlsv1.3"]},
      {:verify, :verify_peer},
      {:cacertfile, :certifi.cacertfile()},
      {:depth, 10},
      {:customize_hostname_check,
       [
         match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
       ]}
    ]
  end

  defp check_no_proxy(nil, _) do
    # Don't bother to check no_proxy if there's no proxy to use anyway.
    nil
  end

  defp check_no_proxy(proxy, request_url) do
    request_host = URI.parse(request_url).host

    should_bypass_proxy =
      get_no_proxy_system_env()
      |> String.split(",")
      |> Enum.any?(fn domain -> matches_no_proxy_value?(request_host, domain) end)

    if should_bypass_proxy do
      nil
    else
      proxy
    end
  end

  defp get_no_proxy_system_env() do
    System.get_env("NO_PROXY") || System.get_env("no_PROXY") || System.get_env("no_proxy") || ""
  end

  defp matches_no_proxy_value?(request_host, no_proxy_value) do
    cond do
      no_proxy_value == "" -> false
      String.starts_with?(no_proxy_value, ".") -> String.ends_with?(request_host, no_proxy_value)
      String.contains?(no_proxy_value, "*") -> matches_wildcard?(request_host, no_proxy_value)
      true -> request_host == no_proxy_value
    end
  end

  defp matches_wildcard?(request_host, wildcard_domain) do
    Regex.escape(wildcard_domain)
    |> String.replace("\\*", ".*")
    |> Regex.compile!()
    |> Regex.match?(request_host)
  end

  @doc false
  @spec request(module, request, fun, fun, fun, fun) ::
          {:ok, Response.t() | AsyncResponse.t() | MaybeRedirect.t()} | {:error, Error.t()}
  def request(
        module,
        request,
        process_response_status_code,
        process_response_headers,
        process_response_body,
        process_response
      ) do
    hn_proxy_options = build_hackney_proxy_options(request)
    hn_options = hn_proxy_options ++ build_hackney_options(module, request)

    case do_request(request, hn_options) do
      {:ok, status_code, headers} ->
        response(
          process_response_status_code,
          process_response_headers,
          process_response_body,
          process_response,
          status_code,
          headers,
          "",
          request
        )

      {:ok, status_code, headers, client} ->
        max_length = Keyword.get(request.options, :max_body_length, :infinity)

        case :hackney.body(client, max_length) do
          {:ok, body} ->
            response(
              process_response_status_code,
              process_response_headers,
              process_response_body,
              process_response,
              status_code,
              headers,
              body,
              request
            )

          {:error, reason} ->
            {:error, %Error{reason: reason}}
        end

      {:ok, {:maybe_redirect, status_code, headers, _client}} ->
        maybe_redirect(
          process_response_status_code,
          process_response_headers,
          status_code,
          headers,
          request
        )

      {:ok, id} ->
        {:ok, %HTTPoison.AsyncResponse{id: id}}

      {:error, reason} ->
        {:error, %Error{reason: reason}}
    end
  end

  defp do_request(%Request{body: {:stream, enumerable}} = request, hn_options) do
    with {:ok, ref} <-
           :hackney.request(request.method, request.url, request.headers, :stream, hn_options) do
      failures =
        Stream.transform(enumerable, :ok, fn
          _, :error -> {:halt, :error}
          bin, :ok -> {[], :hackney.send_body(ref, bin)}
          _, error -> {[error], :error}
        end)
        |> Enum.into([])

      case failures do
        [] ->
          :hackney.start_response(ref)

        [failure] ->
          failure
      end
    end
  end

  defp do_request(request, hn_options) do
    :hackney.request(request.method, request.url, request.headers, request.body, hn_options)
  end

  defp response(
         process_response_status_code,
         process_response_headers,
         process_response_body,
         process_response,
         status_code,
         headers,
         body,
         request
       ) do
    {:ok,
     %Response{
       status_code: process_response_status_code.(status_code),
       headers: process_response_headers.(headers),
       body: process_response_body.(body),
       request: request,
       request_url: request.url
     }
     |> process_response.()}
  end

  defp maybe_redirect(
         process_response_status_code,
         process_response_headers,
         status_code,
         headers,
         request
       ) do
    {:ok,
     %MaybeRedirect{
       status_code: process_response_status_code.(status_code),
       headers: process_response_headers.(headers),
       request: request,
       request_url: request.url,
       redirect_url: get_header(headers, "Location", nil)
     }}
  end

  defp get_header(headers, key, default) do
    key = String.downcase(key)

    Enum.find_value(headers, default, fn
      {k, v} -> if String.downcase(k) == key, do: v, else: nil
      _ -> nil
    end)
  end

  def maybe_process_form({:form, body}) do
    {:form,
     Enum.flat_map(body, fn
       {k, [{_k, _v} | _rest] = v} -> flatten_nested_body(v, k)
       {k, v} when is_map(v) -> flatten_nested_body(v, k)
       {k, v} -> [{k, v}]
     end)}
  end

  def maybe_process_form(body) do
    body
  end

  defp flatten_nested_body(body, parent_key) do
    flattened_body =
      Enum.reduce(body, [], fn
        {key, nested_key_values}, acc when is_map(nested_key_values) ->
          flatten_nested_body(nested_key_values, "#{parent_key}[#{key}]") ++ acc

        {key, [{_key, _value} | _rest] = nested_key_values}, acc ->
          flatten_nested_body(nested_key_values, "#{parent_key}[#{key}]") ++ acc

        {key, value}, acc ->
          [{"#{parent_key}[#{key}]", value} | acc]
      end)

    Enum.reverse(flattened_body)
  end
end
