lib/expression/callbacks.ex

defmodule Expression.Callbacks do
  @moduledoc """
  The function callbacks for the standard function set available
  in FLOIP expressions.

  This should be relatively swappable with another implementation.
  The only requirement is the `handle/3` function.

  FLOIP functions are case insensitive. All functions in this callback
  module are implemented as lowercase names.

  Some functions accept a variable amount of arguments. Elixir doesn't
  support variable arguments in functions.

  If a function accepts a variable number of arguments the convention
  is to call the `<function_name>_vargs/2` callback where the context
  is given as the first argument and the argument list as a second
  argument.

  Reserved names such as `and`, `if`, and `or` are suffixed with an
  underscore.
  """

  @doc """
  Evaluate the given AST against the context and return the value
  after evaluation.
  """
  @spec eval!(term, map) :: term
  def eval!(ast, ctx) do
    ast
    |> Expression.Eval.eval!(ctx, __MODULE__)
    |> Expression.Eval.not_founds_as_nil()
  end

  @doc """
  Evaluate the given AST values against the context and return the
  values after evaluation.
  """
  @spec eval_args!([term], map) :: [term]
  def eval_args!(args, ctx), do: Enum.map(args, &eval!(&1, ctx))

  defmacro __using__(_opts) do
    quote do
      defdelegate handle(function_name, arguments, context), to: Expression.Callbacks
    end
  end

  @reserved_words ~w[and if or not]

  @punctuation_pattern ~r/\s*[,:;!?.-]\s*|\s/

  @doc """
  Convert a string function name into an atom meant to handle
  that function

  Reserved words such as `and`, `if`, and `or` are automatically suffixed
  with an `_` underscore.
  """
  def atom_function_name(function_name) when function_name in @reserved_words,
    do: atom_function_name("#{function_name}_")

  def atom_function_name(function_name) do
    String.to_atom(function_name)
  end

  @doc """
  Handle a function call while evaluating the AST.

  Handlers in this module are either:

  1. The function name as is
  2. The function name with an underscore suffix if the function name is a reserved word
  3. The function name suffixed with `_vargs` if the takes a variable set of arguments
  """
  @callback handle(function_name :: binary, arguments :: [any], context :: map) ::
              {:ok, any} | {:error, :not_implemented}
  @spec handle(function_name :: binary, arguments :: [any], context :: map) ::
          {:ok, any} | {:error, :not_implemented}
  def handle(function_name, arguments, context) do
    case implements(function_name, arguments) do
      {:exact, function_name, _arity} ->
        {:ok, apply(__MODULE__, function_name, [context] ++ arguments)}

      {:vargs, function_name, _arity} ->
        {:ok, apply(__MODULE__, function_name, [context, arguments])}

      {:error, reason} ->
        {:error, reason}
    end
  end

  def implements(module \\ __MODULE__, function_name, arguments) do
    exact_function_name = atom_function_name(function_name)
    vargs_function_name = atom_function_name("#{function_name}_vargs")

    cond do
      # Check if the exact function signature has been implemented
      function_exported?(module, exact_function_name, length(arguments) + 1) ->
        {:exact, exact_function_name, length(arguments) + 1}

      # Check if it's been implemented to accept a variable amount of arguments
      function_exported?(module, vargs_function_name, 2) ->
        {:vargs, vargs_function_name, 2}

      # Otherwise fail
      true ->
        {:error, "#{function_name} is not implemented."}
    end
  end

  @doc """
  Defines a new date value

  # Example

      iex> Expression.evaluate!("@date(2012, 12, 15)")
      ~U[2012-12-15 00:00:00Z]

  """
  def date(ctx, year, month, day) do
    [year, month, day] = eval_args!([year, month, day], ctx)

    fields = [
      calendar: Calendar.ISO,
      year: year,
      month: month,
      day: day,
      hour: 0,
      minute: 0,
      second: 0,
      time_zone: "Etc/UTC",
      zone_abbr: "UTC",
      utc_offset: 0,
      std_offset: 0
    ]

    struct(DateTime, fields)
  end

  @doc """
  Converts date stored in text to an actual date,
  using `strftime` formatting.

  It will fallback to "%Y-%m-%d %H:%M:%S" if no formatting is supplied

  # Example

      iex> Expression.evaluate!("@datevalue(date(2020, 12, 20))")
      "2020-12-20 00:00:00"
      iex> Expression.evaluate!("@datevalue(date(2020, 12, 20), '%Y-%m-%d')")
      "2020-12-20"

  """
  def datevalue(ctx, date, format) do
    [date, format] = eval!([date, format], ctx)
    Timex.format!(date, format, :strftime)
  end

  def datevalue(ctx, date) do
    Timex.format!(eval!(date, ctx), "%Y-%m-%d %H:%M:%S", :strftime)
  end

  @doc """
  Returns only the day of the month of a date (1 to 31)

  # Example

      iex> now = DateTime.utc_now()
      iex> day = Expression.evaluate!("@day(now())")
      iex> day == now.day
      true
  """
  def day(ctx, date) do
    %{day: day} = eval!(date, ctx)
    day
  end

  @doc """
  Moves a date by the given number of months

  # Example

      iex> now = DateTime.utc_now()
      iex> future = Timex.shift(now, months: 1)
      iex> date = Expression.evaluate!("@edate(now(), 1)")
      iex> future.month == date.month
      true
  """
  def edate(ctx, date, months) do
    [date, months] = eval_args!([date, months], ctx)
    date |> Timex.shift(months: months)
  end

  @doc """
  Returns only the hour of a datetime (0 to 23)

  # Example

      iex> now = DateTime.utc_now()
      iex> hour = Expression.evaluate!("@hour(now())")
      iex> now.hour == hour
      true
  """
  def hour(ctx, date) do
    %{hour: hour} = eval!(date, ctx)
    hour
  end

  @doc """
  Returns only the minute of a datetime (0 to 59)

  # Example

      iex> now = DateTime.utc_now()
      iex> minute = Expression.evaluate!("@minute(now)", %{"now" => now})
      iex> now.minute == minute
      true
  """
  def minute(ctx, date) do
    %{minute: minute} = eval!(date, ctx)
    minute
  end

  @doc """
  Returns only the month of a date (1 to 12)

  # Example

      iex> now = DateTime.utc_now()
      iex> month = Expression.evaluate!("@month(now)", %{"now" => now})
      iex> now.month == month
      true
  """
  def month(ctx, date) do
    %{month: month} = eval!(date, ctx)
    month
  end

  @doc """
  Returns the current date time as UTC

  ```
  It is currently @NOW()
  ```

  # Example

      iex> DateTime.utc_now() == Expression.Callbacks.now(%{})
  """
  def now(_ctx) do
    DateTime.utc_now()
  end

  @doc """
  Returns only the second of a datetime (0 to 59)

  # Example

      iex> now = DateTime.utc_now()
      iex> second = Expression.evaluate!("@second(now)", %{"now" => now})
      iex> now.second == second
      true

  """
  def second(ctx, date) do
    %{second: second} = eval!(date, ctx)
    second
  end

  @doc """
  Defines a time value which can be used for time arithmetic

  # Example

      iex> Expression.evaluate!("@time(12, 13, 14)")
      %Time{hour: 12, minute: 13, second: 14}

  """
  def time(ctx, hours, minutes, seconds) do
    [hours, minutes, seconds] = eval_args!([hours, minutes, seconds], ctx)
    %Time{hour: hours, minute: minutes, second: seconds}
  end

  @doc """
  Converts time stored in text to an actual time

  # Example

      iex> Expression.evaluate!("@timevalue(\\"2:30\\")")
      %Time{hour: 2, minute: 30, second: 0}

      iex> Expression.evaluate!("@timevalue(\\"2:30:55\\")")
      %Time{hour: 2, minute: 30, second: 55}
  """
  def timevalue(ctx, expression) do
    expression = eval!(expression, ctx)

    parts =
      expression
      |> String.split(":")
      |> Enum.map(&String.to_integer/1)

    defaults = [
      hour: 0,
      minute: 0,
      second: 0
    ]

    fields =
      [:hour, :minute, :second]
      |> Enum.zip(parts)

    struct(Time, Keyword.merge(defaults, fields))
  end

  @doc """
  Returns the current date

  ```
  Today's date is @TODAY()
  ```

  # Example

      iex> today = Date.utc_today()
      iex> today == Expression.Callbacks.today(%{})
      true

  """
  def today(_ctx) do
    Date.utc_today()
  end

  @doc """
  Returns the day of the week of a date (1 for Sunday to 7 for Saturday)

  # Example

      iex> today = DateTime.utc_now()
      iex> expected = Timex.weekday(today)
      iex> weekday = Expression.evaluate!("@weekday(today)", %{"today" => today})
      iex> weekday == expected
      true
  """
  def weekday(ctx, date) do
    Timex.weekday(eval!(date, ctx))
  end

  @doc """
  Returns only the year of a date


  # Example

      iex> %{year: year} = now = DateTime.utc_now()
      iex> year == Expression.evaluate!("@year(now)", %{"now" => now})

  """
  def year(ctx, date) do
    %{year: year} = eval!(date, ctx)
    year
  end

  @doc """
  Returns TRUE if and only if all its arguments evaluate to TRUE

  # Example

      iex> Expression.evaluate_as_boolean!("@AND(contact.gender = \\"F\\", contact.age >= 18)", %{
      iex>  "contact" => %{
      iex>    "gender" => "F",
      iex>    "age" => 32
      iex>  }})
      true

      iex> Expression.evaluate_as_boolean!("@AND(contact.gender = \\"F\\", contact.age >= 18)", %{
      iex>  "contact" => %{
      iex>    "gender" => "?",
      iex>    "age" => 32
      iex>  }})
      false
  """
  def and_vargs(ctx, arguments) do
    arguments = eval_args!(arguments, ctx)
    Enum.all?(arguments, & &1)
  end

  @doc """
  Returns FALSE if the argument supplied evaluates to truth-y

  # Example

      iex> Expression.evaluate!("@and(not(false), true)")
      true

  """
  def not_(ctx, argument) do
    !eval!(argument, ctx)
  end

  @doc """
  Returns one value if the condition evaluates to TRUE, and another value if it evaluates to FALSE

  # Example

      iex> Expression.evaluate!("@if(true, \\"Yes\\", \\"No\\")")
      "Yes"
      iex> Expression.evaluate!("@if(false, \\"Yes\\", \\"No\\")")
      "No"
  """
  def if_(ctx, condition, yes, no) do
    if(eval!(condition, ctx),
      do: eval!(yes, ctx),
      else: eval!(no, ctx)
    )
  end

  @doc """
  Returns TRUE if any argument is TRUE

  # Example

      iex> Expression.evaluate!("@or(true, false)")
      true
      iex> Expression.evaluate!("@or(true, true)")
      true
      iex> Expression.evaluate!("@or(false, false)")
      false
      iex> Expression.evaluate!("@or(false, \\"foo\\")")
      "foo"
  """
  def or_vargs(ctx, arguments) do
    arguments = eval_args!(arguments, ctx)
    Enum.reduce(arguments, fn a, b -> a || b end)
  end

  @doc """
  Returns the absolute value of a number

  # Example

      iex> Expression.evaluate_as_string!("The absolute value of -1 is @ABS(-1)")
      "The absolute value of -1 is 1"

  """
  def abs(ctx, number) do
    abs(eval!(number, ctx))
  end

  @doc """
  Returns the maximum value of all arguments

  # Example

      iex> Expression.evaluate!("@max(1, 2, 3)")
      3
  """
  def max_vargs(ctx, arguments) do
    Enum.max(eval_args!(arguments, ctx))
  end

  @doc """
  Returns the minimum value of all arguments

  #  Example

      iex> Expression.evaluate!("@min(1, 2, 3)")
      1
  """
  def min_vargs(ctx, arguments) do
    Enum.min(eval_args!(arguments, ctx))
  end

  @doc """
  Returns the result of a number raised to a power - equivalent to the ^ operator

  ```
  2 to the power of 3 is @POWER(2, 3)
  ```
  """
  def power(ctx, a, b) do
    [a, b] = eval_args!([a, b], ctx)
    :math.pow(a, b)
  end

  @doc """
  Returns the sum of all arguments, equivalent to the + operator

  ```
  You have @SUM(contact.reports, contact.forms) reports and forms
  ```

  # Example

      iex> Expression.evaluate!("@sum(1, 2, 3)")
      6

  """
  def sum_vargs(ctx, arguments) do
    Enum.sum(eval_args!(arguments, ctx))
  end

  @doc """
  Returns the character specified by a number


  # Example

      iex> Expression.evaluate_as_string!("As easy as @CHAR(65), @CHAR(66), @CHAR(67)")
      "As easy as A, B, C"

  """
  def char(ctx, code) do
    code = eval!(code, ctx)
    <<code>>
  end

  @doc """
  Removes all non-printable characters from a text string

  ```

  ```

  # Example

      iex> Expression.evaluate_as_string!("You entered @CLEAN(step.value)", %{
      iex>   "step" => %{
      iex>     "value" => <<65, 0, 66, 0, 67>>
      iex>   }
      iex> })
      "You entered ABC"
  """
  def clean(ctx, binary) do
    binary
    |> eval!(ctx)
    |> String.graphemes()
    |> Enum.filter(&String.printable?/1)
    |> Enum.join("")
  end

  @doc """
  Returns a numeric code for the first character in a text string

  # Example

      iex> Expression.evaluate_as_string!("The numeric code of A is @CODE(\\"A\\")")
      "The numeric code of A is 65"
  """
  def code(ctx, code_ast) do
    <<code>> = eval!(code_ast, ctx)
    code
  end

  @doc """
  Joins text strings into one text string


  # Example

      iex> Expression.evaluate_as_string!("Your name is @CONCATENATE(contact.first_name, \\" \\", contact.last_name)", %{
      iex>   "contact" => %{
      iex>     "first_name" => "name",
      iex>     "last_name" => "surname"
      iex>    }
      iex> })
      "Your name is name surname"
  """
  def concatenate_vargs(ctx, arguments) do
    Enum.join(eval_args!(arguments, ctx), "")
  end

  @doc """
  Formats the given number in decimal format using a period and commas

  ```
  You have @FIXED(contact.balance, 2) in your account
  ```

  # Example

      iex> Expression.evaluate!("@fixed(4.209922, 2, false)")
      "4.21"
      iex> Expression.evaluate!("@fixed(4000.424242, 4, true)")
      "4,000.4242"
      iex> Expression.evaluate!("@fixed(3.7979, 2, false)")
      "3.80"
      iex> Expression.evaluate!("@fixed(3.7979, 2)")
      "3.80"

  """
  def fixed(ctx, number, precision) do
    [number, precision] = eval_args!([number, precision], ctx)
    Number.Delimit.number_to_delimited(number, precision: precision)
  end

  def fixed(ctx, number, precision, boolean) do
    case eval_args!([number, precision, boolean], ctx) do
      [number, precision, true] ->
        Number.Delimit.number_to_delimited(number,
          precision: precision,
          delimiter: ",",
          separator: "."
        )

      [number, precision, false] ->
        Number.Delimit.number_to_delimited(number, precision: precision)
    end
  end

  @doc """
  Returns the first characters in a text string. This is Unicode safe.

  # Example

      iex> Expression.evaluate!("@left(\\"foobar\\", 4)")
      "foob"

      iex> Expression.evaluate!("@left(\\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\\", 20)")
      "Умерла Мадлен Олбрай"

  """
  def left(ctx, binary, size) do
    [binary, size] = eval_args!([binary, size], ctx)
    String.slice(binary, 0, size)
  end

  @doc """
  Returns the number of characters in a text string

  # Example

      iex> Expression.evaluate!("@len(\\"foo\\")")
      3
      iex> Expression.evaluate!("@len(\\"zoë\\")")
      3
  """
  def len(ctx, binary) do
    String.length(eval!(binary, ctx))
  end

  @doc """
  Converts a text string to lowercase

  # Example

      iex> Expression.evaluate!("@lower(\\"Foo Bar\\")")
      "foo bar"

  """
  def lower(ctx, binary) do
    String.downcase(eval!(binary, ctx))
  end

  @doc """
  Capitalizes the first letter of every word in a text string

  # Example

      iex> Expression.evaluate!("@proper(\\"foo bar\\")")
      "Foo Bar"
  """
  def proper(ctx, binary) do
    binary
    |> eval!(ctx)
    |> String.split(" ")
    |> Enum.map_join(" ", &String.capitalize/1)
  end

  @doc """
  Repeats text a given number of times

  # Example

      iex> Expression.evaluate!("@rept(\\"*\\", 10)")
      "**********"
  """
  def rept(ctx, value, amount) do
    [value, amount] = eval_args!([value, amount], ctx)
    String.duplicate(value, amount)
  end

  @doc """
  Returns the last characters in a text string.
  This is Unicode safe.

  # Example

      iex> Expression.evaluate!("@right(\\"testing\\", 3)")
      "ing"

      iex> Expression.evaluate!("@right(\\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\\", 20)")
      "ту главы Госдепа США"

  """
  def right(ctx, binary, size) do
    [binary, size] = eval_args!([binary, size], ctx)
    String.slice(binary, -size, size)
  end

  @doc """
  Substitutes new_text for old_text in a text string. If instance_num is given, then only that instance will be substituted

  # Example

      iex> Expression.evaluate!("@substitute(\\"I can't\\", \\"can't\\", \\"can do\\")")
      "I can do"

  """
  def substitute(ctx, subject, pattern, replacement) do
    [subject, pattern, replacement] = eval_args!([subject, pattern, replacement], ctx)
    String.replace(subject, pattern, replacement)
  end

  @doc """
  Returns the unicode character specified by a number

  # Example

      iex> Expression.evaluate!("@unichar(65)")
      "A"
      iex> Expression.evaluate!("@unichar(233)")
      "é"

  """
  def unichar(ctx, code) do
    code = eval!(code, ctx)
    <<code::utf8>>
  end

  @doc """
  Returns a numeric code for the first character in a text string

  # Example

      iex> Expression.evaluate!("@unicode(\\"A\\")")
      65
      iex> Expression.evaluate!("@unicode(\\\\")")
      233
  """
  def unicode(ctx, letter) do
    <<code::utf8>> = eval!(letter, ctx)
    code
  end

  @doc """
  Converts a text string to uppercase

  # Example

      iex> Expression.evaluate!("@upper(\\"foo\\")")
      "FOO"
  """
  def upper(ctx, binary) do
    String.upcase(eval!(binary, ctx))
  end

  @doc """
  Returns the first word in the given text - equivalent to WORD(text, 1)

  # Example

      iex> Expression.evaluate!("@first_word(\\"foo bar baz\\")")
      "foo"

  """
  def first_word(ctx, binary) do
    [word | _] = String.split(eval!(binary, ctx), " ")
    word
  end

  @doc """
  Formats a number as a percentage

  # Example

      iex> Expression.evaluate!("@percent(2/10)")
      "20%"
      iex> Expression.evaluate!("@percent(0.2)")
      "20%"
      iex> Expression.evaluate!("@percent(d)", %{"d" => Decimal.new("0.2")})
      "20%"
  """
  def percent(ctx, decimal) do
    decimal =
      case eval!(decimal, ctx) do
        float when is_float(float) -> Decimal.from_float(float)
        binary when is_binary(binary) -> Decimal.new(binary)
        decimal when is_struct(decimal, Decimal) -> decimal
      end

    decimal
    |> Decimal.mult(100)
    |> Decimal.to_float()
    |> Number.Percentage.number_to_percentage(precision: 0)
  end

  @doc """
  Formats digits in text for reading in TTS

  # Example

      iex> Expression.evaluate!("@read_digits(\\"+271\\")")
      "plus two seven one"

  """
  def read_digits(ctx, binary) do
    map = %{
      "+" => "plus",
      "0" => "zero",
      "1" => "one",
      "2" => "two",
      "3" => "three",
      "4" => "four",
      "5" => "five",
      "6" => "six",
      "7" => "seven",
      "8" => "eight",
      "9" => "nine"
    }

    binary
    |> eval!(ctx)
    |> String.graphemes()
    |> Enum.map(fn grapheme -> Map.get(map, grapheme, nil) end)
    |> Enum.reject(&is_nil/1)
    |> Enum.join(" ")
  end

  @doc """
  Removes the first word from the given text. The remaining text will be unchanged

  # Example

      iex> Expression.evaluate!("@remove_first_word(\\"foo bar\\")")
      "bar"
      iex> Expression.evaluate!("@remove_first_word(\\"foo-bar\\", \\"-\\")")
      "bar"
  """

  def remove_first_word(ctx, binary) do
    binary = eval!(binary, ctx)
    separator = " "
    tl(String.split(binary, separator)) |> Enum.join(separator)
  end

  def remove_first_word(ctx, binary, separator) do
    [binary, separator] = eval_args!([binary, separator], ctx)
    tl(String.split(binary, separator)) |> Enum.join(separator)
  end

  @doc """
  Extracts the nth word from the given text string. If stop is a negative number,
  then it is treated as count backwards from the end of the text. If by_spaces is
  specified and is TRUE then the function splits the text into words only by spaces.
  Otherwise the text is split by punctuation characters as well

  # Example

      iex> Expression.evaluate!("@word(\\"hello cow-boy\\", 2)")
      "cow"
      iex> Expression.evaluate!("@word(\\"hello cow-boy\\", 2, true)")
      "cow-boy"
      iex> Expression.evaluate!("@word(\\"hello cow-boy\\", -1)")
      "boy"

  """
  def word(ctx, binary, n) do
    [binary, n] = eval_args!([binary, n], ctx)
    parts = String.split(binary, @punctuation_pattern)

    # This slicing seems off.
    [part] =
      if n < 0 do
        Enum.slice(parts, n, 1)
      else
        Enum.slice(parts, n - 1, 1)
      end

    part
  end

  def word(ctx, binary, n, by_spaces) do
    [binary, n, by_spaces] = eval_args!([binary, n, by_spaces], ctx)
    splitter = if(by_spaces, do: " ", else: @punctuation_pattern)
    parts = String.split(binary, splitter)

    # This slicing seems off.
    [part] =
      if n < 0 do
        Enum.slice(parts, n, 1)
      else
        Enum.slice(parts, n - 1, 1)
      end

    part
  end

  @doc """
  Returns the number of words in the given text string. If by_spaces is specified and is TRUE then the function splits the text into words only by spaces. Otherwise the text is split by punctuation characters as well

  ```
  You entered @WORD_COUNT(step.value) words
  ```

  # Example

      iex> Expression.evaluate!("@word_count(\\"hello cow-boy\\")")
      3
      iex> Expression.evaluate!("@word_count(\\"hello cow-boy\\", true)")
      2
  """
  def word_count(ctx, binary) do
    binary
    |> eval!(ctx)
    |> String.split(@punctuation_pattern)
    |> Enum.count()
  end

  def word_count(ctx, binary, by_spaces) do
    [binary, by_spaces] = eval_args!([binary, by_spaces], ctx)
    splitter = if(by_spaces, do: " ", else: @punctuation_pattern)

    binary
    |> String.split(splitter)
    |> Enum.count()
  end

  @doc """
  Extracts a substring of the words beginning at start, and up to but not-including stop.
  If stop is omitted then the substring will be all words from start until the end of the text.
  If stop is a negative number, then it is treated as count backwards from the end of the text.
  If by_spaces is specified and is TRUE then the function splits the text into words only by spaces.
  Otherwise the text is split by punctuation characters as well

  # Example

      iex> Expression.evaluate!("@word_slice(\\"RapidPro expressions are fun\\", 2, 4)")
      "expressions are"
      iex> Expression.evaluate!("@word_slice(\\"RapidPro expressions are fun\\", 2)")
      "expressions are fun"
      iex> Expression.evaluate!("@word_slice(\\"RapidPro expressions are fun\\", 1, -2)")
      "RapidPro expressions"
      iex> Expression.evaluate!("@word_slice(\\"RapidPro expressions are fun\\", -1)")
      "fun"
  """
  def word_slice(ctx, binary, start) do
    [binary, start] = eval_args!([binary, start], ctx)

    parts =
      binary
      |> String.split(" ")

    cond do
      start > 0 ->
        parts
        |> Enum.slice(start - 1, length(parts))
        |> Enum.join(" ")

      start < 0 ->
        parts
        |> Enum.slice(start..length(parts))
        |> Enum.join(" ")
    end
  end

  def word_slice(ctx, binary, start, stop) do
    [binary, start, stop] = eval_args!([binary, start, stop], ctx)

    cond do
      stop > 0 ->
        binary
        |> String.split(@punctuation_pattern)
        |> Enum.slice((start - 1)..(stop - 2))
        |> Enum.join(" ")

      stop < 0 ->
        binary
        |> String.split(@punctuation_pattern)
        |> Enum.slice((start - 1)..(stop - 1))
        |> Enum.join(" ")
    end
  end

  def word_slice(ctx, binary, start, stop, by_spaces) do
    [binary, start, stop, by_spaces] = eval_args!([binary, start, stop, by_spaces], ctx)
    splitter = if(by_spaces, do: " ", else: @punctuation_pattern)

    case stop do
      stop when stop > 0 ->
        binary
        |> String.split(splitter)
        |> Enum.slice((start - 1)..(stop - 2))
        |> Enum.join(" ")

      stop when stop < 0 ->
        binary
        |> String.split(splitter)
        |> Enum.slice((start - 1)..(stop - 1))
        |> Enum.join(" ")
    end
  end

  @doc """
  Returns TRUE if the argument is a number.

  # Example

      iex> Expression.evaluate!("@isnumber(1)")
      true
      iex> Expression.evaluate!("@isnumber(1.0)")
      true
      iex> Expression.evaluate!("@isnumber(dec)", %{"dec" => Decimal.new("1.0")})
      true
      iex> Expression.evaluate!("@isnumber(\\"1.0\\")")
      true
      iex> Expression.evaluate!("@isnumber(\\"a\\")")
      false

  """
  def isnumber(ctx, var) do
    var = eval!(var, ctx)

    case var do
      var when is_float(var) or is_integer(var) ->
        true

      var when is_struct(var, Decimal) ->
        true

      var when is_binary(var) ->
        Decimal.new(var)
        true

      _var ->
        false
    end
  rescue
    Decimal.Error -> false
  end

  @doc """
  Returns TRUE if the argument is a boolean.

  # Example

      iex> Expression.evaluate!("@isbool(true)")
      true
      iex> Expression.evaluate!("@isbool(false)")
      true
      iex> Expression.evaluate!("@isbool(1)")
      false
      iex> Expression.evaluate!("@isbool(0)")
      false
      iex> Expression.evaluate!("@isbool(\\"true\\")")
      false
      iex> Expression.evaluate!("@isbool(\\"false\\")")
      false
  """
  def isbool(ctx, var) do
    eval!(var, ctx) in [true, false]
  end

  @doc """
  Returns TRUE if the argument is a string.

  # Example

      iex> Expression.evaluate!("@isstring(\\"hello\\")")
      true
      iex> Expression.evaluate!("@isstring(false)")
      false
      iex> Expression.evaluate!("@isstring(1)")
      false
      iex> Expression.evaluate!("@isstring(d)", %{"d" => Decimal.new("1.0")})
      false
  """
  def isstring(ctx, binary), do: is_binary(eval!(binary, ctx))

  defp search_words(haystack, words) do
    patterns =
      words
      |> String.split(" ")
      |> Enum.map(&Regex.escape/1)
      |> Enum.map(&Regex.compile!(&1, "i"))

    results =
      patterns
      |> Enum.map(&Regex.run(&1, haystack))
      |> Enum.map(fn
        [match] -> match
        nil -> nil
      end)
      |> Enum.reject(&is_nil/1)

    {patterns, results}
  end

  @doc """
  Tests whether all the words are contained in text

  The words can be in any order and may appear more than once.

  # Example

      iex> Expression.evaluate!("@has_all_words(\\"the quick brown FOX\\", \\"the fox\\")")
      true
      iex> Expression.evaluate!("@has_all_words(\\"the quick brown FOX\\", \\"red fox\\")")
      false

  """
  def has_all_words(ctx, haystack, words) do
    [haystack, words] = eval_args!([haystack, words], ctx)
    {patterns, results} = search_words(haystack, words)
    # future match result: Enum.join(results, " ")
    Enum.count(patterns) == Enum.count(results)
  end

  @doc """
  Tests whether any of the words are contained in the text

  Only one of the words needs to match and it may appear more than once.

  # Example

      iex> Expression.evaluate!("@has_any_word(\\"The Quick Brown Fox\\", \\"fox quick\\")")
      true
      iex> Expression.evaluate!("@has_any_word(\\"The Quick Brown Fox\\", \\"yellow\\")")
      false

  """
  def has_any_word(ctx, haystack, words) do
    [haystack, words] = eval_args!([haystack, words], ctx)
    haystack_words = String.split(haystack)
    haystacks_lowercase = Enum.map(haystack_words, &String.downcase/1)
    words_lowercase = String.split(words) |> Enum.map(&String.downcase/1)

    matched_indices =
      haystacks_lowercase
      |> Enum.with_index()
      |> Enum.filter(fn {haystack_word, _index} ->
        Enum.member?(words_lowercase, haystack_word)
      end)
      |> Enum.map(fn {_haystack_word, index} -> index end)

    matched_haystack_words = Enum.map(matched_indices, &Enum.at(haystack_words, &1))

    %{
      "__value__" => Enum.any?(matched_haystack_words),
      "match" => Enum.join(matched_haystack_words, " ")
    }
  end

  @doc """
  Tests whether text starts with beginning

  Both text values are trimmed of surrounding whitespace, but otherwise matching is
  strict without any tokenization.

  # Example

      iex> Expression.evaluate!("@has_beginning(\\"The Quick Brown\\", \\"the quick\\")")
      true
      iex> Expression.evaluate!("@has_beginning(\\"The Quick Brown\\", \\"the    quick\\")")
      false
      iex> Expression.evaluate!("@has_beginning(\\"The Quick Brown\\", \\"quick brown\\")")
      false

  """
  def has_beginning(ctx, text, beginning) do
    [text, beginning] = eval_args!([text, beginning], ctx)

    case Regex.run(~r/^#{Regex.escape(beginning)}/i, text) do
      # future match result: first
      [_first | _remainder] -> true
      nil -> false
    end
  end

  defp extract_dateish(expression) do
    expression = Regex.replace(~r/[a-z]/u, expression, "")

    case DateTimeParser.parse_date(expression) do
      {:ok, date} -> date
      {:error, _} -> nil
    end
  end

  @doc """
  Tests whether `expression` contains a date formatted according to our environment

  This is very naively implemented with a regular expression.

  Supported:

  # Example

      iex> Expression.evaluate!("@has_date(\\"the date is 15/01/2017\\")")
      true
      iex> Expression.evaluate!("@has_date(\\"there is no date here, just a year 2017\\")")
      false

  """
  def has_date(ctx, expression) do
    !!extract_dateish(eval!(expression, ctx))
  end

  @doc """
  Tests whether `expression` is a date equal to `date_string`

  # Examples

      iex> Expression.evaluate!("@has_date_eq(\\"the date is 15/01/2017\\", \\"2017-01-15\\")")
      true
      iex> Expression.evaluate!("@has_date_eq(\\"there is no date here, just a year 2017\\", \\"2017-01-15\\")")
      false
  """
  def has_date_eq(ctx, expression, date_string) do
    [expression, date_string] = eval_args!([expression, date_string], ctx)
    found_date = extract_dateish(expression)
    test_date = extract_dateish(date_string)
    # Future match result: found_date
    found_date == test_date
  end

  @doc """
  Tests whether `expression` is a date after the date `date_string`

  # Example

      iex> Expression.evaluate!("@has_date_gt(\\"the date is 15/01/2017\\", \\"2017-01-01\\")")
      true
      iex> Expression.evaluate!("@has_date_gt(\\"the date is 15/01/2017\\", \\"2017-03-15\\")")
      false

  """
  def has_date_gt(ctx, expression, date_string) do
    [expression, date_string] = eval_args!([expression, date_string], ctx)
    found_date = extract_dateish(expression)
    test_date = extract_dateish(date_string)
    # future match result: found_date
    Date.compare(found_date, test_date) == :gt
  end

  @doc """
  Tests whether `expression` contains a date before the date `date_string`

  # Example

      iex> Expression.evaluate!("@has_date_lt(\\"the date is 15/01/2017\\", \\"2017-06-01\\")")
      true
      iex> Expression.evaluate!("@has_date_lt(\\"the date is 15/01/2021\\", \\"2017-03-15\\")")
      false

  """
  def has_date_lt(ctx, expression, date_string) do
    [expression, date_string] = eval_args!([expression, date_string], ctx)
    found_date = extract_dateish(expression)
    test_date = extract_dateish(date_string)
    # future match result: found_date
    Date.compare(found_date, test_date) == :lt
  end

  @doc """
  Tests whether an email is contained in text

  # Example:

      iex> Expression.evaluate!("@has_email(\\"my email is foo1@bar.com, please respond\\")")
      true
      iex> Expression.evaluate!("@has_email(\\"i'm not sharing my email\\")")
      false

  """
  def has_email(ctx, expression) do
    expression = eval!(expression, ctx)

    case Regex.run(~r/([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/, expression) do
      # future match result: match
      [_match | _] -> true
      nil -> false
    end
  end

  @doc """
  Returns whether the contact is part of group with the passed in UUID

  # Example:

      iex> contact = %{
      ...>   "groups" => [%{
      ...>     "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d"
      ...>   }]
      ...> }
      iex> Expression.evaluate!("@has_group(contact.groups, \\"b7cf0d83-f1c9-411c-96fd-c511a4cfa86d\\")", %{"contact" => contact})
      true
      iex> Expression.evaluate!("@has_group(contact.groups, \\"00000000-0000-0000-0000-000000000000\\")", %{"contact" => contact})
      false

  """
  def has_group(ctx, groups, uuid) do
    [groups, uuid] = eval_args!([groups, uuid], ctx)
    group = Enum.find(groups, nil, &(&1["uuid"] == uuid))
    # future match result: group
    !!group
  end

  defp extract_numberish(expression) do
    with [match] <-
           Regex.run(~r/([0-9]+\.?[0-9]+)/u, replace_arabic_numerals(expression), capture: :first),
         {decimal, ""} <- Decimal.parse(match) do
      decimal
    else
      # Regex can return nil
      nil -> nil
      # Decimal parsing can return :error
      :error -> nil
    end
  end

  defp replace_arabic_numerals(expression) do
    replace_numerals(expression, %{
      "٠" => "0",
      "١" => "1",
      "٢" => "2",
      "٣" => "3",
      "٤" => "4",
      "٥" => "5",
      "٦" => "6",
      "٧" => "7",
      "٨" => "8",
      "٩" => "9"
    })
  end

  defp replace_numerals(expression, mapping) do
    mapping
    |> Enum.reduce(expression, fn {rune, replacement}, expression ->
      String.replace(expression, rune, replacement)
    end)
  end

  defp parse_decimal(decimal) when is_struct(decimal, Decimal), do: decimal
  defp parse_decimal(float) when is_float(float), do: Decimal.from_float(float)

  defp parse_decimal(number) when is_number(number), do: Decimal.new(number)

  defp parse_decimal(binary) when is_binary(binary) do
    case Decimal.parse(binary) do
      {decimal, ""} -> decimal
      :error -> :error
    end
  end

  @doc """
  Tests whether `expression` contains a number

  # Example

      iex> true = Expression.evaluate!("@has_number(\\"the number is 42 and 5\\")")
      iex> true = Expression.evaluate!("@has_number(\\"العدد ٤٢\\")")
      iex> true = Expression.evaluate!("@has_number(\\"٠.٥\\")")
      iex> true = Expression.evaluate!("@has_number(\\"0.6\\")")

  """
  def has_number(ctx, expression) do
    expression = eval!(expression, ctx)
    number = extract_numberish(expression)
    # future match result: number
    !!number
  end

  @doc """
  Tests whether `expression` contains a number equal to the value

  # Example

      iex> true = Expression.evaluate!("@has_number_eq(\\"the number is 42\\", 42)")
      iex> true = Expression.evaluate!("@has_number_eq(\\"the number is 42\\", 42.0)")
      iex> true = Expression.evaluate!("@has_number_eq(\\"the number is 42\\", \\"42\\")")
      iex> true = Expression.evaluate!("@has_number_eq(\\"the number is 42.0\\", \\"42\\")")
      iex> false = Expression.evaluate!("@has_number_eq(\\"the number is 40\\", \\"42\\")")
      iex> false = Expression.evaluate!("@has_number_eq(\\"the number is 40\\", \\"foo\\")")
      iex> false = Expression.evaluate!("@has_number_eq(\\"four hundred\\", \\"foo\\")")

  """
  def has_number_eq(ctx, expression, decimal) do
    [expression, decimal] = eval_args!([expression, decimal], ctx)

    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      Decimal.eq?(number, decimal)
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number greater than min

  # Example

      iex> true = Expression.evaluate!("@has_number_gt(\\"the number is 42\\", 40)")
      iex> true = Expression.evaluate!("@has_number_gt(\\"the number is 42\\", 40.0)")
      iex> true = Expression.evaluate!("@has_number_gt(\\"the number is 42\\", \\"40\\")")
      iex> true = Expression.evaluate!("@has_number_gt(\\"the number is 42.0\\", \\"40\\")")
      iex> false = Expression.evaluate!("@has_number_gt(\\"the number is 40\\", \\"40\\")")
      iex> false = Expression.evaluate!("@has_number_gt(\\"the number is 40\\", \\"foo\\")")
      iex> false = Expression.evaluate!("@has_number_gt(\\"four hundred\\", \\"foo\\")")
  """
  def has_number_gt(ctx, expression, decimal) do
    [expression, decimal] = eval_args!([expression, decimal], ctx)

    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      Decimal.gt?(number, decimal)
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number greater than or equal to min

  # Example

      iex> true = Expression.evaluate!("@has_number_gte(\\"the number is 42\\", 42)")
      iex> true = Expression.evaluate!("@has_number_gte(\\"the number is 42\\", 42.0)")
      iex> true = Expression.evaluate!("@has_number_gte(\\"the number is 42\\", \\"42\\")")
      iex> false = Expression.evaluate!("@has_number_gte(\\"the number is 42.0\\", \\"45\\")")
      iex> false = Expression.evaluate!("@has_number_gte(\\"the number is 40\\", \\"45\\")")
      iex> false = Expression.evaluate!("@has_number_gte(\\"the number is 40\\", \\"foo\\")")
      iex> false = Expression.evaluate!("@has_number_gte(\\"four hundred\\", \\"foo\\")")
  """
  def has_number_gte(ctx, expression, decimal) do
    [expression, decimal] = eval_args!([expression, decimal], ctx)

    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      Decimal.gt?(number, decimal) || Decimal.eq?(number, decimal)
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number less than max

  # Example

      iex> true = Expression.evaluate!("@has_number_lt(\\"the number is 42\\", 44)")
      iex> true = Expression.evaluate!("@has_number_lt(\\"the number is 42\\", 44.0)")
      iex> false = Expression.evaluate!("@has_number_lt(\\"the number is 42\\", \\"40\\")")
      iex> false = Expression.evaluate!("@has_number_lt(\\"the number is 42.0\\", \\"40\\")")
      iex> false = Expression.evaluate!("@has_number_lt(\\"the number is 40\\", \\"40\\")")
      iex> false = Expression.evaluate!("@has_number_lt(\\"the number is 40\\", \\"foo\\")")
      iex> false = Expression.evaluate!("@has_number_lt(\\"four hundred\\", \\"foo\\")")
  """
  def has_number_lt(ctx, expression, decimal) do
    [expression, decimal] = eval_args!([expression, decimal], ctx)

    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      Decimal.lt?(number, decimal)
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number less than or equal to max

  # Example

      iex> true = Expression.evaluate!("@has_number_lte(\\"the number is 42\\", 42)")
      iex> true = Expression.evaluate!("@has_number_lte(\\"the number is 42\\", 42.0)")
      iex> true = Expression.evaluate!("@has_number_lte(\\"the number is 42\\", \\"42\\")")
      iex> false = Expression.evaluate!("@has_number_lte(\\"the number is 42.0\\", \\"40\\")")
      iex> false = Expression.evaluate!("@has_number_lte(\\"the number is 40\\", \\"foo\\")")
      iex> false = Expression.evaluate!("@has_number_lte(\\"four hundred\\", \\"foo\\")")

  """
  def has_number_lte(ctx, expression, decimal) do
    [expression, decimal] = eval_args!([expression, decimal], ctx)

    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      Decimal.lt?(number, decimal) || Decimal.eq?(number, decimal)
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether the text contains only phrase

  The phrase must be the only text in the text to match

  # Example

      iex> Expression.evaluate!("@has_only_phrase(\\"Quick Brown\\", \\"quick brown\\")")
      true
      iex> Expression.evaluate!("@has_only_phrase(\\"\\", \\"\\")")
      true
      iex> Expression.evaluate!("@has_only_phrase(\\"The Quick Brown Fox\\", \\"quick brown\\")")
      false

  """
  def has_only_phrase(ctx, expression, phrase) do
    [expression, phrase] = eval_args!([expression, phrase], ctx)

    case Enum.map([expression, phrase], &String.downcase/1) do
      # Future match result: expression
      [same, same] -> true
      _anything_else -> false
    end
  end

  @doc """
  Returns whether two text values are equal (case sensitive). In the case that they are, it will return the text as the match.

  # Example

      iex> Expression.evaluate!("@has_only_text(\\"foo\\", \\"foo\\")")
      true
      iex> Expression.evaluate!("@has_only_text(\\"\\", \\"\\")")
      true
      iex> Expression.evaluate!("@has_only_text(\\"foo\\", \\"FOO\\")")
      false

  """
  def has_only_text(ctx, expression_one, expression_two) do
    [expression_one, expression_two] = eval_args!([expression_one, expression_two], ctx)
    expression_one == expression_two
  end

  @doc """
  Tests whether `expression` matches the regex pattern

  Both text values are trimmed of surrounding whitespace and matching is case-insensitive.

  # Examples

      iex> Expression.evaluate!("@has_pattern(\\"Buy cheese please\\", \\"buy (\\\\w+)\\")")
      true
      iex> Expression.evaluate!("@has_pattern(\\"Sell cheese please\\", \\"buy (\\\\w+)\\")")
      false

  """
  def has_pattern(ctx, expression, pattern) do
    [expression, pattern] = eval_args!([expression, pattern], ctx)

    with {:ok, regex} <- Regex.compile(String.trim(pattern), "i"),
         [[_first | _remainder]] <- Regex.scan(regex, String.trim(expression), capture: :all) do
      # Future match result: first
      true
    else
      _ -> false
    end
  end

  @doc """
  Tests whether `expresssion` contains a phone number.
  The optional country_code argument specifies the country to use for parsing.

  # Example

      iex> Expression.evaluate!("@has_phone(\\"my number is +12067799294 thanks\\")")
      true
      iex> Expression.evaluate!("@has_phone(\\"my number is 2067799294 thanks\\", \\"US\\")")
      true
      iex> Expression.evaluate!("@has_phone(\\"my number is 206 779 9294 thanks\\", \\"US\\")")
      true
      iex> Expression.evaluate!("@has_phone(\\"my number is none of your business\\", \\"US\\")")
      false

  """
  def has_phone(ctx, expression) do
    [expression] = eval_args!([expression], ctx)
    letters_removed = Regex.replace(~r/[a-z]/i, expression, "")

    case ExPhoneNumber.parse(letters_removed, "") do
      # Future match result: ExPhoneNumber.format(pn, :es164)
      {:ok, _pn} -> true
      _ -> false
    end
  end

  def has_phone(ctx, expression, country_code) do
    [expression, country_code] = eval_args!([expression, country_code], ctx)
    letters_removed = Regex.replace(~r/[a-z]/i, expression, "")

    case ExPhoneNumber.parse(letters_removed, country_code) do
      # Future match result: ExPhoneNumber.format(pn, :es164)
      {:ok, _pn} -> true
      _ -> false
    end
  end

  @doc """
  Tests whether phrase is contained in `expression`

  The words in the test phrase must appear in the same order with no other words in between.

  # Examples

      iex> Expression.evaluate!("@has_phrase(\\"the quick brown fox\\", \\"brown fox\\")")
      true
      iex> Expression.evaluate!("@has_phrase(\\"the quick brown fox\\", \\"quick fox\\")")
      false
      iex> Expression.evaluate!("@has_phrase(\\"the quick brown fox\\", \\"\\")")
      true

  """
  def has_phrase(ctx, expression, phrase) do
    [expression, phrase] = eval_args!([expression, phrase], ctx)
    lower_expression = String.downcase(expression)
    lower_phrase = String.downcase(phrase)
    found? = String.contains?(lower_expression, lower_phrase)
    # Future match result: phrase
    found?
  end

  @doc """
  Tests whether there the `expression` has any characters in it

  # Examples

      iex> Expression.evaluate!("@has_text(\\"quick brown\\")")
      true
      iex> Expression.evaluate!("@has_text(\\"\\")")
      false
      iex> Expression.evaluate!("@has_text(\\" \\n\\")")
      false
      iex> Expression.evaluate!("@has_text(123)")
      true
  """
  def has_text(ctx, expression) do
    expression = eval!(expression, ctx) |> to_string()
    String.trim(expression) != ""
  end

  @doc """
  Tests whether `expression` contains a time.

  # Examples

      iex> Expression.evaluate!("@has_time(\\"the time is 10:30\\")")
      true
      iex> Expression.evaluate!("@has_time(\\"the time is 10:00 pm\\")")
      true
      iex> Expression.evaluate!("@has_time(\\"the time is 10:30:45\\")")
      true
      iex> Expression.evaluate!("@has_time(\\"there is no time here, just the number 25\\")")
      false

  """
  def has_time(ctx, expression) do
    case DateTimeParser.parse_time(eval!(expression, ctx)) do
      # Future match result: time
      {:ok, _time} -> true
      _ -> false
    end
  end

  def map(ctx, enumerable, mapper) do
    [enumerable, mapper] = eval_args!([enumerable, mapper], ctx)

    enumerable
    # wrap in a list to be passed as a list of arguments
    |> Enum.map(&[&1])
    # call the mapper with each list of arguments as a single argument
    |> Enum.map(mapper)
  end
end