lib/expression/callbacks/standard.ex

defmodule Expression.Callbacks.Standard 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.
  """

  import Expression.Callbacks.EvalHelpers

  use Expression.Callbacks
  use Expression.Autodoc

  alias Expression.DateHelpers

  @punctuation_pattern ~r/\s*[,:;!?.-]\s*|\s/
  @doc """
  Defines a new date value
  """
  @expression_doc doc: "Construct a date from year, month, and day integers",
                  expression: "date(year, month, day)",
                  context: %{
                    "year" => 2022,
                    "month" => 1,
                    "day" => 31
                  },
                  result: ~D[2022-01-31]
  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,
      time_zone: "Etc/UTC",
      zone_abbr: "UTC"
    ]

    struct(Date, fields)
  end

  @doc """
  Calculates a new datetime based on the offset and unit provided.

  The unit can be any of the following values:

  * "Y" for years
  * "M" for months
  * "W" for weeks
  * "D" for days
  * "h" for hours
  * "m" for minutes
  * "s" for seconds

  Specifying a negative offset results in date calculations back in time.

  """
  @expression_doc doc: "Calculates a new datetime based on the offset and unit provided.",
                  expression: "datetime_add(datetime, offset, unit)",
                  context: %{
                    "datetime" => ~U[2022-07-31 00:00:00Z],
                    "offset" => "1",
                    "unit" => "M"
                  },
                  result: ~U[2022-08-31 00:00:00Z]
  @expression_doc doc: "Leap year handling in a leap year.",
                  expression: "datetime_add(date(2020, 02, 28), 1, \"D\")",
                  result: ~U[2020-02-29 00:00:00.000000Z]
  @expression_doc doc: "Leap year handling outside of a leap year.",
                  expression: "datetime_add(date(2021, 02, 28), 1, \"D\")",
                  result: ~U[2021-03-01 00:00:00.000000Z]
  @expression_doc doc: "Negative offsets",
                  expression: "datetime_add(date(2020, 02, 29), -1, \"D\")",
                  result: ~U[2020-02-28 00:00:00.000000Z]
  def datetime_add(ctx, datetime, offset, unit) do
    datetime = DateHelpers.extract_datetimeish(eval!(datetime, ctx))
    [offset, unit] = eval_args!([offset, unit], ctx)

    case unit do
      "Y" -> Timex.shift(datetime, years: offset)
      "M" -> Timex.shift(datetime, months: offset)
      "W" -> Timex.shift(datetime, weeks: offset)
      "D" -> Timex.shift(datetime, days: offset)
      "h" -> Timex.shift(datetime, hours: offset)
      "m" -> Timex.shift(datetime, minutes: offset)
      "s" -> Timex.shift(datetime, seconds: offset)
    end
  end

  @doc """
  Converts date stored in text to an actual date object and
  formats it using `strftime` formatting.

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

  """
  @expression_doc doc: "Convert a date from a piece of text to a formatted date string",
                  expression: "datevalue(\"2022-01-01\")",
                  result: %{"__value__" => "2022-01-01 00:00:00", "date" => ~D[2022-01-01]}
  @expression_doc doc: "Convert a date from a piece of text and read the date field",
                  expression: "datevalue(\"2022-01-01\").date",
                  result: ~D[2022-01-01]
  @expression_doc doc: "Convert a date value and read the date field",
                  expression: "datevalue(date(2022, 1, 1)).date",
                  result: ~D[2022-01-01]
  def datevalue(ctx, date, format) do
    [date, format] = eval!([date, format], ctx)

    if date = DateHelpers.extract_dateish(date) do
      %{"__value__" => Timex.format!(date, format, :strftime), "date" => date}
    end
  end

  def datevalue(ctx, date) do
    date = DateHelpers.extract_dateish(eval!(date, ctx))

    %{
      "__value__" => Timex.format!(date, "%Y-%m-%d %H:%M:%S", :strftime),
      "date" => date
    }
  end

  @doc """
  Returns only the day of the month of a date (1 to 31)
  """
  @expression_doc doc: "Getting today's day of the month",
                  expression: "day(date(2022, 9, 10))",
                  result: 10
  @expression_doc doc: "Getting today's day of the month",
                  expression: "day(now())",
                  fake_result: DateTime.utc_now().day
  def day(ctx, date) do
    %{day: day} = eval!(date, ctx)
    day
  end

  @doc """
  Moves a date by the given number of months
  """
  @expression_doc doc: "Move the date in a date object by 1 month",
                  expression: "edate(right_now, 1)",
                  context: %{right_now: DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0))},
                  result:
                    Timex.shift(DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0)),
                      months: 1
                    )
  @expression_doc doc: "Move the date store in a piece of text by 1 month",
                  expression: "edate(\"2022-10-10\", 1)",
                  result: ~D[2022-11-10]
  def edate(ctx, date, months) do
    [date, months] = eval_args!([date, months], ctx)
    DateHelpers.extract_dateish(date) |> Timex.shift(months: months)
  end

  @doc """
  Returns only the hour of a datetime (0 to 23)
  """
  @expression_doc doc: "Get the current hour",
                  expression: "hour(now())",
                  fake_result: DateTime.utc_now().hour
  def hour(ctx, date) do
    %{hour: hour} = eval!(date, ctx)
    hour
  end

  @doc """
  Returns only the minute of a datetime (0 to 59)
  """
  @expression_doc doc: "Get the current minute",
                  expression: "minute(now())",
                  fake_result: DateTime.utc_now().minute
  def minute(ctx, date) do
    %{minute: minute} = DateHelpers.extract_datetimeish(eval!(date, ctx))
    minute
  end

  @doc """
  Returns only the month of a date (1 to 12)
  """
  @expression_doc doc: "Get the current month",
                  expression: "month(now())",
                  fake_result: DateTime.utc_now().month
  def month(ctx, date) do
    %{month: month} = eval!(date, ctx)
    month
  end

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

  ```
  It is currently @NOW()
  ```
  """
  @expression_doc doc: "return the current timestamp as a DateTime value",
                  expression: "now()",
                  fake_result: DateTime.utc_now()
  @expression_doc doc: "return the current datetime and format it using `datevalue`",
                  expression: "datevalue(now(), \"%Y-%m-%d\")",
                  fake_result: %{
                    "__value__" => DateTime.utc_now() |> Timex.format!("%Y-%m-%d", :strftime),
                    "date" => DateTime.utc_now()
                  }
  def now(_ctx) do
    DateTime.utc_now()
  end

  @doc """
  Returns only the second of a datetime (0 to 59)
  """
  @expression_doc expression: "second(now)",
                  context: %{"now" => DateTime.utc_now()},
                  fake_result: DateTime.utc_now().second
  def second(ctx, date) do
    %{second: second} = eval!(date, ctx)
    second
  end

  @doc """
  Defines a time value which can be used for time arithmetic
  """
  @expression_doc expression: "time(12, 13, 14)",
                  result: %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
  """
  @expression_doc expression: "timevalue(\"2:30\")",
                  result: %Time{hour: 2, minute: 30, second: 0}
  @expression_doc expression: "timevalue(\"2:30:55\")",
                  result: %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
  """
  @expression_doc expression: "today()",
                  fake_result: Date.utc_today()
  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)
  """
  @expression_doc expression: "weekday(today)",
                  context: %{"today" => ~D[2022-11-06]},
                  result: 1
  @expression_doc expression: "weekday(today)",
                  context: %{"today" => ~D[2022-11-01]},
                  result: 3
  def weekday(ctx, date) do
    iso_week_day = Timex.weekday(eval!(date, ctx))

    if iso_week_day == 7 do
      1
    else
      iso_week_day + 1
    end
  end

  @doc """
  Returns only the year of a date
  """
  @expression_doc expression: "year(now)",
                  context: %{"now" => DateTime.utc_now()},
                  fake_result: DateTime.utc_now().year
  def year(ctx, date) do
    %{year: year} = DateHelpers.extract_dateish(eval!(date, ctx))
    year
  end

  @doc """
  Returns `true` if and only if all its arguments evaluate to `true`
  """
  @expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)",
                  code_expression: "contact.gender = \"F\" and contact.age >= 18",
                  context: %{
                    "contact" => %{
                      "gender" => "F",
                      "age" => 32
                    }
                  },
                  result: true
  @expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)",
                  code_expression: "contact.gender = \"F\" and contact.age >= 18",
                  context: %{
                    "contact" => %{
                      "gender" => "?",
                      "age" => 32
                    }
                  },
                  result: 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
  """
  @expression_doc expression: "not(false)", result: 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`
  """
  @expression_doc expression: "if(true, \"Yes\", \"No\")",
                  code_expression: """
                  if true do
                    "Yes"
                  else
                    "No"
                  end
                  """,
                  result: "Yes"
  @expression_doc expression: "if(false, \"Yes\", \"No\")",
                  code_expression: "# Shorthand\nif(false, do: \"Yes\", else: \"No\")",
                  result: "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`.
  Returns the first truthy value found or otherwise false.

  Accepts any amount of arguments for testing truthiness.
  """
  @expression_doc doc: "Return true if any of the values are true",
                  expression: "or(true, false)",
                  code_expression: "true or false",
                  result: true
  @expression_doc doc: "Return the first value that is truthy",
                  expression: "or(false, \"foo\")",
                  code_expression: "false or \"foo\"",
                  result: "foo"
  @expression_doc expression: "or(true, true)",
                  code_expression: "true or true",
                  result: true
  @expression_doc expression: "or(false, false)",
                  code_expression: "false or false",
                  result: false
  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
  """
  @expression_doc expression: "abs(-1)",
                  result: 1
  def abs(ctx, number) do
    abs(eval!(number, ctx))
  end

  @doc """
  Returns the maximum value of all arguments
  """
  @expression_doc expression: "max(1, 2, 3)",
                  result: 3
  def max_vargs(ctx, arguments) do
    Enum.max(eval_args!(arguments, ctx))
  end

  @doc """
  Returns the minimum value of all arguments
  """
  @expression_doc expression: "min(1, 2, 3)",
                  result: 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
  """
  @expression_doc expression: "power(2, 3)",
                  fake_result: 8.0
  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
  ```
  """
  @expression_doc expression: "sum(1, 2, 3)",
                  result: 6
  def sum_vargs(ctx, arguments) do
    Enum.sum(eval_args!(arguments, ctx))
  end

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

  ```
  > "As easy as @char(65), @char(66), @char(67)"
  "As easy as A, B, C"
  ```
  """
  @expression_doc expression: "char(65)",
                  result: "A"
  def char(ctx, code) do
    code = eval!(code, ctx)
    <<code>>
  end

  @doc """
  Removes all non-printable characters from a text string
  """
  @expression_doc expression: "clean(value)",
                  context: %{"value" => <<65, 0, 66, 0, 67>>},
                  result: "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

  ```
  > "The numeric code of A is @CODE(\\"A\\")"
  "The numeric code of A is 65"
  ```
  """
  @expression_doc expression: "code(\"A\")",
                  result: 65
  def code(ctx, code_ast) do
    <<code>> = eval!(code_ast, ctx)
    code
  end

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

  ```
  > "Your name is @CONCATENATE(contact.first_name, \\" \\", contact.last_name)"
  "Your name is name surname"
  ```
  """
  @expression_doc expression: "concatenate(contact.first_name, \" \", contact.last_name)",
                  context: %{
                    "contact" => %{
                      "first_name" => "name",
                      "last_name" => "surname"
                    }
                  },
                  result: "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
  "You have 4.21 in your account"
  ```
  """
  @expression_doc expression: "fixed(4.209922, 2, false)",
                  result: "4.21"
  @expression_doc expression: "fixed(4000.424242, 4, true)",
                  result: "4,000.4242"
  @expression_doc expression: "fixed(3.7979, 2, false)",
                  result: "3.80"
  @expression_doc expression: "fixed(3.7979, 2)",
                  result: "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, no_commas) do
    case eval_args!([number, precision, no_commas], 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.
  """
  @expression_doc expression: "left(\"foobar\", 4)",
                  result: "foob"

  @expression_doc expression:
                    "left(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)",
                  result: "Умерла Мадлен Олбрай"
  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
  """
  @expression_doc expression: "len(\"foo\")",
                  result: 3
  @expression_doc expression: "len(\"zoë\")",
                  result: 3
  def len(ctx, binary) do
    String.length(eval!(binary, ctx))
  end

  @doc """
  Converts a text string to lowercase
  """
  @expression_doc expression: "lower(\"Foo Bar\")",
                  result: "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
  """
  @expression_doc expression: "proper(\"foo bar\")",
                  result: "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
  """
  @expression_doc expression: "rept(\"*\", 10)",
                  result: "**********"
  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.
  """
  @expression_doc expression: "right(\"testing\", 3)",
                  result: "ing"
  @expression_doc expression:
                    "right(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)",
                  result: "ту главы Госдепа США"
  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
  """
  @expression_doc expression: "substitute(\"I can't\", \"can't\", \"can do\")",
                  result: "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
  """
  @expression_doc expression: "unichar(65)", result: "A"
  @expression_doc expression: "unichar(233)", result: "é"
  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
  """
  @expression_doc expression: "unicode(\"A\")", result: 65
  @expression_doc expression: "unicode(\"é\")", result: 233
  def unicode(ctx, letter) do
    <<code::utf8>> = eval!(letter, ctx)
    code
  end

  @doc """
  Converts a text string to uppercase
  """
  @expression_doc expression: "upper(\"foo\")",
                  result: "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)
  """
  @expression_doc expression: "first_word(\"foo bar baz\")",
                  result: "foo"
  def first_word(ctx, binary) do
    [word | _] = String.split(eval!(binary, ctx), " ")
    word
  end

  @doc """
  Formats a number as a percentage
  """
  @expression_doc expression: "percent(2/10)", result: "20%"
  @expression_doc expression: "percent(0.2)", result: "20%"
  @expression_doc expression: "percent(d)", context: %{"d" => "0.2"}, result: "20%"
  def percent(ctx, float) do
    float = eval!(float, ctx)

    with float when is_number(float) <- parse_float(float) do
      Number.Percentage.number_to_percentage(float * 100, precision: 0)
    end
  end

  @doc """
  Formats digits in text for reading in TTS
  """
  @expression_doc expression: "read_digits(\"+271\")", result: "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
  """
  @expression_doc expression: "remove_first_word(\"foo bar\")", result: "bar"
  @expression_doc expression: "remove_first_word(\"foo-bar\", \"-\")", result: "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
  """
  @expression_doc expression: "word(\"hello cow-boy\", 2)", result: "cow"
  @expression_doc expression: "word(\"hello cow-boy\", 2, true)", result: "cow-boy"
  @expression_doc expression: "word(\"hello cow-boy\", -1)", result: "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("one two three") words
  You entered 3 words
  ```
  """
  @expression_doc expression: "word_count(\"hello cow-boy\")", result: 3
  @expression_doc expression: "word_count(\"hello cow-boy\", true)", result: 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
  """
  @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2, 4)",
                  result: "expressions are"
  @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2)",
                  result: "expressions are fun"
  @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 1, -2)",
                  result: "FLOIP expressions"
  @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", -1)",
                  result: "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.
  """
  @expression_doc expression: "isnumber(1)", result: true
  @expression_doc expression: "isnumber(1.0)", result: true
  @expression_doc expression: "isnumber(\"1.0\")", result: true
  @expression_doc expression: "isnumber(\"a\")", result: 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_binary(var) ->
        String.match?(var, ~r/^\d+?.?\d+$/)

      _var ->
        false
    end
  end

  @doc """
  Returns `true` if the argument is a boolean.
  """
  @expression_doc expression: "isbool(true)", result: true
  @expression_doc expression: "isbool(false)", result: true
  @expression_doc expression: "isbool(1)", result: false
  @expression_doc expression: "isbool(0)", result: false
  @expression_doc expression: "isbool(\"true\")", result: false
  @expression_doc expression: "isbool(\"false\")", result: false
  def isbool(ctx, var) do
    eval!(var, ctx) in [true, false]
  end

  @doc """
  Returns `true` if the argument is a string.
  """
  @expression_doc expression: "isstring(\"hello\")", result: true
  @expression_doc expression: "isstring(false)", result: false
  @expression_doc expression: "isstring(1)", result: 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, to_string(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.
  """
  @expression_doc expression: "has_all_words(\"the quick brown FOX\", \"the fox\")", result: true
  @expression_doc expression: "has_all_words(\"the quick brown FOX\", \"red fox\")", result: 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.
  """
  @expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"fox quick\")",
                  result: %{"__value__" => true, "match" => "Quick Fox"}
  @expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"yellow\")",
                  result: %{"__value__" => false, "match" => nil}
  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))

    match? = Enum.any?(matched_haystack_words)

    %{
      "__value__" => match?,
      "match" => if(match?, do: Enum.join(matched_haystack_words, " "), else: nil)
    }
  end

  @doc """
  Tests whether text starts with beginning

  Both text values are trimmed of surrounding whitespace, but otherwise matching is
  strict without any tokenization.
  """
  @expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")", result: true
  @expression_doc expression: "has_beginning(\"The Quick Brown\", \"the    quick\")",
                  result: false
  @expression_doc expression: "has_beginning(\"The Quick Brown\", \"quick brown\")", result: 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

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

  This is very naively implemented with a regular expression.
  """
  @expression_doc expression: "has_date(\"the date is 15/01/2017\")", result: true
  @expression_doc expression: "has_date(\"there is no date here, just a year 2017\")",
                  result: false
  def has_date(ctx, expression) do
    !!DateHelpers.extract_dateish(eval!(expression, ctx))
  end

  @doc """
  Tests whether `expression` is a date equal to `date_string`
  """
  @expression_doc expression: "has_date_eq(\"the date is 15/01/2017\", \"2017-01-15\")",
                  result: true
  @expression_doc expression:
                    "has_date_eq(\"there is no date here, just a year 2017\", \"2017-01-15\")",
                  result: false
  def has_date_eq(ctx, expression, date_string) do
    [expression, date_string] = eval_args!([expression, date_string], ctx)
    found_date = DateHelpers.extract_dateish(expression)
    test_date = DateHelpers.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`
  """
  @expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-01-01\")",
                  result: true
  @expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-03-15\")",
                  result: false
  def has_date_gt(ctx, expression, date_string) do
    [expression, date_string] = eval_args!([expression, date_string], ctx)
    found_date = DateHelpers.extract_dateish(expression)
    test_date = DateHelpers.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`
  """
  @expression_doc expression: "has_date_lt(\"the date is 15/01/2017\", \"2017-06-01\")",
                  result: true
  @expression_doc expression: "has_date_lt(\"the date is 15/01/2021\", \"2017-03-15\")",
                  result: false
  def has_date_lt(ctx, expression, date_string) do
    [expression, date_string] = eval_args!([expression, date_string], ctx)
    found_date = DateHelpers.extract_dateish(expression)
    test_date = DateHelpers.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
  """
  @expression_doc expression: "has_email(\"my email is foo1@bar.com, please respond\")",
                  result: true
  @expression_doc expression: "has_email(\"i'm not sharing my email\")", result: 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
  """
  @expression_doc expression:
                    "has_group(contact.groups, \"b7cf0d83-f1c9-411c-96fd-c511a4cfa86d\")",
                  context: %{
                    "contact" => %{
                      "groups" => [
                        %{
                          "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d"
                        }
                      ]
                    }
                  },
                  result: true
  @expression_doc expression:
                    "has_group(contact.groups, \"00000000-0000-0000-0000-000000000000\")",
                  context: %{
                    "contact" => %{
                      "groups" => [
                        %{
                          "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d"
                        }
                      ]
                    }
                  },
                  result: 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),
         float <- parse_float(match) do
      float
    else
      # Regex can return nil
      nil -> nil
      # Float 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

  def parse_float(number) when is_number(number), do: number

  def parse_float(binary) when is_binary(binary) do
    case Float.parse(binary) do
      {float, ""} -> float
      _ -> nil
    end
  end

  @doc """
  Tests whether `expression` contains a number
  """
  @expression_doc expression: "has_number(\"the number is 42 and 5\")", result: true
  @expression_doc expression: "has_number(\"العدد ٤٢\")", result: true
  @expression_doc expression: "has_number(\"٠.٥\")", result: true
  @expression_doc expression: "has_number(\"0.6\")", result: true

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

  @expression_doc expression: "has_number_eq(\"the number is 42\", 42)", result: true
  @expression_doc expression: "has_number_eq(\"the number is 42\", 42.0)", result: true
  @expression_doc expression: "has_number_eq(\"the number is 42\", \"42\")", result: true
  @expression_doc expression: "has_number_eq(\"the number is 42.0\", \"42\")", result: true
  @expression_doc expression: "has_number_eq(\"the number is 40\", \"42\")", result: false
  @expression_doc expression: "has_number_eq(\"the number is 40\", \"foo\")", result: false
  @expression_doc expression: "has_number_eq(\"four hundred\", \"foo\")", result: false
  def has_number_eq(ctx, expression, float) do
    [expression, float] = eval_args!([expression, float], ctx)

    with number when is_number(number) <- extract_numberish(expression),
         float when is_number(float) <- parse_float(float) do
      # Future match result: number
      float == number
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number greater than min
  """
  @expression_doc expression: "has_number_gt(\"the number is 42\", 40)", result: true
  @expression_doc expression: "has_number_gt(\"the number is 42\", 40.0)", result: true
  @expression_doc expression: "has_number_gt(\"the number is 42\", \"40\")", result: true
  @expression_doc expression: "has_number_gt(\"the number is 42.0\", \"40\")", result: true
  @expression_doc expression: "has_number_gt(\"the number is 40\", \"40\")", result: false
  @expression_doc expression: "has_number_gt(\"the number is 40\", \"foo\")", result: false
  @expression_doc expression: "has_number_gt(\"four hundred\", \"foo\")", result: false
  def has_number_gt(ctx, expression, float) do
    [expression, float] = eval_args!([expression, float], ctx)

    with number when is_number(number) <- extract_numberish(expression),
         float when is_number(float) <- parse_float(float) do
      # Future match result: number
      number > float
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number greater than or equal to min
  """
  @expression_doc expression: "has_number_gte(\"the number is 42\", 42)", result: true
  @expression_doc expression: "has_number_gte(\"the number is 42\", 42.0)", result: true
  @expression_doc expression: "has_number_gte(\"the number is 42\", \"42\")", result: true
  @expression_doc expression: "has_number_gte(\"the number is 42.0\", \"45\")", result: false
  @expression_doc expression: "has_number_gte(\"the number is 40\", \"45\")", result: false
  @expression_doc expression: "has_number_gte(\"the number is 40\", \"foo\")", result: false
  @expression_doc expression: "has_number_gte(\"four hundred\", \"foo\")", result: false
  def has_number_gte(ctx, expression, float) do
    [expression, float] = eval_args!([expression, float], ctx)

    with number when is_number(number) <- extract_numberish(expression),
         float when is_number(float) <- parse_float(float) do
      # Future match result: number
      number >= float
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number less than max
  """
  @expression_doc expression: "has_number_lt(\"the number is 42\", 44)", result: true
  @expression_doc expression: "has_number_lt(\"the number is 42\", 44.0)", result: true
  @expression_doc expression: "has_number_lt(\"the number is 42\", \"40\")", result: false
  @expression_doc expression: "has_number_lt(\"the number is 42.0\", \"40\")", result: false
  @expression_doc expression: "has_number_lt(\"the number is 40\", \"40\")", result: false
  @expression_doc expression: "has_number_lt(\"the number is 40\", \"foo\")", result: false
  @expression_doc expression: "has_number_lt(\"four hundred\", \"foo\")", result: false
  def has_number_lt(ctx, expression, float) do
    [expression, float] = eval_args!([expression, float], ctx)

    with number when is_number(number) <- extract_numberish(expression),
         float when is_number(float) <- parse_float(float) do
      # Future match result: number
      number < float
    else
      nil -> false
      :error -> false
    end
  end

  @doc """
  Tests whether `expression` contains a number less than or equal to max
  """
  @expression_doc expression: "has_number_lte(\"the number is 42\", 42)", result: true
  @expression_doc expression: "has_number_lte(\"the number is 42\", 42.0)", result: true
  @expression_doc expression: "has_number_lte(\"the number is 42\", \"42\")", result: true
  @expression_doc expression: "has_number_lte(\"the number is 42.0\", \"40\")", result: false
  @expression_doc expression: "has_number_lte(\"the number is 40\", \"foo\")", result: false
  @expression_doc expression: "has_number_lte(\"four hundred\", \"foo\")", result: false
  @expression_doc expression: "has_number_lte(\"@response\", 5)",
                  context: %{"response" => 3},
                  result: true
  def has_number_lte(ctx, expression, float) do
    [expression, float] = eval_args!([expression, float], ctx)

    with number when is_number(number) <- extract_numberish(expression),
         float when is_number(float) <- parse_float(float) do
      # Future match result: number
      number <= float
    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
  """
  @expression_doc expression: "has_only_phrase(\"Quick Brown\", \"quick brown\")", result: true
  @expression_doc expression: "has_only_phrase(\"\", \"\")", result: true
  @expression_doc expression: "has_only_phrase(\"The Quick Brown Fox\", \"quick brown\")",
                  result: 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.
  """
  @expression_doc expression: "has_only_text(\"foo\", \"foo\")", result: true
  @expression_doc expression: "has_only_text(\"\", \"\")", result: true
  @expression_doc expression: "has_only_text(\"foo\", \"FOO\")", result: 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.
  """
  @expression_doc expression: "has_pattern(\"Buy cheese please\", \"buy (\\w+)\")", result: true
  @expression_doc expression: "has_pattern(\"Sell cheese please\", \"buy (\\w+)\")", result: 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.
  """
  @expression_doc expression: "has_phone(\"my number is +12067799294 thanks\")", result: true
  @expression_doc expression: "has_phone(\"my number is 2067799294 thanks\", \"US\")",
                  result: true
  @expression_doc expression: "has_phone(\"my number is 206 779 9294 thanks\", \"US\")",
                  result: true
  @expression_doc expression: "has_phone(\"my number is none of your business\", \"US\")",
                  result: 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.
  """
  @expression_doc expression: "has_phrase(\"the quick brown fox\", \"brown fox\")", result: true
  @expression_doc expression: "has_phrase(\"the quick brown fox\", \"quick fox\")", result: false
  @expression_doc expression: "has_phrase(\"the quick brown fox\", \"\")", result: 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
  """
  @expression_doc expression: "has_text(\"quick brown\")", result: true
  @expression_doc expression: "has_text(\"\")", result: false
  @expression_doc expression: "has_text(\" \n\")", result: false
  @expression_doc expression: "has_text(123)", result: true
  def has_text(ctx, expression) do
    expression = eval!(expression, ctx) |> to_string()
    String.trim(expression) != ""
  end

  @doc """
  Tests whether `expression` contains a time.
  """
  @expression_doc expression: "has_time(\"the time is 10:30\")",
                  result: %{"__value__" => true, "match" => ~T[10:30:00]}
  @expression_doc expression: "has_time(\"the time is 10:00 pm\")",
                  result: %{"__value__" => true, "match" => ~T[10:00:00]}
  @expression_doc expression: "has_time(\"the time is 10:30:45\")",
                  result: %{"__value__" => true, "match" => ~T[10:30:45]}
  @expression_doc expression: "has_time(\"there is no time here, just the number 25\")",
                  result: false
  def has_time(ctx, expression) do
    if time = DateHelpers.extract_timeish(eval!(expression, ctx)) do
      %{
        "__value__" => true,
        "match" => time
      }
    else
      false
    end
  end

  @doc """
  map over a list of items and apply the mapper function to every item, returning
  the result.
  """
  @expression_doc doc: "Map over the range of numbers, create a date in January for every number",
                  expression: "map(1..3, &date(2022, 1, &1))",
                  result: [~D[2022-01-01], ~D[2022-01-02], ~D[2022-01-03]]
  @expression_doc doc:
                    "Map over the range of numbers, multiple each by itself and return the result",
                  expression: "map(1..3, &(&1 * &1))",
                  result: [1, 4, 9]
  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

  @doc """
  Return the division remainder of two integers.
  """
  @expression_doc expression: "rem(4, 2)",
                  result: 0
  @expression_doc expression: "rem(85, 3)",
                  result: 1
  def rem(ctx, integer1, integer2) do
    [integer1, integer2] = eval_args!([integer1, integer2], ctx)

    rem(integer1, integer2)
  end
end