defmodule Crutches.Option do
  @moduledoc """
  Convenience functions for dealing with function option handling.

  This provides a mechanism for declaring default options and merging these with
  those given by any caller.

  # Usage

  When you have a function with the following head, the use of this module may
  be beneficial. Of course you can have as much required `args` as you want.
  `options` is a keyword list.

      def foo(args, options)

  Usage is pretty simple. Declare a module attribute with the name of your
  function. It should be a keyword list with the keys `:valid` and `:defaults`.
  `:valid` should contain a list of atoms. `:defaults` should contain another
  keyword list with the default options of your function.

  ## Example

      @function_name [
        valid: ~w(foo bar)a
        defaults: [
          foo: "some",
          bar: "value"
        ]
      ]

  When this is done, you can declare your function head like this:

      def function_name(args, opts \\ []])

  And then you're all set to actually write the meat of your function. (You of
  course don't need a function head if your function only consists of one clause.)

      def function_name(args, opts) do
        # This validates and merges the options, throwing on error.
        opts = Crutches.Options.combine!(opts, @function_name)

        # You can now use the options.
        do_something_with(opts[:foo])
      end
  """

  @type key :: atom
  @type value :: any

  @type t :: [{key, value}]
  @type t(value) :: [{key, value}]

  @type ok(value) :: {:ok, value}
  @type error :: {:error, any}

  @doc """
  Validates the `opts` keyword list according to `config`, combines defaults.

  For intended use see the module documentation.

  # Config variable

  The `config` parameter should be a keyword list with the following keys:

    - `:valid` ([atom]) --- Parameters that your function accepts.
    - `:defaults` ([atom: any]) --- Default values for the options in `:valid`.

  Returns `{:ok, opts}` on succes, `{:error, invalid_keys}` on failure.

  # Examples

      iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]]
      iex> Option.combine([foo: "another"], config)
      {:ok, [bar: "value", foo: "another"]}

      iex> config = [valid: ~w(bar baz)a, defaults: [bar: "good", baz: "values"]]
      iex> Option.combine([boom: "this blows up"], config)
      {:error, [:boom]}

  """
  @spec combine(t, t([atom]) | t(t)) :: ok(t) | error
  def combine(opts, config) do
    combine(opts, config, &Elixir.Keyword.merge(&1, &2))
  end

  @doc """
  This function is the same as `combine/2`, except it returns `options` on
  validation succes and throws `ArgumentError` on validation failure.

  # Examples

      iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]]
      iex> Option.combine!([foo: "another"], config)
      [bar: "value", foo: "another"]

      iex> config = [valid: ~w(bar baz)a, defaults: [bar: "good", baz: "values"]]
      iex> Option.combine!([boom: "this blows up"], config)
      ** (ArgumentError) invalid key boom

  """
  @spec combine!(t, t([atom]) | t(t)) :: t
  def combine!(opts, config) do
    combine!(opts, config, &Elixir.Keyword.merge(&1, &2))
  end

  @doc """
  Validate `opts` according to `config`, combines according to `combinator`

  Behavior is the same as `combine/2`, except that you can specify how `opts`
  and `config[:defaults]` are merged by passing a `combinator` function.

  This function should combine the two keyword lists into one. It receives
  `config[:defaults]` as the first parameter and the validated `opts` as the
  second.

  # Examples

  Contrived example showing of the use of `combinator`.

      iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]]
      iex> combinator = &Keyword.merge/2
      iex> Option.combine([foo: "again"], config, combinator)
      {:ok, [bar: "value", foo: "again"]}

  """
  @spec combine(t, t | t(t), (t, t -> t)) :: ok(t) | error
  def combine(opts, config, combinator) do
    case validate(opts, config[:valid]) do
      {:ok, _} -> {:ok, config[:defaults] |> combinator.(opts) |> sort_options}
      {:error, invalid} -> {:error, invalid}
    end
  end

  defp sort_options(options) do
    Enum.sort(options, fn {key1, _}, {key2, _} -> key1 <= key2 end)
  end

  @doc ~S"""
  Throwing version of `combine/3`

  # Examples

      iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]]
      iex> combinator = fn(_, _) -> nil end
      iex> Option.combine!([baz: "fail"], config, combinator)
      ** (ArgumentError) invalid key baz
  """
  @spec combine!(t, t | t(t), (t, t -> t)) :: t
  def combine!(opts, config, combinator) do
    case combine(opts, config, combinator) do
      {:ok, opts} ->
        opts

      {:error, invalid} ->
        invalid = invalid |> Enum.join(" ")
        raise ArgumentError, message: "invalid key #{invalid}"
    end
  end

  @doc ~S"""
  Checks a `opts` for keys not in `valid`.

  Returns {:ok, []} if all options are kosher, otherwise {:error, list},
  where list is a list of all invalid keys.

  # Examples

      iex> Option.validate([good: "", good_opt: ""], [:good, :good_opt])
      {:ok, []}

      iex> Option.validate([good: "", bad: ""], [:good])
      {:error, [:bad]}

  """
  @spec validate(t, [atom]) :: ok([]) | error
  def validate(opts, valid) do
    if Enum.empty?(invalid_options(opts, valid)) do
      {:ok, []}
    else
      {:error, invalid_options(opts, valid)}
    end
  end

  @doc ~S"""
  Throwing version of `Option.validate`

  # Examples

      iex> Option.validate!([good: "", bad: ""], [:good])
      ** (ArgumentError) invalid key bad

      iex> Option.validate!([good: "", bad: "", worse: ""], [:good])
      ** (ArgumentError) invalid key bad, worse

      iex> Option.validate!([good: ""], [:good])
      true
  """
  @spec validate!(t, [atom]) :: true
  def validate!(opts, valid) do
    case validate(opts, valid) do
      {:ok, _} ->
        true

      {:error, invalid_options} ->
        raise ArgumentError, "invalid key " <> Enum.join(invalid_options, ", ")
    end
  end

  @doc ~S"""
  Check `opts` for keys not in `valid`.

  Return `false` when a bad key is found, otherwise return `true`.

  # Examples

      iex> Option.all_valid?([good: "", good_opt: ".", bad: "!"], [:good, :good_opt])
      false

      iex> Option.all_valid?([good: "", good_opt: "."], [:good, :good_opt])
      true
  """
  @spec all_valid?(t, [atom]) :: boolean
  def all_valid?(opts, valid) do
    Enum.empty?(invalid_options(opts, valid))
  end

  defp invalid_options(opts, valid) do
    opts
    |> Keyword.keys()
    |> Enum.reject(&(&1 in valid))
  end
end
