lib/exampple/xmpp/stanza/xdata.ex

defmodule Exampple.Xmpp.Stanza.Xdata do
  @moduledoc """
  Using Xdata gives the functionality to create and validate forms.
  You can use Xdata to define your own form, create a sent and/or validate
  the form against the rules you defined giving the facilities to translate
  it directly to stanzas or XML string.
  """
  alias Exampple.Xml.Xmlel
  alias Exampple.Xmpp.Jid
  alias Exampple.Xmpp.Stanza.Xdata.FieldError
  alias __MODULE__

  @type t() :: %__MODULE__{
          data: map(),
          xdata_form_type: String.t(),
          module: module(),
          errors: nil | [map()],
          valid?: boolean()
        }

  defstruct data: %{},
            xdata_form_type: "form",
            module: nil,
            errors: nil,
            valid?: true

  @doc false
  defmacro __using__(_args) do
    quote do
      import Exampple.Xmpp.Stanza.Xdata
      Module.register_attribute(__MODULE__, :fields, accumulate: true)
      @before_compile Exampple.Xmpp.Stanza.Xdata
      @instructions nil

      @doc """
      Creates a new Xdata structure. See more information in `Exampple.Xmpp.Stanza.Xdata`.
      """
      @spec new(String.t(), map()) :: Exampple.Xmpp.Stanza.Xdata.t()
      def new(xdata_form_type \\ "form", data \\ %{})

      def new("form", data) when map_size(data) == 0 do
        Exampple.Xmpp.Stanza.Xdata.new(__MODULE__)
      end

      def new(xdata_form_type, data) when map_size(data) == 0 do
        Exampple.Xmpp.Stanza.Xdata.new(__MODULE__, xdata_form_type)
      end

      def new(xdata_form_type, data) do
        Exampple.Xmpp.Stanza.Xdata.new(__MODULE__, xdata_form_type)
        |> Exampple.Xmpp.Stanza.Xdata.cast(data)
      end

      @doc """
      Check if a field inside of the definition is multi or not.
      See more information in `Exampple.Xmpp.Stanza.Xdata`.
      """
      @spec is_multi_type?(String.t()) :: :error | boolean()
      def is_multi_type?(var) do
        Exampple.Xmpp.Stanza.Xdata.is_multi_type?(__MODULE__, var)
      end

      @doc """
      Check if a field is existing into the form definition.
      See more information in `Exampple.Xmpp.Stanza.Xdata` .
      """
      @spec has_field?(String.t()) :: boolean()
      def has_field?(var) do
        Exampple.Xmpp.Stanza.Xdata.has_field?(__MODULE__, var)
      end

      @doc """
      Parse a form prefixing the module to the current one.
      See more information in `Exampple.Xmpp.Stanza.Xdata`.
      """
      @spec parse(String.t() | Exampple.Xml.Xmlel.t()) :: Exampple.Xmpp.Stanza.Xdata.t()
      def parse(form) do
        Exampple.Xmpp.Stanza.Xdata.parse(form, __MODULE__)
      end
    end
  end

  @doc """
  Creates a new Xdata structure. It let us define the module which has
  the definition for the xdata, the type attribute for the form tag. The possible
  types for the xdata form are the following ones:

  - `form` (default): The form-processing entity is asking the form-submitting
    entity to complete a form.
  - `submit`: The form-submitting entity is submitting data to the form-processing
    entity. The submission MAY include fields that were not provided in the empty
    form, but the form-processing entity MUST ignore any fields that it does not
    understand. Furthermore, the submission MAY omit fields not marked with <required/>
    by the form-processing entity.
  - `cancel`: The form-submitting entity has cancelled submission of data to the form
    processing entity.
  - `result`: The form-processing entity is returning data (e.g., search results) to
    the form-submitting entity, or the data is a generic data set.

  See further here: <https://xmpp.org/extensions/xep-0004.html#protocol-formtypes>
  """
  @spec new(module(), String.t()) :: t()
  def new(form_type_mod, xdata_form_type \\ "form") do
    %__MODULE__{
      module: form_type_mod,
      xdata_form_type: xdata_form_type
    }
  end

  @doc false
  defmacro __before_compile__(env) do
    fields = Module.get_attribute(env.module, :fields)

    quote do
      @doc """
      Retrieves all of the fields.
      """
      def get_fields(), do: unquote(fields)

      @doc """
      Retrieves a field based on the name (if exists), otherwise is nil.
      """
      def get_field(var) do
        Enum.find(unquote(fields), &(&1.var == var))
      end

      @doc """
      Retrieves the FORM_TYPE for the current form.
      """
      def get_form_type(), do: @form_type

      @doc """
      Retrieves the instructions (if any).
      """
      def get_instructions(), do: @instructions

      @doc """
      Retrieves the title (if any).
      """
      def get_title(), do: @title
    end
  end

  @doc """
  Creates a form definition inside of a module which is using
  the `Exampple.Xmpp.Stanza.Xdata` module. The parameters are the following:

  - `xmlns` (required) the namespace which will be inserted as `FORM_TYPE` data
    inside of the form.
  - `title` (optional) the title which will be apearing as the `<title/>` tag
    inside of the form.

  We have to define a configuration block where we will use other macros like
  `field` or `instructions`, see below.
  """
  defmacro form(xmlns, title \\ nil, do: block) do
    field =
      %{
        var: "FORM_TYPE",
        type: "hidden",
        required: false,
        label: nil,
        desc: nil,
        value: xmlns,
        options: nil
      }
      |> Macro.escape()
      |> Macro.escape()

    quote do
      @title unquote(title)
      @form_type unquote(xmlns)
      @fields unquote(field)
      unquote(block)
    end
  end

  @doc """
  Creates the instructions definition inside of the form definition. It accepts
  only one parameter which is the text regarding of the instructions to be included
  inside of the form.
  """
  defmacro instructions(text) do
    quote do
      @instructions unquote(text)
    end
  end

  # https://xmpp.org/extensions/xep-0004.html#protocol-fieldtypes
  defp get_type(:boolean), do: "boolean"
  defp get_type(:fixed), do: "fixed"
  defp get_type(:hidden), do: "hidden"
  defp get_type(:jid_multi), do: "jid-multi"
  defp get_type(:jid_single), do: "jid-single"
  defp get_type(:list_multi), do: "list-multi"
  defp get_type(:list_single), do: "list-single"
  defp get_type(:text_multi), do: "text-multi"
  defp get_type(:text_private), do: "text-private"
  defp get_type(:text_single), do: "text-single"

  defp get_type(type) do
    raise FieldError, """
    Field with type `#{type}` is invalid, please use one of the valid ones.
    Check: https://xmpp.org/extensions/xep-0004.html#protocol-fieldtypes
    """
  end

  @doc """
  Creates a field definition inside of the form definition. The field is the
  most important part of the form and they have two missions: specifiy what will
  be useful for the form to be retrieved from the input map (cast) and the kind
  of data the field supports to ensure we are using the correct values (validation).

  The field accepts two main arguments:

  - `var` is the name of the field.
  - `type` is the type of the field. The types are defined into the [XEP-004].
    and they are defined below.

  Types we can use:

  - `:boolean`: we can set the values as `true` or `1` and `false` or `0`.
  - `:fixed`: this is not data itself, it is in use to define a separator into the form.
  - `:hidden`: data which will be sent to the user, and it should back as is from user.
  - `:jid_multi`: possible to send one or more JID as values.
  - `:jid_single`: a JID.
  - `:list_multi`: possible to send one or more elements defined into a list of options.
  - `:list_single`: possible to send one element from a defined list of options.
  - `:text_multi`: possible to send one or more text values.
  - `:text_private`: tells to the interface to _obscure_ the input, special for password inputs.
  - `:text_single`: generic text entry. The most common.

  The other options we can add to the specification are:

  - `required` (boolean) if the value is required. Default to `false`.
  - `label` (text) a label which will be shown into an interface.
  - `desc` (text) the description of the input.
  - `value` is the default value indeed.
  - `options` is a list of 2-element tuples which are `{name, id}` letting use to define
    as the name (the visual definition of the value) and `id` as the element which we use:

    ```
    [{"Male", "M"}, {"Female", "F"}]
    ```

  [XEP-0004]: https://xmpp.org/extensions/xep-0004.html
  """
  defmacro field(var, type, args) do
    required = args[:required] || false
    label = args[:label]
    desc = args[:desc]
    value = args[:value]
    type = get_type(type)

    options =
      case type do
        "list-" <> _ -> args[:options]
        _ -> nil
      end

    field =
      %{
        var: var,
        type: type,
        required: required,
        label: label,
        desc: desc,
        value: value,
        options: options
      }
      |> Macro.escape()
      |> Macro.escape()

    quote do
      @fields unquote(field)
    end
  end

  @doc """
  Submitting a form is the action to get a previous form and proceed to
  the following step. The steps which follows usually a xdata form are the
  following ones:

  - `form`: we receive the form with this type and the specification of the
    fields. It gives us information about what we should to fill up.
  - `submit`: when we get a `form` and perform the submit function, it is
    changed to `submit`. We have to provide the specific data for the
    form as well. It performs first a `cast` and then a `validation`. If
    everything is fine, then we have prepared a form to be in use.
  - `result`: if we receive a `submit` form and we want to reply it, it is
    changed to `result` and we can provide more information inside or modify
    the information to report to the submitter what was changed or added.

  See `cast/2` and `validate_form/2` for further information.
  """
  def submit(%__MODULE__{xdata_form_type: "form"} = xdata, data) do
    %__MODULE__{xdata | xdata_form_type: "submit"}
    |> cast(data)
    |> validate_form()
  end

  def submit(%__MODULE__{xdata_form_type: "submit"} = xdata, data) do
    %__MODULE__{xdata | xdata_form_type: "result"}
    |> cast(data)
    |> validate_form()
  end

  @doc """
  Let us know if a field from a definition specified as the first parameter,
  exists and it's a multi value. We can receive a boolean value if the field
  is found, otherwise we will receive the atom `:error`. If the boolean value
  is true, that is meaning the type is `list-multi`, `text-multi` or `jid-multi`,
  otherwise we receive false.
  """
  @spec is_multi_type?(module(), String.t()) :: :error | boolean()
  def is_multi_type?(module, var) do
    case module.get_field(var) do
      nil -> :error
      field -> String.ends_with?(field.type, "-multi")
    end
  end

  @spec is_multi_type?(map()) :: boolean()
  def is_multi_type?(%{type: type}) do
    String.ends_with?(type, "-multi")
  end

  @doc """
  Let us know if the field exists inside of a form definition.
  """
  @spec has_field?(module(), String.t()) :: boolean()
  def has_field?(module, var) when is_atom(module) do
    not is_nil(module.get_field(var))
  end

  @doc """
  Transform a XML representation of a xdata form into a Xdata structure. We have
  to provide the data to be transformed in string or Xmlel structure representations
  as first parameter. The second parameter let us define the module which have the
  definitions of the form.
  """
  @spec parse(String.t() | Xmlel.t(), module()) :: t()
  def parse(form, module) when is_binary(form) do
    {form_str, ""} = Xmlel.parse(form)
    parse(form_str, module)
  end

  def parse(%Xmlel{} = form, module) do
    data =
      for %Xmlel{attrs: %{"var" => var}} = field <- form["field"],
          has_field?(module, var),
          into: %{} do
        values = for %Xmlel{children: [value]} <- field["value"] || [], do: value

        value =
          case module.is_multi_type?(var) do
            true -> values
            false when values == [] -> nil
            false -> hd(values)
          end

        {var, value}
      end

    %__MODULE__{
      module: module,
      data: data,
      xdata_form_type: form.attrs["type"] || "form"
    }
    |> validate_xdata_form_type()
  end

  defp validate_xdata_form_type(%__MODULE__{data: %{"FORM_TYPE" => form_type}} = xdata)
       when form_type in [nil, ""] do
    add_error(xdata, "form_type_missing", "FORM_TYPE is missing and is required")
  end

  defp validate_xdata_form_type(
         %__MODULE__{data: %{"FORM_TYPE" => xmlns}, module: module} = xdata
       ) do
    case module.get_form_type() do
      ^xmlns ->
        xdata

      right_xmlns ->
        mod_str =
          case to_string(module) do
            "Elixir." <> mod_str -> mod_str
            mod_str -> mod_str
          end

        add_error(
          xdata,
          "form_type_not_matching",
          "FORM_TYPE #{xmlns} != #{right_xmlns} invalid for #{mod_str}"
        )
    end
  end

  defp validate_xdata_form_type(%__MODULE__{} = xdata) do
    add_error(xdata, "form_type_missing", "FORM_TYPE is missing and is required")
  end

  @doc """
  Performs the validation of the form (or Xdata structure). It's checking the data
  stored inside previously using `cast/2` and then perform changes into the _errors_
  and _valid?_ internal values.

  After the running of the validation we'll get the transformed Xdata structure and
  we can use `valid?` value inside of the structure to know if the validation was
  fine or it detects errors.
  """
  @spec validate_form(t()) :: t()
  def validate_form(%__MODULE__{data: data, module: module} = xdata) do
    module.get_fields()
    |> Enum.reduce(xdata, fn %{var: var, required: is_required} = field, acc ->
      is_multi = is_multi_type?(field)

      case data[var] do
        nil when is_required -> add_error(acc, "required", "#{var} is required")
        "" when is_required -> add_error(acc, "required", "#{var} is required")
        nil -> acc
        "" -> acc
        [_ | _] when is_multi -> acc
        [_ | _] -> add_error(acc, "no-multi", "#{var} doesn't support multi values")
        _value when is_multi -> acc
        _value -> acc
      end
      |> validate_type(field, data[var])
    end)
    |> validate_xdata_form_type()
  end

  defp validate_type(xdata, _, nil), do: xdata

  defp validate_type(xdata, %{var: var, type: "boolean"}, value) do
    value
    |> String.downcase()
    |> String.trim()
    |> case do
      "true" -> xdata
      "false" -> xdata
      "0" -> xdata
      "1" -> xdata
      _ -> add_error(xdata, "invalid-boolean", "#{var} boolean is invalid: #{value}")
    end
  end

  defp validate_type(xdata, %{type: "hidden"}, _), do: xdata
  defp validate_type(xdata, %{type: "text-single"}, _), do: xdata
  defp validate_type(xdata, %{type: "text-multi"}, _), do: xdata

  defp validate_type(xdata, %{var: var, type: "fixed"}, fixed) do
    if String.contains?(fixed, ["\r", "\n"]) do
      add_error(xdata, "invalid-fixed", "#{var} fixed cannot contains \\r\\n")
    else
      xdata
    end
  end

  defp validate_type(xdata, %{var: var, type: "jid-single"}, jid) do
    case Jid.parse(jid) do
      %Jid{} ->
        xdata

      "" ->
        xdata

      {:error, :enojid} ->
        add_error(xdata, "invalid-jid", "#{var} has invalid JID `#{jid}`")
    end
  end

  defp validate_type(xdata, %{var: var, type: "jid-multi"}, jids) do
    Enum.reduce(jids, xdata, fn jid, acc ->
      case Jid.parse(jid) do
        %Jid{} ->
          acc

        "" ->
          acc

        {:error, :enojid} ->
          add_error(acc, "invalid-jid", "#{var} has invalid JID `#{jid}`")
      end
    end)
  end

  defp validate_type(xdata, %{var: var, type: "list-single", options: options}, option) do
    case List.keyfind(options, option, 1) do
      nil when option in [nil, ""] ->
        add_error(xdata, "invalid-option", "#{var} use invalid empty option")

      nil ->
        add_error(xdata, "invalid-option", "#{var} use invalid option `#{option}`")

      _option ->
        xdata
    end
  end

  defp validate_type(xdata, %{var: var, type: "list-multi", options: options}, option_multi) do
    Enum.reduce(option_multi, xdata, fn option, acc ->
      if List.keyfind(options, option, 1) do
        acc
      else
        add_error(acc, "invalid-option", "#{var} use invalid option `#{option}`")
      end
    end)
  end

  @doc """
  Performs the inclusion of the data from the map into the second parameter
  into the first one. It's not performing validation, it's only checking which
  values are legal to be included and the way (using the types) we could include
  them.
  """
  @spec cast(t(), map()) :: t()
  def cast(%__MODULE__{module: module} = xdata, %{} = data) do
    data =
      if Map.has_key?(data, "FORM_TYPE") do
        data
      else
        Map.put(data, "FORM_TYPE", module.get_form_type())
      end

    module.get_fields()
    |> Enum.reduce(xdata, fn %{var: var} = field, acc ->
      is_multi = is_multi_type?(field)

      case data[var] do
        nil -> acc
        [_ | _] = values when is_multi -> add_data(acc, var, values)
        [value | _] -> add_data(acc, var, value)
        value when is_multi -> add_data(acc, var, [value])
        value -> add_data(acc, var, value)
      end
    end)
  end

  @doc """
  Add a value inside of the data structure. In difference of `cast/2` this is
  forcing to be included and it's not checking the types.
  """
  @spec add_data(t(), String.t(), any()) :: t()
  def add_data(%__MODULE__{data: data} = xdata, var, value) do
    %__MODULE__{xdata | data: Map.put(data, var, value)}
  end

  @doc """
  Add an error definition inside of the structure and set the `valid?` value
  to `false`. It needs the name of the error and the definition (or text)
  for the error.
  """
  @spec add_error(t(), String.t(), String.t()) :: t()
  def add_error(%__MODULE__{errors: errors} = xdata, name, text) do
    error = %{name: name, text: text}
    %__MODULE__{xdata | valid?: false, errors: [error | errors || []]}
  end

  defimpl Exampple.Xml, for: __MODULE__ do
    alias Exampple.Xml.Xmlel
    alias Exampple.Xmpp.Stanza.Xdata

    defp maybe_add(_tag, nil), do: []
    defp maybe_add(tag, title), do: [Xmlel.new(tag, %{}, [title])]

    defp maybe_add_required(%{required: false}), do: []
    defp maybe_add_required(%{var: "FORM_TYPE"}), do: []
    defp maybe_add_required(%{required: true}), do: [Xmlel.new("required")]

    defp maybe_add_value(%{}, value) when is_binary(value) do
      [Xmlel.new("value", %{}, [value])]
    end

    defp maybe_add_value(%{}, values) when is_list(values) do
      for value <- values do
        Xmlel.new("value", %{}, [value])
      end
    end

    defp maybe_add_value(%{value: nil}, nil), do: []

    defp maybe_add_value(%{value: value}, nil) when is_binary(value) do
      [Xmlel.new("value", %{}, [value])]
    end

    defp maybe_add_value(%{value: values}, nil) when is_list(values) do
      for value <- values do
        Xmlel.new("value", %{}, [value])
      end
    end

    defp maybe_add_options(%{options: nil}), do: []

    defp maybe_add_options(%{options: options}) do
      for {name, id} <- options do
        Xmlel.new("option", %{"label" => name}, [Xmlel.new("value", %{}, [id])])
      end
    end

    defp maybe_add_label(attrs, %{label: nil}), do: attrs

    defp maybe_add_label(attrs, %{label: label}) do
      Map.put(attrs, "label", label)
    end

    defp get_final_type(%Xdata{valid?: false}), do: "cancel"
    defp get_final_type(%Xdata{xdata_form_type: type}), do: type

    @doc """
    Converts a Xdata form into the XML representation.
    """
    def to_xmlel(form) do
      Xmlel.new(
        "x",
        %{"type" => get_final_type(form), "xmlns" => "jabber:x:data"},
        (for field <- form.module.get_fields() do
           value = form.data[field.var]

           Xmlel.new(
             "field",
             maybe_add_label(%{"type" => field.type, "var" => field.var}, field),
             maybe_add_required(field) ++
               maybe_add_value(field, value) ++
               maybe_add_options(field)
           )
         end ++
           maybe_add("instructions", form.module.get_instructions()) ++
           maybe_add("title", form.module.get_title()))
        |> Enum.reverse()
      )
    end
  end

  defimpl String.Chars, for: __MODULE__ do
    @doc """
    Converts a Xdata form into the XML string representation.
    """
    def to_string(form) do
      form
      |> Exampple.Xml.to_xmlel()
      |> Kernel.to_string()
    end
  end
end