defmodule Ravenx do
  @moduledoc """
  Ravenx main module.

  It includes and manages dispatching of messages through registered strategies.
  """
  use Application

  @type notif_id :: atom
  @type notif_strategy :: atom
  @type notif_payload :: map
  @type notif_options :: map
  @type notif_result :: {:ok, any} | {:error, {atom, any}}
  @type notif_config ::
          {notif_strategy, notif_payload}
          | {notif_strategy, notif_payload, notif_options}
  @type dispatch_type :: :sync | :async | :nolink

  def start(_type, _args) do
    Task.Supervisor.start_link(name: Ravenx.Supervisor, max_restarts: 2)
  end

  @doc """
  Dispatch a notification `payload` to a specified `strategy`.

  Custom options for this call can be passed in `options` parameter.

  Returns a tuple with `:ok` or `:error` indicating the final state.

  ## Examples

      iex> Ravenx.dispatch(:slack, %{title: "Hello world!", body: "Science is cool"})
      {:ok, "ok"}

      iex> Ravenx.dispatch(:wadus, %{title: "Hello world!", body: "Science is cool"})
      {:error, {:unknown_strategy, :wadus}}

  """
  @spec dispatch(notif_strategy, notif_payload, notif_options) :: notif_result
  def dispatch(strategy, payload, options \\ %{}) do
    handler = Keyword.get(available_strategies(), strategy)

    opts = get_options(strategy, payload, options)

    if is_nil(handler) do
      {:error, {:unknown_strategy, strategy}}
    else
      handler.call(payload, opts)
    end
  end

  @doc """
  Dispatch a notification `payload` to a specified `strategy` asynchronously.

  This function should be used when the caller has an interest in the notification dispatch result,
  which must be received using `Task.await/2`. Keep in mind that this function links the notification
  dispatch task with the caller process, so if one fails the other will fail also.

  If you simply want to dispatch an asynchronous notification without having any interest in the
  result, take a look at `dispatch_nolink/3`.

  Custom options for this call can be passed in `options` parameter.

  Returns a tuple with `:ok` or `:error` indicating the task launch result.
  If the result was `:ok`, the Task of the process launched is also returned

  ## Examples

      iex> {status, task} = Ravenx.dispatch_async(:slack, %{title: "Hello world!", body: "Science is cool"})
      {:ok, %Task{owner: #PID<0.165.0>, pid: #PID<0.183.0>, ref: #Reference<0.0.4.418>}}

      iex> Task.await(task)
      {:ok, "ok"}

      iex> Ravenx.dispatch_async(:wadus, %{title: "Hello world!", body: "Science is cool"})
      {:error, {:unknown_strategy, :wadus}}

  """
  @spec dispatch_async(notif_strategy, notif_payload, notif_options) :: notif_result
  def dispatch_async(strategy, payload, options \\ %{}) do
    handler = Keyword.get(available_strategies(), strategy)

    opts = get_options(strategy, payload, options)

    if is_nil(handler) do
      {:error, {:unknown_strategy, strategy}}
    else
      task = Task.Supervisor.async(Ravenx.Supervisor, fn -> handler.call(payload, opts) end)
      {:ok, task}
    end
  end

  @doc """
  Dispatch a notification `payload` to a specified `strategy` unlinked.

  This function spawns a separated process for dispatching the notification in an unlinked way,
  meaning that the caller won't be able to know the notification dispatch result.
  If you want to dispatch an asynchronous notification and receive its result take a look at
  `dispatch_async/3`.

  Custom options for this call can be passed in `options` parameter.

  Returns a tuple with `:ok` or `:error` indicating the task launch result.
  If the result was `:ok`, the PID of the notification dispatch process is also returned.

  ## Examples

      iex> {status, pid} = Ravenx.dispatch_nolink(:slack, %{title: "Hello world!", body: "Science is cool"})
      {:ok, #PID<0.165.0>}

      iex> Ravenx.dispatch_nolink(:wadus, %{title: "Hello world!", body: "Science is cool"})
      {:error, {:unknown_strategy, :wadus}}
  """
  def dispatch_nolink(strategy, payload, options \\ %{}) do
    handler = Keyword.get(available_strategies(), strategy)

    opts = get_options(strategy, payload, options)

    if is_nil(handler) do
      {:error, {:unknown_strategy, strategy}}
    else
      Task.Supervisor.start_child(Ravenx.Supervisor, fn -> handler.call(payload, opts) end)
    end
  end

  @doc """
  Function to get a Keyword list of registered strategies.
  """
  @spec available_strategies() :: keyword
  def available_strategies do
    bundled_strategies = [
      slack: Ravenx.Strategy.Slack,
      email: Ravenx.Strategy.Email,
      dummy: Ravenx.Strategy.Dummy
    ]

    bundled_strategies
    |> Keyword.merge(Application.get_env(:ravenx, :strategies, []))
  end

  # Private function to get definitive options keyword list by getting options
  # from three different places.
  #
  @spec get_options(notif_strategy, notif_payload, notif_options) :: notif_options
  defp get_options(strategy, payload, options) do
    # Get strategy configuration in application
    app_config_opts = Enum.into(Application.get_env(:ravenx, strategy, []), %{})

    # Get config module and call the function of this strategy (if any)
    module_name = Application.get_env(:ravenx, :config, nil)
    config_module_opts = call_config_module(module_name, strategy, payload)

    # Merge options
    app_config_opts
    |> Map.merge(config_module_opts)
    |> Map.merge(options)
  end

  # Private function to call the config module if it's defined.
  #
  @spec call_config_module(atom, notif_strategy, notif_payload) :: notif_options
  defp call_config_module(module, _strategy, _payload) when is_nil(module), do: %{}

  defp call_config_module(module, strategy, payload) do
    if Keyword.has_key?(module.__info__(:functions), strategy) do
      apply(module, strategy, [payload])
    else
      %{}
    end
  end
end
