lib/ex_sdp/repeat_times.ex

defmodule ExSDP.RepeatTimes do
  @moduledoc """
  This module represents the field of SDP that specifies
  rebroadcasts of a session. Works directly in conjunction
  with timing `t` parameter.

   - active_duration - how long session will last
   - repeat_interval - interval of session rebroadcast
   - offsets - offset between scheduled rebroadcast

  If `start_time` of `t` is set to today 3 pm, `active_duration` is set
  to `3h`, `repeat_interval` is set to `14d` and `offsets` are `0 4d`
  then the session will be rebroadcasted today at 3 pm and on Thursday 3 pm
  every two week until `end_time` of param `t`.

  For more details please see [RFC4566 Section 5.10](https://tools.ietf.org/html/rfc4566#section-5.10).
  """
  use Bunch.Access

  @enforce_keys [:repeat_interval, :active_duration, :offsets]
  defstruct @enforce_keys

  @type t :: %__MODULE__{
          repeat_interval: non_neg_integer(),
          active_duration: non_neg_integer(),
          offsets: [non_neg_integer()]
        }

  @unit_mappings %{
    "d" => 86_400,
    "h" => 3600,
    "m" => 60,
    "s" => 1
  }

  @valid_keys Map.keys(@unit_mappings)

  @type reason ::
          :duration_nan
          | :interval_nan
          | :no_offsets
          | :malformed_repeat
          | {:invalid_offset | :invalid_unit, binary()}

  @spec parse(binary()) :: {:ok, t()} | {:error, reason}
  def parse(repeat) do
    case String.split(repeat, " ") do
      [interval, duration | offsets] = as_list ->
        if compact?(as_list) do
          parse_compact(as_list)
        else
          parse_explicit(interval, duration, offsets)
        end

      _malformed_repeat ->
        {:error, :malformed_repeat}
    end
  end

  defp compact?(parts) do
    Enum.any?(parts, fn time ->
      Enum.any?(@valid_keys, fn unit -> String.ends_with?(time, unit) end)
    end)
  end

  defp parse_explicit(_interval, _duration, []), do: {:error, :no_offsets}

  defp parse_explicit(interval, duration, offsets) do
    with {interval, ""} <- Integer.parse(interval),
         {duration, ""} <- Integer.parse(duration),
         {:ok, offsets} <- process_offsets(offsets) do
      explicit_repeat = %__MODULE__{
        repeat_interval: interval,
        active_duration: duration,
        offsets: offsets
      }

      {:ok, explicit_repeat}
    else
      {:error, _} = error -> error
      _other_error -> {:error, :malformed_repeat}
    end
  end

  defp process_offsets(offsets, acc \\ [])
  defp process_offsets([], acc), do: {:ok, Enum.reverse(acc)}

  defp process_offsets([offset | rest], acc) do
    case Integer.parse(offset) do
      {offset, ""} when offset >= 0 -> process_offsets(rest, [offset | acc])
      {_, _} -> {:error, {:invalid_offset, offset}}
    end
  end

  defp parse_compact(list) do
    with [_ | _] = result <- decode_compact(list) do
      result
      |> Enum.reverse()
      |> build_compact()
    end
  end

  defp decode_compact(list) do
    Enum.reduce_while(list, [], fn
      "0", acc ->
        {:cont, [0 | acc]}

      elem, acc ->
        case Integer.parse(elem) do
          {value, unit} when unit in @valid_keys ->
            time = value * @unit_mappings[unit]
            {:cont, [time | acc]}

          {_, invalid_unit} ->
            {:halt, {:error, {:invalid_unit, invalid_unit}}}
        end
    end)
  end

  defp build_compact(list)
  defp build_compact([_, _]), do: {:error, :no_offsets}

  defp build_compact([interval, duration | offsets]) do
    compact = %__MODULE__{
      repeat_interval: interval,
      active_duration: duration,
      offsets: offsets
    }

    {:ok, compact}
  end
end

defimpl String.Chars, for: ExSDP.RepeatTimes do
  @impl true
  def to_string(repeat_times) do
    serialized_fields =
      [
        Integer.to_string(repeat_times.repeat_interval),
        Integer.to_string(repeat_times.active_duration)
      ] ++
        Enum.map(repeat_times.offsets, &Integer.to_string/1)

    Enum.join(serialized_fields, " ")
  end
end