lib/formula.ex

defmodule Formula do
  @moduledoc """
  Documentation for `Formula`.
  """
  alias Explorer.Series
  alias Formula.Actions.GetFields
  alias Formula.Errors.InvalidFormulaError
  alias Formula.Evaluator
  alias Formula.Function
  alias Formula.Lexer

  @reserved_result_key "elixir.formula.result"

  @doc """
  Evaluate the formula given the data
  """
  @spec evaluate(map(), String.t()) :: term() | {:error, term()}
  def evaluate(data, formula) when is_binary(formula) do
    evaluation_result =
      data
      |> Map.merge(%{@reserved_result_key => "=" <> formula})
      |> evaluate()

    case evaluation_result do
      {:error, error} -> {:error, error}
      result -> Map.get(result, @reserved_result_key)
    end
  end

  def evaluate(_data, _formula) do
    {:error, InvalidFormulaError.exception(message: "formula must be a string")}
  end

  @doc """
  Evaluate a given map that contains function
  """
  @spec evaluate(map()) :: map() | {:error, term()}
  def evaluate(data) do
    case parse(data) do
      {:error, error} -> {:error, error}
      parsed_data -> Evaluator.evaluate(parsed_data)
    end
  end

  @doc """
  Parse a given map into functions and symbols
  """
  @spec parse(map()) :: map() | {:error, term()}
  def parse(data) do
    Enum.reduce_while(data, %{}, fn kv, acc ->
      case parse_data(kv) do
        {_key, {:error, error}} -> {:halt, {:error, error}}
        {key, value} -> {:cont, Map.put(acc, key, value)}
      end
    end)
  end

  @doc """
  Parse a formula.

  Return a function struct if it is valid. Return error otherwise.
  """
  @spec parse_formula(String.t()) :: Function.t() | {:error, InvalidFormulaError.t()}
  def parse_formula(string) do
    parse_formula(string, @reserved_result_key)
  end

  @spec parse_formula(String.t(), String.t()) :: Function.t() | {:error, InvalidFormulaError.t()}
  def parse_formula(string, key) do
    result =
      string
      |> Lexer.tokenize!()
      |> :formula_parser.parse()

    case result do
      {:ok, function} ->
        function

      {:error, _} ->
        {:error, InvalidFormulaError.exception(message: "Please check your formula", key: key)}
    end
  end

  @doc """
  Return the fields for a given formula
  """
  @spec get_fields(String.t()) :: list(String.t()) | {:error, term()}
  defdelegate get_fields(formula), to: GetFields, as: :call

  defp parse_data({key, "=" <> value}) do
    {key, parse_formula(value, key)}
  end

  defp parse_data({key, value}) when is_binary(value) do
    case Decimal.parse(value) do
      {decimal, _} -> {key, decimal}
      :error -> {key, value}
    end
  end

  defp parse_data({key, value}) when is_integer(value) do
    {key, Decimal.new(value)}
  end

  defp parse_data({key, value}) when is_float(value) do
    {key, Decimal.from_float(value)}
  end

  defp parse_data({key, value}) when is_list(value) do
    parsed_value =
      value
      |> Enum.map(&parse_series_data/1)
      |> Series.from_list(dtype: :float)

    {key, parsed_value}
  end

  defp parse_data({key, value}), do: {key, value}

  defp parse_series_data(%Decimal{} = value) do
    Decimal.to_float(value)
  end

  defp parse_series_data(value) when is_binary(value) do
    value
    |> Float.parse()
    |> elem(0)
  end

  defp parse_series_data(value) when is_float(value), do: value

  defp parse_series_data(value) when is_integer(value) do
    value
    |> Integer.to_string()
    |> Float.parse()
    |> elem(0)
  end
end