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.
  """

  @reserved_words ~w[and if or]

  @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
  """
  @spec handle(function_name :: binary, arguments :: [any], context :: map) ::
          {:ok, any} | {:error, :not_implemented}
  def handle(function_name, arguments, context) 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) ->
        {:ok, apply(__MODULE__, exact_function_name, [context] ++ arguments)}

      # Check if it's been implemented to accept a variable amount of arguments
      function_exported?(__MODULE__, vargs_function_name, 2) ->
        {:ok, apply(__MODULE__, vargs_function_name, [context, arguments])}

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

  @doc """
  Defines a new date value

  ```
  This is a date @DATE(2012, 12, 25)
  ```

  # Example

      iex> to_string(Expression.Callbacks.date(%{}, 2012, 12, 25))
      "2012-12-25 00:00:00Z"

  """
  def date(_ctx, year, month, day) do
    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

  ```
  You joined on @DATEVALUE(contact.joined_date, "%Y-%m%-d")
  ```

  # Example

      iex> date = Expression.Callbacks.date(%{}, 2020, 12, 20)
      iex> Expression.Callbacks.datevalue(%{}, date)
      "2020-12-20 00:00:00"
      iex> Expression.Callbacks.datevalue(%{}, date, "%Y-%m-%d")
      "2020-12-20"
  """
  def datevalue(ctx, date, format \\ "%Y-%m-%d %H:%M:%S")

  def datevalue(_ctx, date, format) do
    Timex.format!(date, format, :strftime)
  end

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

  ```
  The current day is @DAY(contact.joined_date)
  ```

  # Example

      iex> now = DateTime.utc_now()
      iex> day = Expression.Callbacks.day(%{}, now)
      iex> day == now.day
      true
  """
  def day(_ctx, %{day: day} = _date) do
    day
  end

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

  ```
  Next month's meeting will be on @EDATE(date.today, 1)
  ```

  # Example

      iex> now = DateTime.utc_now()
      iex> future = Timex.shift(now, months: 1)
      iex> date = Expression.Callbacks.edate(%{}, now, 1)
      iex> future == date
      true
  """
  def edate(_ctx, date, months) do
    date |> Timex.shift(months: months)
  end

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

  ```
  The current hour is @HOUR(NOW())
  ```

  # Example

      iex> now = DateTime.utc_now()
      iex> hour = Expression.Callbacks.hour(%{}, now)
      iex> now.hour == hour
      true
  """
  def hour(_ctx, %{hour: hour} = _date) do
    hour
  end

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

  ```
  The current minute is @MINUTE(NOW())
  ```

  # Example

      iex> now = DateTime.utc_now()
      iex> minute = Expression.Callbacks.minute(%{}, now)
      iex> now.minute == minute
      true
  """
  def minute(_ctx, %{minute: minute} = _date) do
    minute
  end

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

  ```
  The current month is @MONTH(NOW())
  ```

  # Example

      iex> now = DateTime.utc_now()
      iex> month = Expression.Callbacks.month(%{}, now)
      iex> now.month == month
      true
  """
  def month(_ctx, %{month: month} = _date) do
    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)

  ```
  The current second is @SECOND(NOW())
  ```

  # Example

      iex> now = DateTime.utc_now()
      iex> second = Expression.Callbacks.second(%{}, now)
      iex> now.second == second
      true

  """
  def second(_ctx, %{second: second} = _date) do
    second
  end

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

  ```
  2 hours and 30 minutes from now is @(date.now + TIME(2, 30, 0))
  ```

  # Example

      iex> Expression.Callbacks.time(%{}, 12, 13, 14)
      %Time{hour: 12, minute: 13, second: 14}

  """
  def time(_ctx, hours, minutes, seconds) do
    %Time{hour: hours, minute: minutes, second: seconds}
  end

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

  ```
  Your appointment is at @(date.today + TIME("2:30"))
  ```

  # Example

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

      iex> Expression.Callbacks.timevalue(%{}, "2:30:55")
      %Time{hour: 2, minute: 30, second: 55}
  """
  def timevalue(_ctx, expression) do
    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)

  ```
  Today is day no. @WEEKDAY(TODAY()) in the week
  ```

  # Example

      iex> today = DateTime.utc_now()
      iex> expected = Timex.weekday(today)
      iex> weekday = Expression.Callbacks.weekday(%{}, today)
      iex> weekday == expected
      true
  """
  def weekday(_ctx, date) do
    Timex.weekday(date)
  end

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

  ```
  The current year is @YEAR(NOW())
  ```

  # Example

      iex> %{year: year} = now = DateTime.utc_now()
      iex> year == Expression.Callbacks.year(%{}, now)

  """
  def year(_ctx, %{year: year} = _date) do
    year
  end

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

  ```
  @AND(contact.gender = "F", contact.age >= 18)
  ```

  # Example

      iex> Expression.Callbacks.handle("and", [true, true], %{})
      {:ok, true}
      iex> Expression.Callbacks.and_vargs(%{}, [true, true])
      true
      iex> Expression.Callbacks.and_vargs(%{}, [true, false])
      false
      iex> Expression.Callbacks.and_vargs(%{}, [false, false])
      false

  """
  def and_vargs(_ctx, arguments) do
    Enum.all?(arguments, fn
      true -> true
      _other -> false
    end)
  end

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

  ```
  Dear @IF(contact.gender = "M", "Sir", "Madam")
  ```

  # Example

      iex> Expression.Callbacks.handle("if", [true, "Yes", "No"], %{})
      {:ok, "Yes"}
      iex> Expression.Callbacks.handle("if", [false, "Yes", "No"], %{})
      {:ok, "No"}
  """
  def if_(_ctx, condition, yes, no) do
    if(condition, do: yes, else: no)
  end

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

  ```
  @OR(contact.state = "GA", contact.state = "WA", contact.state = "IN")
  ```

  # Example

      iex> Expression.Callbacks.handle("or", [true, false], %{})
      {:ok, true}
      iex> Expression.Callbacks.handle("or", [true, true], %{})
      {:ok, true}
      iex> Expression.Callbacks.handle("or", [false, false], %{})
      {:ok, false}
  """
  def or_vargs(_ctx, arguments) do
    Enum.any?(arguments, fn
      true -> true
      _anything_else -> false
    end)
  end

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

  ```
  The absolute value of -1 is @ABS(-1)
  ```

  # Example

      iex> Expression.Callbacks.abs(%{}, -1)
      1
  """
  def abs(_ctx, number) do
    abs(number)
  end

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

  ```
  Please complete at most @MAX(flow.questions, 10) questions
  ```

  # Example

      iex> Expression.Callbacks.handle("max", [1, 2, 3], %{})
      {:ok, 3}
  """
  def max_vargs(_ctx, arguments) do
    Enum.max(arguments)
  end

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

  ```
  Please complete at least @MIN(flow.questions, 10) questions
  ```

  #  Example

      iex> Expression.Callbacks.handle("min", [1, 2, 3], %{})
      {:ok, 1}
  """
  def min_vargs(_ctx, arguments) do
    Enum.min(arguments)
  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
    :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.Callbacks.handle("sum", [1, 2, 3], %{})
      {:ok, 6}

  """
  def sum_vargs(_ctx, arguments) do
    Enum.sum(arguments)
  end

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

  ```
  As easy as @CHAR(65), @CHAR(66), @CHAR(67)
  ```

  # Example

      iex> Expression.Callbacks.char(%{}, 65)
      "A"

  """
  def char(_ctx, code) do
    <<code>>
  end

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

  ```
  You entered @CLEAN(step.value)
  ```

  # Example

      iex> Expression.Callbacks.clean(%{}, <<65, 0, 66, 0, 67>>)
      "ABC"
  """
  def clean(_ctx, binary) do
    binary
    |> String.graphemes()
    |> Enum.filter(&String.printable?/1)
    |> Enum.join("")
  end

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

  ```
  The numeric code of A is @CODE("A")
  ```

  # Example

      iex> Expression.Callbacks.code(%{}, "A")
      65
  """
  def code(_ctx, <<code>>) do
    code
  end

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

  ```
  Your name is @CONCATENATE(contact.first_name, " ", contact.last_name)
  ```

  # Example

      iex> Expression.Callbacks.handle("concatenate", ["name", " ", "surname"], %{})
      {:ok, "name surname"}
  """
  def concatenate_vargs(_ctx, arguments) do
    Enum.join(arguments, "")
  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.Callbacks.fixed(%{}, 4.209922, 2, false)
      "4.21"
      iex> Expression.Callbacks.fixed(%{}, 4000.424242, 4, true)
      "4,000.4242"
      iex> Expression.Callbacks.fixed(%{}, 3.7979, 2, false)
      "3.80"
      iex> Expression.Callbacks.fixed(%{}, 3.7979, 2)
      "3.80"

  """
  def fixed(_ctx, number, precision, no_commas \\ false)

  def fixed(_ctx, number, precision, true) do
    Number.Delimit.number_to_delimited(number,
      precision: precision,
      delimiter: ",",
      separator: "."
    )
  end

  def fixed(_ctx, number, precision, false) do
    Number.Delimit.number_to_delimited(number, precision: precision)
  end

  @doc """
  Returns the first characters in a text string

  ```
  You entered PIN @LEFT(step.value, 4)
  ```

  # Example

      iex> Expression.Callbacks.left(%{}, "foobar", 4)
      "foob"
  """
  def left(_ctx, binary, size) do
    binary_part(binary, 0, size)
  end

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

  ```
  You entered @LEN(step.value) characters
  ```

  # Example

      iex> Expression.Callbacks.len(%{}, "foo")
      3
      iex> Expression.Callbacks.len(%{}, "zoë")
      3
  """
  def len(_ctx, binary) do
    String.length(binary)
  end

  @doc """
  Converts a text string to lowercase

  ````
  Welcome @LOWER(contact)
  ```

  # Example

      iex> Expression.Callbacks.lower(%{}, "Foo Bar")
      "foo bar"

  """
  def lower(_ctx, binary) do
    String.downcase(binary)
  end

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

  ```
  Your name is @PROPER(contact)
  ```

  # Example

      iex> Expression.Callbacks.proper(%{}, "foo bar")
      "Foo Bar"
  """
  def proper(_ctx, binary) do
    binary
    |> String.split(" ")
    |> Enum.map(&String.capitalize/1)
    |> Enum.join(" ")
  end

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

  ```
  Stars! @REPT("*", 10)
  ```

  # Example

      iex> Expression.Callbacks.rept(%{}, "*", 10)
      "**********"
  """
  def rept(_ctx, value, amount) do
    String.duplicate(value, amount)
  end

  @doc """
  Returns the last characters in a text string

  ```
  Your input ended with ...@RIGHT(step.value, 3)
  ```

  # Example

      iex> Expression.Callbacks.right(%{}, "testing", 3)
      "ing"

  """
  def right(_ctx, binary, size) do
    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

  ```
  @SUBSTITUTE(step.value, "can't", "can")
  ```

  # Example

    iex> Expression.Callbacks.substitute(%{}, "I can't", "can't", "can do")
    "I can do"

  """
  def substitute(%{}, subject, pattern, replacement) do
    String.replace(subject, pattern, replacement)
  end

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

  ```
  As easy as @UNICHAR(65), @UNICHAR(66) , @UNICHAR(67)
  ```

  # Example

    iex> Expression.Callbacks.unichar(%{}, 65)
    "A"
    iex> Expression.Callbacks.unichar(%{}, 233)
    "é"

  """
  def unichar(_ctx, code) do
    <<code::utf8>>
  end

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

  ```
  The numeric code of A is @UNICODE("A")
  ```

  # Example

      iex> Expression.Callbacks.unicode(%{}, "A")
      65
      iex> Expression.Callbacks.unicode(%{}, "é")
      233
  """
  def unicode(_ctx, <<code::utf8>>) do
    code
  end

  @doc """
  Converts a text string to uppercase

  ```
  WELCOME @UPPER(contact)!!
  ```

  # Example

      iex> Expression.Callbacks.upper(%{}, "foo")
      "FOO"
  """
  def upper(_ctx, binary) do
    String.upcase(binary)
  end

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

  ```
  The first word you entered was @FIRST_WORD(step.value)
  ```

  # Example

      iex> Expression.Callbacks.first_word(%{}, "foo bar baz")
      "foo"

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

  @doc """
  Formats a number as a percentage

  ```
  You've completed @PERCENT(contact.reports_done / 10) reports
  ```

  # Example

      iex> Expression.Callbacks.percent(%{}, 2/10)
      "20%"
      iex> Expression.Callbacks.percent(%{}, "0.2")
      "20%"
      iex> Expression.Callbacks.percent(%{}, Decimal.new("0.2"))
      "20%"
  """
  @spec percent(Expression.Context.t(), float) :: binary
  def percent(ctx, float) when is_float(float) do
    percent(ctx, Decimal.from_float(float))
  end

  @spec percent(Expression.Context.t(), binary) :: binary
  def percent(ctx, binary) when is_binary(binary) do
    percent(ctx, Decimal.new(binary))
  end

  @spec percent(Expression.Context.t(), Decimal.t()) :: binary
  def percent(_ctx, decimal) do
    Number.Percentage.number_to_percentage(Decimal.mult(decimal, 100), precision: 0)
  end

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

  ```
  Your number is @READ_DIGITS(contact.tel_e164)
  ```

  # Example

      iex> Expression.Callbacks.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
    |> 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

  ```
  You entered @REMOVE_FIRST_WORD(step.value)
  ```

  # Example

      iex> Expression.Callbacks.remove_first_word(%{}, "foo bar")
      "bar"
      iex> Expression.Callbacks.remove_first_word(%{}, "foo-bar", "-")
      "bar"
  """
  def remove_first_word(_ctx, binary, separator \\ " ")

  def remove_first_word(_ctx, binary, separator) do
    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.Callbacks.word(%{}, "hello cow-boy", 2)
      "cow"
      iex> Expression.Callbacks.word(%{}, "hello cow-boy", 2, true)
      "cow-boy"
      iex> Expression.Callbacks.word(%{}, "hello cow-boy", -1)
      "boy"

  """
  def word(ctx, binary, n, by_spaces \\ false)

  def word(_ctx, binary, n, by_spaces) do
    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.Callbacks.word_count(%{}, "hello cow-boy")
      3
      iex> Expression.Callbacks.word_count(%{}, "hello cow-boy", true)
      2
  """
  def word_count(ctx, binary, by_spaces \\ false)

  def word_count(_ctx, binary, by_spaces) do
    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.Callbacks.word_slice(%{}, "RapidPro expressions are fun", 2, 4)
      "expressions are"
      iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", 2)
      "expressions are fun"
      iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", 1, -2)
      "RapidPro expressions"
      iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", -1)
      "fun"
  """
  def word_slice(_ctx, binary, start) when start > 0 do
    parts =
      binary
      |> String.split(" ")

    parts
    |> Enum.slice(start - 1, length(parts))
    |> Enum.join(" ")
  end

  def word_slice(_ctx, binary, start) when start < 0 do
    parts =
      binary
      |> String.split(" ")

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

  def word_slice(_ctx, binary, start, stop, by_spaces \\ false)

  def word_slice(_ctx, binary, start, stop, by_spaces) when stop > 0 do
    splitter = if(by_spaces, do: " ", else: @punctuation_pattern)

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

  def word_slice(_ctx, binary, start, stop, by_spaces) when stop < 0 do
    splitter = if(by_spaces, do: " ", else: @punctuation_pattern)

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

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

  ```
  @ISNUMBER(contact.age) will return TRUE if the contact's age is a number.
  ```

  # Example

      iex> Expression.Callbacks.isnumber(%{}, 1)
      true
      iex> Expression.Callbacks.isnumber(%{}, 1.0)
      true
      iex> Expression.Callbacks.isnumber(%{}, Decimal.new("1.0"))
      true
      iex> Expression.Callbacks.isnumber(%{}, "1.0")
      true
      iex> Expression.Callbacks.isnumber(%{}, "a")
      false

  """
  def isnumber(_ctx, var) when is_float(var) or is_integer(var), do: true

  def isnumber(_ctx, %{__struct__: Decimal}), do: true

  def isnumber(_ctx, var) when is_binary(var) do
    Decimal.new(var)
    true
  rescue
    Decimal.Error ->
      false
  end

  def isnumber(_ctx, _var), do: false

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

  ```
  @ISBOOL(block.value) will return TRUE if the block returned a boolean value.
  ```

  # Example

      iex> Expression.Callbacks.isbool(%{}, true)
      true
      iex> Expression.Callbacks.isbool(%{}, false)
      true
      iex> Expression.Callbacks.isbool(%{}, 1)
      false
      iex> Expression.Callbacks.isbool(%{}, 0)
      false
      iex> Expression.Callbacks.isbool(%{}, "true")
      false
      iex> Expression.Callbacks.isbool(%{}, "false")
      false
  """
  def isbool(_ctx, var) when var in [true, false], do: true
  def isbool(_ctx, _var), do: false

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

  ```
  @ISSTRING(contact.name) will return TRUE if the contact's name is a string.
  ```

  # Example

      iex> Expression.Callbacks.isstring(%{}, "hello")
      true
      iex> Expression.Callbacks.isstring(%{}, false)
      false
      iex> Expression.Callbacks.isstring(%{}, 1)
      false
      iex> Expression.Callbacks.isstring(%{}, Decimal.new("1.0"))
      false
  """
  def isstring(_ctx, binary), do: is_binary(binary)

  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.

  ```
  @(has_all_words("the quick brown FOX", "the fox")) → true
  @(has_all_words("the quick brown fox", "red fox")) → false
  ```

  NOTE: the flowspec supports `.match` which isn't support here yet.

  ```
  @(has_all_words("the quick brown FOX", "the fox").match) → the FOX
  ```

  # Example

    iex> Expression.Callbacks.has_all_words(%{}, "the quick brown FOX", "the fox")
    {:ok, true}
    iex> Expression.Callbacks.has_all_words(%{}, "the quick brown FOX", "red fox")
    {:ok, false}

  """
  def has_all_words(_ctx, haystack, words) do
    {patterns, results} = search_words(haystack, words)
    # future match result: Enum.join(results, " ")
    {:ok, 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.

  ```
  @(has_any_word("The Quick Brown Fox", "fox quick")) → true
  ```

  Unsupported:

  ```
  @(has_any_word("The Quick Brown Fox", "fox quick").match) → Quick Fox
  @(has_any_word("The Quick Brown Fox", "red fox").match) → Fox
  ```

  # Example

    iex> Expression.Callbacks.has_any_word(%{}, "The Quick Brown Fox", "fox quick")
    {:ok, true}
    iex> Expression.Callbacks.has_any_word(%{}, "The Quick Brown Fox", "yellow")
    {:ok, false}

  """
  def has_any_word(_ctx, haystack, words) do
    {_patterns, results} = search_words(haystack, words)

    # future match result Enum.join(results, " ")
    {:ok, Enum.any?(results)}
  end

  @doc """
  Tests whether text starts with beginning

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

  Supported:

  ```
  @(has_beginning("The Quick Brown", "the quick")) → true
  @(has_beginning("The Quick Brown", "the   quick")) → false
  @(has_beginning("The Quick Brown", "quick brown")) → false
  ```

  Unsupported

  ```
  @(has_beginning("The Quick Brown", "the quick").match) → The Quick
  ```

  # Example

    iex> Expression.Callbacks.has_beginning(%{}, "The Quick Brown", "the quick")
    {:ok, true}
    iex> Expression.Callbacks.has_beginning(%{}, "The Quick Brown", "the    quick")
    {:ok, false}
    iex> Expression.Callbacks.has_beginning(%{}, "The Quick Brown", "quick brown")
    {:ok, false}

  """
  def has_beginning(_ctx, text, beginning) do
    case Regex.run(~r/^#{Regex.escape(beginning)}/i, text) do
      # future match result: first
      [_first | _remainder] -> {:ok, true}
      nil -> {:ok, 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:

  ```
  @(has_date("the date is 15/01/2017")) → true
  @(has_date("there is no date here, just a year 2017")) → false
  ```

  Unsupported:

  ```
  @(has_date("the date is 15/01/2017").match) → 2017-01-15T13:24:30.123456-05:00
  ```

  # Example

    iex> Expression.Callbacks.has_date(%{}, "the date is 15/01/2017")
    {:ok, true}
    iex> Expression.Callbacks.has_date(%{}, "there is no date here, just a year 2017")
    {:ok, false}

  """
  def has_date(_, expression) do
    date = extract_dateish(expression)
    # future match result: date
    {:ok, !!date}
  end

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

  Supported:

  ```
  @(has_date_eq("the date is 15/01/2017", "2017-01-15")) → true
  @(has_date_eq("there is no date here, just a year 2017", "2017-06-01")) → false
  @(has_date_eq("there is no date here, just a year 2017", "not date")) → ERROR
  ```

  Not supported:

  ```
  @(has_date_eq("the date is 15/01/2017", "2017-01-15").match) → 2017-01-15T13:24:30.123456-05:00
  @(has_date_eq("the date is 15/01/2017 15:00", "2017-01-15").match) → 2017-01-15T15:00:00.000000-05:00
  ```

  # Examples

    iex> Expression.Callbacks.has_date_eq(%{}, "the date is 15/01/2017", "2017-01-15")
    {:ok, true}
    iex> Expression.Callbacks.has_date_eq(%{}, "there is no date here, just a year 2017", "2017-01-15")
    {:ok, false}
  """
  def has_date_eq(_ctx, expression, date_string) do
    found_date = extract_dateish(expression)
    test_date = extract_dateish(date_string)
    # Future match result: found_date
    {:ok, found_date == test_date}
  end

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

  ```
  @(has_date_gt("the date is 15/01/2017", "2017-01-01")) → true
  @(has_date_gt("the date is 15/01/2017", "2017-03-15")) → false
  @(has_date_gt("there is no date here, just a year 2017", "2017-06-01")) → false
  @(has_date_gt("there is no date here, just a year 2017", "not date")) → ERROR
  ```

  Not supported:

  ```
  @(has_date_gt("the date is 15/01/2017", "2017-01-01").match) → 2017-01-15T13:24:30.123456-05:00
  ```

  # Example

    iex> Expression.Callbacks.has_date_gt(%{}, "the date is 15/01/2017", "2017-01-01")
    {:ok, true}
    iex> Expression.Callbacks.has_date_gt(%{}, "the date is 15/01/2017", "2017-03-15")
    {:ok, false}

  """
  def has_date_gt(_ctx, expression, date_string) do
    found_date = extract_dateish(expression)
    test_date = extract_dateish(date_string)
    # future match result: found_date
    {:ok, Date.compare(found_date, test_date) == :gt}
  end

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

  ```
  @(has_date_lt("the date is 15/01/2017", "2017-06-01")) → true
  @(has_date_lt("there is no date here, just a year 2017", "2017-06-01")) → false
  @(has_date_lt("there is no date here, just a year 2017", "not date")) → ERROR
  ```

  Not supported:

  ```
  @(has_date_lt("the date is 15/01/2017", "2017-06-01").match) → 2017-01-15T13:24:30.123456-05:00
  ```

  # Example

    iex> Expression.Callbacks.has_date_lt(%{}, "the date is 15/01/2017", "2017-06-01")
    {:ok, true}
    iex> Expression.Callbacks.has_date_lt(%{}, "the date is 15/01/2021", "2017-03-15")
    {:ok, false}

  """
  def has_date_lt(_ctx, expression, date_string) do
    found_date = extract_dateish(expression)
    test_date = extract_dateish(date_string)
    # future match result: found_date
    {:ok, Date.compare(found_date, test_date) == :lt}
  end

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

  ```
  @(has_email("my email is foo1@bar.com, please respond")) → true
  @(has_email("i'm not sharing my email")) → false
  ```

  Not supported:

  ```
  @(has_email("my email is foo1@bar.com, please respond").match) → foo1@bar.com
  @(has_email("my email is <foo@bar2.com>").match) → foo@bar2.com
  ```

  # Example:

    iex> Expression.Callbacks.has_email(%{}, "my email is foo1@bar.com, please respond")
    {:ok, true}
    iex> Expression.Callbacks.has_email(%{}, "i'm not sharing my email")
    {:ok, false}

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

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

  ```
  @(has_group(array(), "97fe7029-3a15-4005-b0c7-277b884fc1d5")) → false
  ```

  Not supported:

  ```
  @(has_group(contact.groups, "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d").match) → {name: Testers, uuid: b7cf0d83-f1c9-411c-96fd-c511a4cfa86d}
  ```

  # Example:

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

  """
  def has_group(_ctx, groups, uuid) do
    group = Enum.find(groups, nil, &(&1["uuid"] == uuid))
    # future match result: group
    {:ok, !!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(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

  ```
  @(has_number("the number is 42")) → true
  @(has_number("the number is forty two")) → false
  ```

  Not supported:

  ```
  @(has_number("the number is 42").match) → 42
  @(has_number("العدد ٤٢").match) → 42
  ```

  # Example

    iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "the number is 42 and 5")
    iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "العدد ٤٢")
    iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "٠.٥")
    iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "0.6")

  """
  def has_number(_ctx, expression) do
    number = extract_numberish(expression)
    # future match result: number
    {:ok, !!number}
  end

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

  ```
  @(has_number_eq("the number is 42", 42)) → true
  @(has_number_eq("the number is 42", 40)) → false
  @(has_number_eq("the number is not there", 40)) → false
  @(has_number_eq("the number is not there", "foo")) → ERROR
  ```

  Not supported:

  ```
  @(has_number_eq("the number is 42", 42).match) → 42
  ```

  # Example

    iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42", 42)
    iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42", 42.0)
    iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42", "42")
    iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42.0", "42")
    iex> {:ok, false} = Expression.Callbacks.has_number_eq(%{}, "the number is 40", "42")
    iex> {:ok, false} = Expression.Callbacks.has_number_eq(%{}, "the number is 40", "foo")
    iex> {:ok, false} = Expression.Callbacks.has_number_eq(%{}, "four hundred", "foo")

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

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

  ```
  @(has_number_gt("the number is 42", 40)) → true
  @(has_number_gt("the number is 42", 42)) → false
  @(has_number_gt("the number is not there", 40)) → false
  @(has_number_gt("the number is not there", "foo")) → ERROR
  ```

  Not supported:

  ```
  @(has_number_gt("the number is 42", 40).match) → 42
  ```

  # Example

    iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42", 40)
    iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42", 40.0)
    iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42", "40")
    iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42.0", "40")
    iex> {:ok, false} = Expression.Callbacks.has_number_gt(%{}, "the number is 40", "40")
    iex> {:ok, false} = Expression.Callbacks.has_number_gt(%{}, "the number is 40", "foo")
    iex> {:ok, false} = Expression.Callbacks.has_number_gt(%{}, "four hundred", "foo")
  """
  def has_number_gt(_ctx, expression, decimal) do
    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      {:ok, Decimal.gt?(number, decimal)}
    else
      nil -> {:ok, false}
      :error -> {:ok, false}
    end
  end

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

  ```
  @(has_number_gte("the number is 42", 42)) → true
  @(has_number_gte("the number is 42", 45)) → false
  @(has_number_gte("the number is not there", 40)) → false
  @(has_number_gte("the number is not there", "foo")) → ERROR
  ```

  Not supported:

  ```
  @(has_number_gte("the number is 42", 42).match) → 42
  ```

  # Example

    iex> {:ok, true} = Expression.Callbacks.has_number_gte(%{}, "the number is 42", 42)
    iex> {:ok, true} = Expression.Callbacks.has_number_gte(%{}, "the number is 42", 42.0)
    iex> {:ok, true} = Expression.Callbacks.has_number_gte(%{}, "the number is 42", "42")
    iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "the number is 42.0", "45")
    iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "the number is 40", "45")
    iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "the number is 40", "foo")
    iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "four hundred", "foo")
  """
  def has_number_gte(_ctx, expression, decimal) do
    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      {:ok, Decimal.gt?(number, decimal) || Decimal.eq?(number, decimal)}
    else
      nil -> {:ok, false}
      :error -> {:ok, false}
    end
  end

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

  ```
  @(has_number_lt("the number is 42", 44)) → true
  @(has_number_lt("the number is 42", 40)) → false
  @(has_number_lt("the number is not there", 40)) → false
  @(has_number_lt("the number is not there", "foo")) → ERROR
  ```

  Not supported:

  ```
  @(has_number_lt("the number is 42", 44).match) → 42
  ```

  # Example

    iex> {:ok, true} = Expression.Callbacks.has_number_lt(%{}, "the number is 42", 44)
    iex> {:ok, true} = Expression.Callbacks.has_number_lt(%{}, "the number is 42", 44.0)
    iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 42", "40")
    iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 42.0", "40")
    iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 40", "40")
    iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 40", "foo")
    iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "four hundred", "foo")
  """
  def has_number_lt(_ctx, expression, decimal) do
    with %Decimal{} = number <- extract_numberish(expression),
         %Decimal{} = decimal <- parse_decimal(decimal) do
      # Future match result: number
      {:ok, Decimal.lt?(number, decimal)}
    else
      nil -> {:ok, false}
      :error -> {:ok, false}
    end
  end

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

  ```
  @(has_number_lte("the number is 42", 42)) → true
  @(has_number_lte("the number is 42", 40)) → false
  @(has_number_lte("the number is not there", 40)) → false
  @(has_number_lte("the number is not there", "foo")) → ERROR
  ```

  Not supported:

  ```
  @(has_number_lte("the number is 42", 42).match) → 42
  ```

  # Example

    iex> {:ok, true} = Expression.Callbacks.has_number_lte(%{}, "the number is 42", 42)
    iex> {:ok, true} = Expression.Callbacks.has_number_lte(%{}, "the number is 42", 42.0)
    iex> {:ok, true} = Expression.Callbacks.has_number_lte(%{}, "the number is 42", "42")
    iex> {:ok, false} = Expression.Callbacks.has_number_lte(%{}, "the number is 42.0", "40")
    iex> {:ok, false} = Expression.Callbacks.has_number_lte(%{}, "the number is 40", "foo")
    iex> {:ok, false} = Expression.Callbacks.has_number_lte(%{}, "four hundred", "foo")

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

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

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

  ```
  @(has_only_phrase("Quick Brown", "quick brown")) → true
  @(has_only_phrase("The Quick Brown Fox", "quick brown")) → false
  @(has_only_phrase("the Quick Brown fox", "")) → false
  @(has_only_phrase("", "").match) →
  @(has_only_phrase("The Quick Brown Fox", "red fox")) → false
  ```

  Not supported:

  ```
  @(has_only_phrase("Quick Brown", "quick brown").match) → Quick Brown
  ```

  # Example

    iex> Expression.Callbacks.has_only_phrase(%{}, "Quick Brown", "quick brown")
    {:ok, true}
    iex> Expression.Callbacks.has_only_phrase(%{}, "", "")
    {:ok, true}
    iex> Expression.Callbacks.has_only_phrase(%{}, "The Quick Brown Fox", "quick brown")
    {:ok, false}

  """
  def has_only_phrase(_ctx, expression, phrase) do
    case Enum.map([expression, phrase], &String.downcase/1) do
      # Future match result: expression
      [same, same] -> {:ok, true}
      _anything_else -> {:ok, 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.

  ```
  @(has_only_text("foo", "foo")) → true
  @(has_only_text("foo", "FOO")) → false
  @(has_only_text("foo", "bar")) → false
  @(has_only_text("foo", " foo ")) → false
  @(has_only_text(results.webhook.category, "Failure")) → false
  ```

  Not supported:

  ```
  @(has_only_text("foo", "foo").match) → foo
  @(has_only_text(run.status, "completed").match) → completed
  @(has_only_text(results.webhook.category, "Success").match) → Success
  ```

  # Example

    iex> Expression.Callbacks.has_only_text(%{}, "foo", "foo")
    {:ok, true}
    iex> Expression.Callbacks.has_only_text(%{}, "", "")
    {:ok, true}
    iex> Expression.Callbacks.has_only_text(%{}, "foo", "FOO")
    {:ok, false}

  """
  def has_only_text(_ctx, expression, expression) when is_binary(expression),
    # future match result: expression
    do: {:ok, true}

  def has_only_text(_ctx, _expression, _something_else),
    # Future match result: expression
    do: {:ok, false}

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

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

  ```
  @(has_pattern("Buy cheese please", "buy (\w+)")) → true
  @(has_pattern("Sell cheese please", "buy (\w+)")) → false
  ```

  Not supported:

  ```
  @(has_pattern("Buy cheese please", "buy (\w+)").match) → Buy cheese
  @(has_pattern("Buy cheese please", "buy (\w+)").extra) → {0: Buy cheese, 1: cheese}
  ```

  # Examples

    iex> Expression.Callbacks.has_pattern(%{}, "Buy cheese please", "buy (\\\\w+)")
    {:ok, true}
    iex> Expression.Callbacks.has_pattern(%{}, "Sell cheese please", "buy (\\\\w+)")
    {:ok, false}

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

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

  ```
  @(has_phone("my number is +12067799294 thanks")) → true
  @(has_phone("my number is none of your business", "US")) → false
  ```

  Not supported:

  ```
  @(has_phone("my number is +12067799294").match) → +12067799294
  @(has_phone("my number is 2067799294", "US").match) → +12067799294
  @(has_phone("my number is 206 779 9294", "US").match) → +12067799294
  ```

  # Example

    iex> Expression.Callbacks.has_phone(%{}, "my number is +12067799294 thanks")
    {:ok, true}
    iex> Expression.Callbacks.has_phone(%{}, "my number is 2067799294 thanks", "US")
    {:ok, true}
    iex> Expression.Callbacks.has_phone(%{}, "my number is 206 779 9294 thanks", "US")
    {:ok, true}
    iex> Expression.Callbacks.has_phone(%{}, "my number is none of your business", "US")
    {:ok, false}

  """
  def has_phone(%{}, expression, country_code \\ "") do
    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} -> {:ok, true}
      _ -> {:ok, 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.

  ```
  @(has_phrase("the quick brown fox", "brown fox")) → true
  @(has_phrase("the Quick Brown fox", "quick fox")) → false
  @(has_phrase("the Quick Brown fox", "").match) →
  ```

  Not supported:

  ```
  @(has_phrase("the quick brown fox", "brown fox").match) → brown fox
  ```

  # Examples

    iex> Expression.Callbacks.has_phrase(%{}, "the quick brown fox", "brown fox")
    {:ok, true}
    iex> Expression.Callbacks.has_phrase(%{}, "the quick brown fox", "quick fox")
    {:ok, false}
    iex> Expression.Callbacks.has_phrase(%{}, "the quick brown fox", "")
    {:ok, true}

  """
  def has_phrase(_ctx, expression, phrase) do
    lower_expression = String.downcase(expression)
    lower_phrase = String.downcase(phrase)
    found? = String.contains?(lower_expression, lower_phrase)
    # Future match result: phrase
    {:ok, found?}
  end

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

  ```
  @(has_text("quick brown")) → true
  @(has_text("")) → false
  @(has_text(" \n")) → false
  @(has_text(contact.fields.not_set)) → false
  ```

  Not supported:

  ```
  @(has_text("quick brown").match) → quick brown
  @(has_text(123).match) → 123
  ```

  # Examples

    iex> Expression.Callbacks.has_text(%{}, "quick brown")
    {:ok, true}
    iex> Expression.Callbacks.has_text(%{}, "")
    {:ok, false}
    iex> Expression.Callbacks.has_text(%{}, " \\n")
    {:ok, false}
    iex> Expression.Callbacks.has_text(%{}, 123)
    {:ok, true}
    iex> Expression.Callbacks.has_text(%{}, nil)
    {:ok, false}
  """
  def has_text(ctx, expression) when not is_binary(expression),
    do: has_text(ctx, to_string(expression))

  def has_text(_ctx, expression) when is_binary(expression) do
    case String.trim(expression) do
      "" -> {:ok, false}
      # Future match result: any_other_binary
      _any_other_binary -> {:ok, true}
    end
  end

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

  ```
  @(has_time("the time is 10:30")) → true
  @(has_time("the time is 10:30:45").match) → 10:30:45.000000
  @(has_time("there is no time here, just the number 25")) → false
  ```

  Not supported:

  ```
  @(has_time("the time is 10:30").match) → 10:30:00.000000
  @(has_time("the time is 10 PM").match) → 22:00:00.000000
  ```

  # Examples

    iex> Expression.Callbacks.has_time(%{}, "the time is 10:30")
    {:ok, true}
    iex> Expression.Callbacks.has_time(%{}, "the time is 10:00 pm")
    {:ok, true}
    iex> Expression.Callbacks.has_time(%{}, "the time is 10:30:45")
    {:ok, true}
    iex> Expression.Callbacks.has_time(%{}, "there is no time here, just the number 25")
    {:ok, false}

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