lib/expression/v2/compat.ex

defmodule Expression.V2.Compat do
  @moduledoc """
  Compatibility module to make the transition from V1 to V2 a bit easier, hopefully.

  It does a few things:

  * It swaps out V2 callbacks for V1 callbacks when evaluating expressions with V1.
  * It does some patching of the context to match V1's assumptions:
      * case insensitive context keys
      * casting of integers
      * casting of datetimes
  * It compares the output of V1 to V2, if those aren't equal it will log an error and return the V1 response.
  * If there is no error it will return the value from V2.

  > **NOTE**: This module does *twice* the work because it runs V1 and V2 sequentially
    and then compares the result before returning a value.

  > **NOTE**: This was throwing more errors in prod than anticipated, hacking in a revert temporarily
  """
  require Logger

  def evaluate_as_string!(
        expression,
        context,
        callback_module \\ Expression.Callbacks.Standard
      )

  def evaluate_as_string!(expression, context, callback_module) do
    v1_resp = Expression.evaluate_as_string!(expression, context, callback_module)

    # v2_resp =
    #   V2.eval_as_string(
    #     expression,
    #     V2.Context.new(patch_v1_context(context), callback_module)
    #   )

    # return_or_raise(expression, context, v1_resp, v2_resp)
    v1_resp
  end

  # def v1_module(Turn.Build.Callbacks), do: Turn.Build.CallbacksV1
  # def v1_module(V2.Callbacks.Standard), do: Expression.Callbacks.Standard

  def patch_v1_key(key),
    do:
      key
      |> to_string()
      |> String.downcase()

  def patch_v1_context(datetime) when is_struct(datetime, DateTime), do: datetime

  def patch_v1_context(date) when is_struct(date, Date), do: date

  def patch_v1_context(struct) when is_struct(struct) do
    Map.from_struct(struct)
    |> patch_v1_context()
  end

  def patch_v1_context(list) when is_list(list), do: Enum.map(list, &patch_v1_context/1)

  def patch_v1_context(map) when is_map(map) do
    map
    |> Enum.map(fn {key, value} -> {patch_v1_key(key), patch_v1_context(value)} end)
    |> Enum.into(%{})
  end

  def patch_v1_context(binary) when is_binary(binary) do
    with :nope <- attempt_integer(binary),
         :nope <- attempt_float(binary),
         :nope <- attempt_datetime(binary),
         :nope <- attempt_boolean(binary) do
      binary
    end
  end

  def patch_v1_context(other), do: other

  def attempt_boolean(binary) do
    potential_boolean =
      binary
      |> String.trim()
      |> String.downcase()

    case potential_boolean do
      "true" -> true
      "false" -> false
      _other -> :nope
    end
  end

  # Leading plus is still parsed as an integer, which we don't want
  def attempt_integer("+" <> _), do: :nope
  # Leading zero likely means a string code, not an integer
  def attempt_integer("0" <> binary) when byte_size(binary) > 0, do: :nope

  def attempt_integer(binary) do
    String.to_integer(binary)
  rescue
    ArgumentError -> :nope
  end

  # Leading plus is still parsed as an integer, which we don't want
  def attempt_float("+" <> _), do: :nope

  def attempt_float(binary) do
    String.to_float(binary)
  rescue
    ArgumentError -> :nope
  end

  def attempt_datetime(binary) do
    case DateTime.from_iso8601(binary) do
      {:ok, datetime, _} -> datetime
      _other -> :nope
    end
  end

  def evaluate!(expression, context \\ %{}, callback_module \\ Expression.Callbacks.Standard)

  def evaluate!(expression, context, callback_module) do
    v1_resp = Expression.evaluate!(expression, context, callback_module)

    # v2_resp =
    #   V2.eval(
    #     expression,
    #     V2.Context.new(patch_v1_context(context), callback_module)
    #   )
    #   |> hd

    # return_or_raise(expression, context, v1_resp, v2_resp)
    unpack_returned_value(v1_resp)
  end

  def evaluate_block!(
        expression,
        context \\ %{},
        callback_module \\ Callbacks.Standard
      )

  def evaluate_block!(expression, context, callback_module) do
    v1_resp = Expression.evaluate_block(expression, context, callback_module)

    # v2_resp =
    #   case V2.eval_block(
    #          expression,
    #          V2.Context.new(patch_v1_context(context), callback_module)
    #        ) do
    #     {:error, error, reason} -> {:error, error <> " " <> reason}
    #     value -> {:ok, value}
    #   end

    # cond do
    #   # Hack for handling random returns from `rand_between()` callback function
    #   # these will throw an error because they're designed to be different every time
    #   String.contains?(expression, "rand_between") ->
    #     return_or_raise(expression, context, v2_resp, v2_resp)

    #   # Hack for handling `@if` expressions, in V2 these aren't evaluated.
    #   # See the note for this in `eval_compat_test.exs`.
    #   String.contains?(String.downcase(expression), ["@if", "@left"]) ->
    #     return_or_raise(expression, context, v2_resp, v2_resp)

    #   true ->
    #     return_or_raise(expression, context, v1_resp, v2_resp)
    # end
    unpack_returned_value(v1_resp)
  end

  def unpack_returned_value({:ok, val}), do: val
  def unpack_returned_value({:error, reason}), do: reason
  def unpack_returned_value(other), do: other

  def return_or_raise(
        _expression,
        _context,
        {:not_found, _v1_path} = _v1_resp,
        nil = _v2_resp
      ) do
    nil
  end

  def return_or_raise(expression, context, {:ok, val1}, {:ok, val2}) do
    return_or_raise(expression, context, val1, val2)
  end

  def return_or_raise(_expression, _context, {:error, error1}, {:error, _error2}) do
    error1
  end

  def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp)
      when byte_size(v1_resp) == 10 do
    {:ok, v1_resp} = Date.from_iso8601(v1_resp)
    {:ok, v2_resp} = Date.from_iso8601(v2_resp)
    return_or_raise(expression, context, v1_resp, v2_resp)
  end

  def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp) do
    {:ok, v1_resp, _} = DateTime.from_iso8601(v1_resp)
    {:ok, v2_resp, _} = DateTime.from_iso8601(v2_resp)
    return_or_raise(expression, context, v1_resp, v2_resp)
  end

  def return_or_raise(expression, context, v1_resp, v2_resp) do
    cond do
      is_binary(v1_resp) and is_binary(v2_resp) ->
        return_or_raise_binaries(expression, context, v1_resp, v2_resp)

      is_struct(v1_resp, DateTime) and is_struct(v2_resp, DateTime) ->
        if DateTime.diff(v1_resp, v2_resp) <= :timer.seconds(1) do
          v2_resp
        else
          log_error(expression, context, v1_resp, v2_resp)
        end

      normalize_value(v1_resp) == normalize_value(v2_resp) ->
        v2_resp

      true ->
        log_error(expression, context, v1_resp, v2_resp)
    end
  end

  # To minimize random errors due to the V1 & V2 expressions being evaluated at different
  # times we're truncating DateTime structs to the second to give the CPU some grace
  # In the `return_or_raise` we confirm that it's still within a second though and return
  # the original (non truncated) value
  def normalize_value(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second)
  def normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1)

  def normalize_value(map) when is_map(map) and not is_struct(map) do
    map
    |> Enum.map(fn {key, value} -> {key, normalize_value(value)} end)
    |> Enum.into(%{})
  end

  def normalize_value(other), do: other

  def return_or_raise_binaries(expression, context, v1_resp, v2_resp) do
    if String.jaro_distance(v1_resp, v2_resp) > 0.9 do
      v2_resp
    else
      log_error(expression, context, v1_resp, v2_resp)
    end
  end

  def log_error(expression, context, v1_resp, v2_resp) do
    Logger.error("""

    ** Compatibility Error **

    Expression: #{inspect(expression)}
    Context: #{inspect(Map.drop(context, ["flow"]), pretty: true)}

    V1: #{inspect(v1_resp)}
    V2: #{inspect(v2_resp)}
    """)

    v1_resp
  end
end