defmodule Monad.Behaviour do
  @moduledoc """
  A behaviour that provides the common code for monads.

  Creating a monad consists of three steps:
  1. Call `use Monad.Behaviour`
  2. Implement `return/1`
  3. Implement `bind/2`

  By completing the above steps, the monad will automatically conform to the
  `Functor` and `Applicative` protocols in addition to the `Monad` protocol.

  ## Example

  The following is an example showing how to use `Monad.Behaviour`.

      iex> defmodule Monad.Identity.Sample do
      ...>   use Elixir.Monad.Behaviour  # The `Elixir` prefix is needed for the doctest.
      ...>
      ...>   defstruct value: nil
      ...>
      ...>   def return(value) do
      ...>     %Monad.Identity.Sample{value: value}
      ...>   end
      ...>
      ...>   def bind(%Monad.Identity.Sample{value: value}, fun) do
      ...>     fun.(value)
      ...>   end
      ...>
      ...>   def unwrap(%Monad.Identity.Sample{value: value}) do
      ...>     value
      ...>   end
      ...> end
      iex> m = Monad.Identity.Sample.return 42
      iex> Monad.Identity.Sample.unwrap m
      42
      iex> m2 = Elixir.Monad.bind m, (& Monad.Identity.Sample.return(&1 * 2))
      iex> Monad.Identity.Sample.unwrap m2
      84
  """

  @type t :: Monad.t()
  @type bind_fun :: (term -> t)

  @callback return(value :: term) :: t
  @callback bind(monad :: t, fun :: bind_fun) :: t

  @doc """
  Calls `module`'s `return/1` function.

  Wraps the given value in the specified monad.
  """
  @spec return(atom, term) :: t
  def return(module, value), do: module.return(value)

  @doc """
  Calls `module`'s `bind/2` function.

  Unwraps `monad` then applies the wrapped value to `fun`. Returns a new monad.
  """
  @spec bind(atom, t, (term -> t)) :: t
  def bind(module, monad, fun), do: module.bind(monad, fun)

  @doc false
  defmacro __using__(_) do
    quote do
      @behaviour Monad.Behaviour

      defimpl Functor, for: __MODULE__ do
        def fmap(monad, fun) do
          return = &Monad.Behaviour.return(@for, &1)
          Monad.Behaviour.bind(@for, monad, &(&1 |> fun.() |> return.()))
        end
      end

      defimpl Applicative, for: __MODULE__ do
        def apply(monad, monad_fun) do
          Monad.Behaviour.bind(@for, monad_fun, &Functor.fmap(monad, &1))
        end
      end

      defimpl Monad, for: __MODULE__ do
        def bind(monad, fun) do
          Monad.Behaviour.bind(@for, monad, fun)
        end
      end
    end
  end
end
