lib/glific/flows/router.ex

defmodule Glific.Flows.Router do
  @moduledoc """
  The Router object which encapsulates the router in a given node.
  """
  alias __MODULE__

  use Ecto.Schema
  import GlificWeb.Gettext
  require Logger

  alias Glific.{
    Contacts,
    Flows,
    Messages,
    Messages.Message
  }

  alias Glific.Flows.{
    Case,
    Category,
    FlowContext,
    Localization,
    Node,
    Wait
  }

  @required_fields [:type, :operand, :default_category_uuid, :cases, :categories]

  @type t() :: %__MODULE__{
          type: String.t() | nil,
          result_name: String.t() | nil,
          default_category_uuid: Ecto.UUID.t() | nil,
          default_category: Category.t() | nil,
          node_uuid: Ecto.UUID.t() | nil,
          other_exit_uuid: Ecto.UUID.t() | nil,
          no_response_exit_uuid: Ecto.UUID.t() | nil,
          wait: Wait.t() | nil,
          node: Node.t() | nil,
          cases: [Case.t()] | nil,
          categories: [Category.t()] | nil
        }

  schema "routers" do
    field(:type, :string)
    field(:operand, :string)
    field(:result_name, :string)
    field(:wait_type, :string)

    field(:default_category_uuid, Ecto.UUID)
    embeds_one(:default_category, Category)

    embeds_one(:wait, Wait)

    field(:node_uuid, Ecto.UUID)
    embeds_one(:node, Node)

    embeds_many(:cases, Case)
    embeds_many(:categories, Category)

    # in case we need to figure out the node for other/no response
    # lets cache the exit uuids
    field(:other_exit_uuid, Ecto.UUID)
    field(:no_response_exit_uuid, Ecto.UUID)
  end

  @doc """
  Process a json structure from flow editor to the Glific data types
  """
  @spec process(map(), map(), Node.t()) :: {Router.t(), map()}
  def process(%{"type" => "random"} = json, uuid_map, node) do
    Flows.check_required_fields(json, [:type, :categories])

    router = %Router{
      node: node,
      node_uuid: node.uuid,
      type: json["type"],
      operand: json["operand"] || "",
      result_name: json["result_name"]
    }

    {categories, uuid_map} =
      Flows.build_flow_objects(
        json["categories"],
        uuid_map,
        &Category.process/3
      )

    {Map.put(router, :categories, categories), uuid_map}
  end

  def process(json, uuid_map, node) do
    Flows.check_required_fields(json, @required_fields)

    router = %Router{
      node: node,
      node_uuid: node.uuid,
      type: json["type"],
      operand: json["operand"],
      result_name: json["result_name"]
    }

    {categories, uuid_map} =
      Flows.build_flow_objects(
        json["categories"],
        uuid_map,
        &Category.process/3
      )

    # Check that the default_category_uuid exists, if not raise an error
    if !Map.has_key?(uuid_map, json["default_category_uuid"]),
      do: raise(ArgumentError, message: "Default Category ID does not exist for Router")

    {cases, uuid_map} =
      Flows.build_flow_objects(
        json["cases"],
        uuid_map,
        &Case.process/3
      )

    {wait, uuid_map} =
      if Map.has_key?(json, "wait"),
        do: Wait.process(json["wait"], uuid_map, router),
        else: {nil, uuid_map}

    {
      router
      |> Map.put(:categories, categories)
      |> Map.put(:default_category_uuid, json["default_category_uuid"])
      |> Map.put(:cases, cases)
      |> Map.put(:wait, wait)
      |> Map.put(:other_exit_uuid, get_category_exit_uuid(categories, "Other"))
      |> Map.put(:no_response_exit_uuid, get_category_exit_uuid(categories, "No Response")),
      uuid_map
    }
  end

  @spec get_category_exit_uuid([Category.t()], String.t()) :: Ecto.UUID.t() | nil
  defp get_category_exit_uuid(categories, name) do
    category = Enum.find(categories, fn c -> c.name == name end)

    if is_nil(category),
      do: nil,
      else: category.exit_uuid
  end

  @spec validate_eex(Keyword.t(), String.t()) :: Keyword.t()
  defp validate_eex(errors, content) do
    cond do
      Glific.suspicious_code(content) ->
        [{EEx, "Suspicious Code"}] ++ errors

      !is_nil(EEx.compile_string(content)) ->
        errors
    end
  rescue
    # if there is a syntax error or anything else
    # an exception is thrown and hence we rescue it here
    _ ->
      [{EEx, "Invalid Code"}] ++ errors
  end

  @doc """
  Validate a action and all its children
  """
  @spec validate(Router.t(), Keyword.t(), map()) :: Keyword.t()
  def validate(router, errors, flow) do
    errors = validate_eex(errors, router.operand)

    errors =
      router.categories
      |> Enum.reduce(
        errors,
        &Category.validate(&1, &2, flow)
      )

    errors =
      router.cases
      |> Enum.reduce(
        errors,
        &Case.validate(&1, &2, flow, router.wait)
      )

    if router.wait,
      do: Wait.validate(router.wait, errors, flow),
      else: errors
  end

  @reserved_messages ["No Response", "Exit Loop", "Success", "Failure"]

  @doc """
  Execute a router, given a message stream.
  Consume the message stream as processing occurs
  """
  @spec execute(Router.t(), FlowContext.t(), [Message.t()]) ::
          {:ok, FlowContext.t(), [Message.t()]} | {:error, String.t()}
  def execute(nil, context, messages),
    do: {:ok, context, messages}

  ## Check if there is no messages and wait for
  ## user message to process further.

  def execute(%{wait: wait} = _router, context, []) when wait != nil,
    do: Wait.execute(wait, context, [])

  def execute(%{type: type} = router, context, messages) when type == "random" do
    Node.bump_count(router.node, context)

    {msg, rest} =
      if messages == [] do
        msg =
          context.organization_id
          |> Messages.create_temp_message("random")

        {msg, []}
      else
        [msg | rest] = messages
        {msg, rest}
      end

    {category_uuid, is_checkbox} = find_category(router, context, msg)
    execute_category(router, context, {msg, rest}, {category_uuid, is_checkbox})
  end

  def execute(%{type: type} = router, context, messages) when type == "switch" do
    Node.bump_count(router.node, context)

    {msg, rest} =
      if messages == [] do
        ## split by group is also calling the same function.
        ## currently we are differentiating based on operand
        split_by_expression(router, context)
      else
        [msg | rest] = messages
        {msg, rest}
      end

    context =
      if msg.body in @reserved_messages or is_nil(msg.id),
        do: context,
        else: FlowContext.update_recent(context, msg, :recent_inbound)

    {category_uuid, is_checkbox} = find_category(router, context, msg)

    execute_category(router, context, {msg, rest}, {category_uuid, is_checkbox})
  end

  def execute(_router, _context, _messages),
    do: raise(UndefinedFunctionError, message: "Unimplemented router type and/or wait type")

  @spec execute_category(
          Router.t(),
          FlowContext.t(),
          {Message.t(), [Message.t()]},
          {Ecto.UUID.t() | nil, boolean}
        ) ::
          {:ok, FlowContext.t(), [Message.t()]} | {:error, String.t()}
  defp execute_category(_router, context, {msg, _rest}, {nil, _is_checkbox}) do
    # lets reset the context tree
    FlowContext.reset_all_contexts(context, "Could not find category for: #{msg.body}")

    # This error is logged and sent upstream to the reporting engine
    {:error, dgettext("errors", "Could not find category for: %{body}", body: msg.body)}
  end

  defp execute_category(router, context, {msg, rest}, {category_uuid, is_checkbox}) do
    # find the category object and send it over
    {:ok, {:category, category}} = Map.fetch(context.uuid_map, category_uuid)

    translated_category_name = Localization.get_translated_category_name(context, category)

    category = Map.put(category, :name, translated_category_name)

    ## We need to change the category name for other translations.

    context =
      if is_nil(router.result_name),
        # if there is a result name, store it in the context table along with the category name first
        do: context,
        else: update_context_results(context, router.result_name, msg, {category, is_checkbox})

    Category.execute(category, context, rest)
  end

  ## We are using this operand for splitting contacts by groups
  @spec split_by_expression(Router.t(), FlowContext.t()) :: {Message.t(), []}
  defp split_by_expression(%{operand: "@contact.groups"} = _router, context) do
    contact = Contacts.get_contact_field_map(context.contact_id)

    msg =
      context.organization_id
      |> Messages.create_temp_message("#{inspect(contact.in_groups)}",
        extra: %{contact_groups: contact.in_groups}
      )

    {msg, []}
  end

  defp split_by_expression(router, context) do
    operand = format_operand(router.operand)

    content =
      FlowContext.parse_context_string(context, operand)
      # Once we have the content, we send it over to EEx to execute
      |> Glific.execute_eex()

    msg = Messages.create_temp_message(context.organization_id, content)
    {msg, []}
  end

  # return the right category but also return if it is a "checkbox" related category
  @spec find_category(Router.t(), FlowContext.t(), Message.t()) :: {Ecto.UUID.t() | nil, boolean}
  defp find_category(router, _context, %{body: body, extra: %{intent: intent}} = _msg)
       when body in @reserved_messages and is_nil(intent) do
    # Find the category with above name
    category = Enum.find(router.categories, fn c -> c.name == body end)

    if is_nil(category),
      do: {nil, false},
      else: {category.uuid, false}
  end

  defp find_category(%{type: "random"} = router, _context, _msg) do
    category = Enum.random(router.categories)
    {category.uuid, false}
  end

  defp find_category(router, context, msg) do
    # go thru the cases and find the first one that succeeds
    c =
      Enum.find(
        router.cases,
        nil,
        fn c -> Case.execute(c, context, msg) end
      )

    if is_nil(c),
      do: {router.default_category_uuid, false},
      else: {c.category_uuid, c.type == "has_multiple"}
  end

  @spec update_context_results(FlowContext.t(), String.t(), Message.t(), {Category.t(), boolean}) ::
          FlowContext.t()
  defp update_context_results(context, key, _msg, _) when key in ["", nil] do
    Logger.info("invalid results key for context: #{inspect(context)}")
    context
  end

  defp update_context_results(context, key, msg, {category, is_checkbox}) do
    default_results = %{
      "input" => msg.body,
      "category" => category.name,
      "inserted_at" => DateTime.utc_now()
    }

    results =
      cond do
        Flows.is_media_type?(msg.type) ->
          json =
            msg.media
            |> Map.take([:id, :source_url, :url, :caption])
            |> Map.put(:category, "media")
            |> Map.put(:input, msg.media.url)
            |> Map.put(:inserted_at, DateTime.utc_now())

          %{key => json}

        msg.type in [:location] ->
          json =
            msg.location
            |> Map.take([:id, :longitude, :latitude])
            |> Map.put(:category, "location")
            |> Map.put(:inserted_at, DateTime.utc_now())

          %{key => json}

        msg.type in [:quick_reply, :list] ->
          json =
            default_results
            |> Map.merge(msg.extra)
            |> Map.put("interactive", msg.interactive_content)

          %{key => json}

        is_checkbox ->
          %{
            key =>
              Map.merge(default_results, %{
                "selected" => msg.body |> Glific.make_set() |> MapSet.to_list()
              })
          }

        # this also handles msg.type in [:text]
        true ->
          %{
            key =>
              Map.merge(
                default_results,
                msg.extra
              )
          }
      end

    FlowContext.update_results(context, results)
  end

  ## Format operand and replace @fields. to @contact.fields. so that system can parse it automatically.
  ## for other router operand we are handling everything in a same way.
  @spec format_operand(String.t()) :: String.t()
  defp format_operand(nil), do: ""

  defp format_operand(operand) do
    if String.starts_with?(operand, "@fields."),
      do: String.replace(operand, "fields.", "contact.fields.", global: false),
      else: operand
  end
end