lib/ex_waiter/polling.ex

defmodule ExWaiter.Polling do
  alias ExWaiter.Polling.Attempt
  alias ExWaiter.Polling.InvalidResult
  alias ExWaiter.Polling.Poller
  alias ExWaiter.Polling.Poller.Config

  @valid_options [:delay, :max_attempts, :record_history]

  @type poll_result ::
          {:ok, Poller.t()}
          | {:error, :retries_exhausted, Poller.t()}
          | {:error, :attempt_failed, Poller.t()}
  @type options :: [
          {:delay, Config.delay()}
          | {:max_attempts, Config.max_attempts()}
          | {:record_history, boolean()}
        ]

  @spec new_poller(Poller.Config.polling_fn(), options()) :: Poller.t()
  def new_poller(polling_fn, opts) do
    Enum.each(opts, fn {key, _} ->
      unless key in @valid_options do
        raise "#{key} is not a valid option"
      end
    end)

    max_attempts = Keyword.get(opts, :max_attempts, 5)
    record_history = Keyword.get(opts, :record_history, false)
    delay = Keyword.get(opts, :delay, &(&1.attempt_num * 10))

    unless is_integer(delay) or is_function(delay, 0) or is_function(delay, 1) do
      raise ":delay must be either an integer or a function with an arity of 0 or 1 (can take the Poller struct)"
    end

    unless is_function(polling_fn, 0) || is_function(polling_fn, 1) do
      raise "The polling function must have an arity of 0 or 1 (can take the Poller struct)"
    end

    unless is_integer(max_attempts) || max_attempts == :infinity || is_function(max_attempts, 0) ||
             is_function(max_attempts, 1) do
      raise ":max_attempts must be either an integer (ms), :infinity, or a function with an arity of 0 or 1 (can take the Poller struct)"
    end

    history = if record_history, do: []

    %Poller{
      config: %Config{
        polling_fn: polling_fn,
        delay: delay,
        max_attempts: max_attempts,
        record_history: record_history
      },
      attempt_num: 0,
      history: history
    }
  end

  @spec poll_once(Poller.t()) :: poll_result()
  def poll_once(%Poller{status: :ok} = poller), do: {:ok, poller}

  def poll_once(%Poller{status: {:error, :retries_exhausted}} = poller),
    do: {:error, :retries_exhausted, poller}

  def poll_once(%Poller{} = poller) do
    poller =
      poller
      |> Map.put(:attempt_num, poller.attempt_num + 1)
      |> then(&Map.put(&1, :total_delay, calculate_total_delay(&1)))
      |> Map.put(:next_delay, nil)

    case handle_config_fn(poller, poller.config.polling_fn) do
      {:ok, value} -> handle_successful_attempt(poller, value)
      :ok -> handle_successful_attempt(poller, nil)
      true -> handle_successful_attempt(poller, nil)
      {:error, value} -> handle_failed_attempt(poller, value)
      :error -> handle_failed_attempt(poller, nil)
      false -> handle_failed_attempt(poller, nil)
      result -> raise InvalidResult, result
    end
  end

  defp handle_successful_attempt(%Poller{} = poller, value) do
    poller =
      poller
      |> Map.put(:value, value)
      |> Map.put(:status, :ok)
      |> record_history()

    {:ok, poller}
  end

  defp handle_failed_attempt(%Poller{} = poller, value) do
    poller = Map.put(poller, :value, value)

    if retryable?(poller) do
      handle_retryable_attempt(poller)
    else
      poller =
        poller
        |> Map.put(:status, {:error, :retries_exhausted})
        |> record_history()

      {:error, :retries_exhausted, poller}
    end
  end

  defp handle_retryable_attempt(%Poller{} = poller) do
    poller =
      poller
      |> Map.put(:status, {:error, :attempt_failed})
      |> then(&Map.put(&1, :next_delay, determine_delay(&1)))
      |> record_history()

    {:error, :attempt_failed, poller}
  end

  defp record_history(%Poller{config: %{record_history: false}} = poller), do: poller

  defp record_history(%Poller{} = poller) do
    current_attempt = %Attempt{
      value: poller.value,
      next_delay: poller.next_delay
    }

    history =
      poller.history
      |> Enum.reverse()
      |> then(&[current_attempt | &1])
      |> Enum.reverse()

    %{poller | history: history}
  end

  defp calculate_total_delay(%Poller{total_delay: total_delay, next_delay: nil}),
    do: total_delay

  defp calculate_total_delay(%Poller{total_delay: total_delay, next_delay: next_delay}),
    do: total_delay + next_delay

  defp determine_delay(%Poller{config: %{delay: ms}}) when is_integer(ms), do: ms

  defp determine_delay(%Poller{config: %{delay: delay_fn}} = poller)
       when is_function(delay_fn),
       do: handle_config_fn(poller, delay_fn)

  defp retryable?(%Poller{attempt_num: attempt_num, config: %{max_attempts: max_attempts}})
       when is_integer(max_attempts),
       do: attempt_num < max_attempts

  defp retryable?(%Poller{config: %{max_attempts: :infinity}}), do: true

  defp retryable?(%Poller{config: %{max_attempts: max_attempts_fn}} = poller)
       when is_function(max_attempts_fn) do
    case handle_config_fn(poller, max_attempts_fn) do
      true -> true
      false -> false
      _ -> raise ":max_attempts must return a boolean"
    end
  end

  defp handle_config_fn(%Poller{} = poller, config_fn) do
    case :erlang.fun_info(config_fn)[:arity] do
      1 -> config_fn.(poller)
      0 -> config_fn.()
      _ -> raise "Function must have an arity of either 0 or 1"
    end
  end
end