lib/smppex/pdu/multipart.ex

defmodule SMPPEX.Pdu.Multipart do
  @moduledoc """
  Module for operating with multipart information packed as UDH in message body.
  """

  alias :proplists, as: Proplists

  alias SMPPEX.Pdu
  alias SMPPEX.Pdu.UDH

  @concateneated_8bit_ref_ie_id 0x00
  @concateneated_16bit_ref_ie_id 0x08

  @error_invalid_8bit_ie "Invalid 8bit refrence number concatenated short messages info"
  @error_invalid_16bit_ie "Invalid 16bit refrence number concatenated short messages info"
  @error_invalid_pdu "Pdu has no message field"
  @error_not_a_multipart_message "Message is not multipart"

  @error_invalid_ref_num "Invalid refrence number in multipart info"
  @error_invalid_count "Invalid count in multipart info"
  @error_invalid_seq_num "Invalid sequence number in multipart info"

  @error_invalid_max "Invalid limits for splitting message"
  @error_invalid_message "Invalid message"

  @type actual_part_info ::
          {ref_num :: non_neg_integer, count :: non_neg_integer, seq_num :: non_neg_integer}
  @type part_info :: :single | actual_part_info
  @type extract_result :: {:ok, part_info, binary} | {:error, term}

  @spec extract_from_message(binary) :: extract_result

  @doc """
  Extracts multipart information from binary message.

  Returns one of the following:
  * `{:ok, :single, message}` if the `message` does not contain any multipart information and represents a
  single message. The outcoming `message` is cleared from UDH bytes.
  * `{:ok, {ref_num, count, seq_num}, message}` if the original message contains multipart information in
  UDH fields. The outcoming `message` is cleared from UDH bytes.
  * `{:error, reason}`

  ## Example

      iex> data = <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>
      iex> SMPPEX.Pdu.Multipart.extract_from_message(data)
      {:ok, {3,2,1}, "message"}

      iex> data = <<0x06, 0x08, 0x04, 0x00, 0x03, 0x02, 0x01, "message">>
      iex> SMPPEX.Pdu.Multipart.extract_from_message(data)
      {:ok, {3,2,1}, "message"}

  """
  def extract_from_message(message) when is_binary(message) do
    case UDH.extract(message) do
      {:ok, ies, message} ->
        case extract_from_ies(ies) do
          {:ok, part_info} -> {:ok, part_info, message}
          {:error, _} = err -> err
        end

      {:error, _} = err ->
        err
    end
  end

  @spec extract_from_pdu(Pdu.t()) :: extract_result

  @doc """
  Extracts multipart information from PDU.

  Returns one of the following:
  * `{:ok, :single, message}` if the `message` does not contain any multipart information and represents a
  single message. The outcoming `message` is cleared from UDH bytes.
  * `{:ok, {ref_num, count, seq_num}, message}` if the original message contains multipart information in
  UDH fields. The outcoming `message` is cleared from UDH bytes.
  * `{:error, reason}`

  ## Example

      iex> pdu = Pdu.new({1,0,1}, %{esm_class: 0b01000000, short_message: <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>})
      iex> SMPPEX.Pdu.Multipart.extract_from_pdu(pdu)
      {:ok, {3,2,1}, "message"}

      iex> pdu = Pdu.new({1,0,1}, %{short_message: <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>})
      iex> SMPPEX.Pdu.Multipart.extract_from_pdu(pdu)
      {:error, "#{@error_not_a_multipart_message}"}

  """
  def extract_from_pdu(%Pdu{} = pdu) do
    message = Pdu.field(pdu, :message_payload) || Pdu.field(pdu, :short_message)

    if message do
      if UDH.has_udh?(pdu) do
        extract_from_message(message)
      else
        {:error, @error_not_a_multipart_message}
      end
    else
      {:error, @error_invalid_pdu}
    end
  end

  @spec extract(Pdu.t() | binary) :: extract_result

  @doc """
  Extracts multipart information from PDU or directly from binary message. This function is deprecated. Use `extract_from_pdu/1` or `extract_from_message/1` instead.

  Returns one of the following:
  * `{:ok, :single, message}` if the `message` does not contain any multipart information and represents a
  single message;
  * `{:ok, {ref_num, count, seq_num}, message}` if the original message contains multipart information in
  UDH fields. The outcoming `message` is cleared from UDH bytes.
  * `{:error, reason}`

  ## Example

      iex> data = <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>
      iex> SMPPEX.Pdu.Multipart.extract(data)
      {:ok, {3,2,1}, "message"}

      iex> data = <<0x06, 0x08, 0x04, 0x00, 0x03, 0x02, 0x01, "message">>
      iex> SMPPEX.Pdu.Multipart.extract(data)
      {:ok, {3,2,1}, "message"}

      iex> pdu = Pdu.new({1,0,1}, %{esm_class: 0b01000000, short_message: <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>})
      iex> SMPPEX.Pdu.Multipart.extract(pdu)
      {:ok, {3,2,1}, "message"}

      iex> pdu = Pdu.new({1,0,1}, %{short_message: <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>})
      iex> SMPPEX.Pdu.Multipart.extract(pdu)
      {:error, "#{@error_not_a_multipart_message}"}

  """
  def extract(message) when is_binary(message) do
    extract_from_message(message)
  end

  def extract(pdu) do
    extract_from_pdu(pdu)
  end

  @spec extract_from_ies(list(UDH.ie())) :: {:ok, part_info} | {:error, any}

  @doc """
  Extracts multipart information from already parsed list of UDH IEs.

  Return one of the following:
  * `{:ok, :single}` if IEs do not contain any multipart message related ones;
  * `{:ok, {ref_num, count, seq_num}}` if there are multipart message related IEs (the first is taken);
  * `{:error, reason}` in case of errors.

  ## Example

      iex> ies = [{0, <<0x03, 0x02, 0x01>>}]
      iex> SMPPEX.Pdu.Multipart.extract_from_ies(ies)
      {:ok, {3, 2, 1}}

      iex> ies = [{0, <<0x03, 0x02, 0x01>>}, {8, <<0x00, 0x04, 0x02, 0x01>>}]
      iex> SMPPEX.Pdu.Multipart.extract_from_ies(ies)
      {:ok, {3, 2, 1}}

      iex> ies = [{8, <<0x00, 0x03, 0x02, 0x01>>}]
      iex> SMPPEX.Pdu.Multipart.extract_from_ies(ies)
      {:ok, {3, 2, 1}}

      iex> ies = [{8, <<0x00, 0x03, 0x02>>}]
      iex> SMPPEX.Pdu.Multipart.extract_from_ies(ies)
      {:error, "#{@error_invalid_16bit_ie}"}

      iex> ies = []
      iex> SMPPEX.Pdu.Multipart.extract_from_ies(ies)
      {:ok, :single}

  """
  def extract_from_ies(ies) do
    cond do
      Proplists.is_defined(@concateneated_8bit_ref_ie_id, ies) ->
        @concateneated_8bit_ref_ie_id |> Proplists.get_value(ies) |> parse_8bit

      Proplists.is_defined(@concateneated_16bit_ref_ie_id, ies) ->
        @concateneated_16bit_ref_ie_id |> Proplists.get_value(ies) |> parse_16bit

      true ->
        {:ok, :single}
    end
  end

  defp parse_8bit(<<
         ref_num::integer-unsigned-size(8),
         count::integer-unsigned-size(8),
         seq_num::integer-unsigned-size(8)
       >>) do
    {:ok, {ref_num, count, seq_num}}
  end

  defp parse_8bit(_), do: {:error, @error_invalid_8bit_ie}

  defp parse_16bit(<<
         ref_num::integer-big-unsigned-size(16),
         count::integer-unsigned-size(8),
         seq_num::integer-unsigned-size(8)
       >>) do
    {:ok, {ref_num, count, seq_num}}
  end

  defp parse_16bit(_), do: {:error, @error_invalid_16bit_ie}

  @spec multipart_ie(actual_part_info) :: {:error, term} | {:ok, UDH.ie()}

  @doc """
  Generates IE encoding multipart information.

  ## Example

      iex> SMPPEX.Pdu.Multipart.multipart_ie({3,2,1})
      {:ok, {0, <<0x03, 0x02, 0x01>>}}

      iex> SMPPEX.Pdu.Multipart.multipart_ie({256,2,1})
      {:ok, {8, <<0x01, 0x00, 0x02, 0x01>>}}

      iex> SMPPEX.Pdu.Multipart.multipart_ie({1, 1, 256})
      {:error, "#{@error_invalid_seq_num}"}

  """
  def multipart_ie({ref_num, _count, _seq_num}) when ref_num < 0 or ref_num > 65_535,
    do: {:error, @error_invalid_ref_num}

  def multipart_ie({_ref_num, count, _seq_num}) when count < 1 or count > 255,
    do: {:error, @error_invalid_count}

  def multipart_ie({_ref_num, _count, seq_num}) when seq_num < 1 or seq_num > 255,
    do: {:error, @error_invalid_seq_num}

  def multipart_ie({ref_num, count, seq_num}) do
    {
      :ok,
      if ref_num > 255 do
        {@concateneated_16bit_ref_ie_id,
         <<
           ref_num::integer-big-unsigned-size(16),
           count::integer-unsigned-size(8),
           seq_num::integer-unsigned-size(8)
         >>}
      else
        {@concateneated_8bit_ref_ie_id,
         <<
           ref_num::integer-unsigned-size(8),
           count::integer-unsigned-size(8),
           seq_num::integer-unsigned-size(8)
         >>}
      end
    }
  end

  @spec prepend_message_with_part_info(actual_part_info, binary) :: {:error, term} | {:ok, binary}

  @doc """
  Prepends message with multipart info encoded as UDH.

  ## Example

      iex> SMPPEX.Pdu.Multipart.prepend_message_with_part_info({3,2,1}, "message")
      {:ok, <<0x05, 0x00, 0x03, 0x03, 0x02, 0x01, "message">>}

      iex> SMPPEX.Pdu.Multipart.prepend_message_with_part_info({256,2,1}, "message")
      {:ok, <<0x06, 0x08, 0x04, 0x01, 0x00, 0x02, 0x01, "message">>}

  """
  def prepend_message_with_part_info(part_info, message) do
    case multipart_ie(part_info) do
      {:ok, ie} -> UDH.add([ie], message)
      {:error, _} = err -> err
    end
  end

  @type split_result :: {:ok, :unsplit} | {:ok, :split, [binary]} | {:error, term}

  @spec split_message(ref_num :: integer, message :: binary, max_len :: integer) :: split_result

  @doc """
  Splits message into parts prepending each part with multipart information UDH
  so that the resulting size of each part does not exceed `max_len` bytes.

  The result is one of the following:
  * `{:ok, :unsplit}` if the message already fits into `max_len` bytes;
  * `{:ok, :split, parts}` if the message was succesfully split into `parts`;
  * `{:error, reason}` in case of errors.

  ## Example

      iex> SMPPEX.Pdu.Multipart.split_message(123, "abc", 3)
      {:ok, :unsplit}

      iex> SMPPEX.Pdu.Multipart.split_message(123, "abcdefg", 6)
      {:error, "#{@error_invalid_max}"}

      iex> SMPPEX.Pdu.Multipart.split_message(123, "abcdefghi", 8)
      {:ok, :split, [
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x01, "ab">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x02, "cd">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x03, "ef">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x04, "gh">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x05, "i">>
      ]}

  """

  def split_message(_ref_num, message, _max_len) when not is_binary(message),
    do: {:error, @error_invalid_message}

  def split_message(ref_num, _message, _max_len) when ref_num < 0,
    do: {:error, @error_invalid_ref_num}

  def split_message(ref_num, _message, _max_len) when ref_num > 65_535,
    do: {:error, @error_invalid_ref_num}

  def split_message(_ref_num, _message, max_len) when max_len < 0,
    do: {:error, @error_invalid_max}

  def split_message(ref_num, message, max_len) do
    case prepend_message_with_part_info({ref_num, 1, 1}, <<>>) do
      {:ok, bin} ->
        max_split = if max_len >= byte_size(bin), do: max_len - byte_size(bin), else: 0
        split_message(ref_num, message, max_len, max_split)

      {:error, _} = err ->
        err
    end
  end

  @spec split_message(
          ref_num :: integer,
          message :: binary,
          max_len :: integer,
          max_split :: integer
        ) :: split_result

  @doc """
  Splits message into parts not exceeding `max_split` bytes and prepending each part with multipart information UDH.
  The message is not split if its size does not exceed `max_len` bytes.

  The results format is the same as in `split_message/3`.

  ## Example

      iex> SMPPEX.Pdu.Multipart.split_message(123, "abcdefghi", 0, 2)
      {:ok, :split, [
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x01, "ab">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x02, "cd">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x03, "ef">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x04, "gh">>,
        <<0x05, 0x00, 0x03, 0x7b, 0x05, 0x05, "i">>
      ]}

  """

  def split_message(_ref_num, message, _max_len, _max_split) when not is_binary(message),
    do: {:error, @error_invalid_message}

  def split_message(ref_num, _message, _max_len, _max_split) when ref_num < 0,
    do: {:error, @error_invalid_ref_num}

  def split_message(ref_num, _message, _max_len, _max_split) when ref_num > 65_535,
    do: {:error, @error_invalid_ref_num}

  def split_message(_ref_num, _message, max_len, _max_split) when max_len < 0,
    do: {:error, @error_invalid_max}

  def split_message(_ref_num, _message, _max_len, max_split) when max_split < 0,
    do: {:error, @error_invalid_max}

  def split_message(ref_num, message, max_len, max_split) do
    message_size = byte_size(message)

    if message_size <= max_len do
      {:ok, :unsplit}
    else
      if max_split > 0 do
        part_count = message_part_count(message_size, max_split)
        split_message_into_parts({ref_num, part_count, 1}, message, max_split, [])
      else
        {:error, @error_invalid_max}
      end
    end
  end

  defp message_part_count(message_size, max_size) do
    if rem(message_size, max_size) == 0 do
      div(message_size, max_size)
    else
      1 + div(message_size, max_size)
    end
  end

  defp split_message_into_parts({_ref_num, count, n}, <<>>, _max_len, parts) when n > count,
    do: {:ok, :split, Enum.reverse(parts)}

  defp split_message_into_parts({ref_num, count, n} = part_info, message, max_len, parts) do
    {part, rest} =
      case message do
        <<part::binary-size(max_len), rest::binary>> -> {part, rest}
        last_part -> {last_part, <<>>}
      end

    case prepend_message_with_part_info(part_info, part) do
      {:ok, part_with_info} ->
        split_message_into_parts({ref_num, count, n + 1}, rest, max_len, [part_with_info | parts])

      {:error, _} = err ->
        err
    end
  end
end