lib/tox/period.ex

defmodule Tox.Period do
  @moduledoc """
  A `Period` struct and functions.

  The Period struct contains the fields `year`, `month`, `week`, `day`, `hour`,
  `minute` and second. The values for the fields represent the amount of time
  for a unit. Expected `second`, all values are integers equal or greater than
  `0`. The field `second` can also be a float equals to or greater than `0`.
  """

  @microseconds_per_second 1_000_000

  @key_map_date %{?Y => :year, ?M => :month, ?W => :week, ?D => :day}
  @key_map_time %{?H => :hour, ?M => :minute, ?S => :second}

  @typedoc """
  An amount of time with a specified unit e.g. `{second: 5.500}` or `{hour: 1}`.
  The amount of all durations must be equal or greater as `0`.
  """
  @type duration ::
          {:year, non_neg_integer()}
          | {:month, non_neg_integer}
          | {:week, non_neg_integer}
          | {:day, non_neg_integer}
          | {:hour, non_neg_integer}
          | {:minute, non_neg_integer}
          | {:second, non_neg_integer}

  @type t :: %__MODULE__{
          year: non_neg_integer(),
          month: non_neg_integer(),
          week: non_neg_integer(),
          day: non_neg_integer(),
          hour: non_neg_integer(),
          minute: non_neg_integer(),
          second: non_neg_integer() | float()
        }

  defstruct year: 0, month: 0, week: 0, day: 0, hour: 0, minute: 0, second: 0

  @doc """
  Creates a new period. All values in durations must be greater or equal `0`.

  ## Examples

      iex> {:ok, period} = Tox.Period.new(1, 2, 3, 4, 5, 6, 7.8)
      iex> period
      #Tox.Period<P1Y2M3W4DT5H6M7.8S>

      iex> Tox.Period.new(1, 2, 3, 4, 5, 6, -7.8)
      {:error, :invalid_period}

  """
  @spec new(
          year :: non_neg_integer(),
          month :: non_neg_integer(),
          week :: non_neg_integer(),
          day :: non_neg_integer(),
          hour :: non_neg_integer(),
          minute :: non_neg_integer(),
          second :: non_neg_integer() | float
        ) :: {:ok, t()} | {:error, :invalid_period}
  def new(year, month, week, day, hour, minute, second) do
    new(
      year: year,
      month: month,
      week: week,
      day: day,
      hour: hour,
      minute: minute,
      second: second
    )
  end

  @doc """
  Creates a new period or raise an error.

  See `new/7` for more informations.

  ## Examples

      iex> Tox.Period.new!(1, 2, 3, 4, 5, 6, 7.8)
      #Tox.Period<P1Y2M3W4DT5H6M7.8S>

      iex> Tox.Period.new!(1, 2, 3, 4, 5, 6, -7.8)
      ** (ArgumentError) cannot create a new period with [year: 1, month: 2, week: 3, day: 4, hour: 5, minute: 6, second: -7.8], reason: :invalid_period

  """
  @spec new!(
          year :: non_neg_integer(),
          month :: non_neg_integer(),
          week :: non_neg_integer(),
          day :: non_neg_integer(),
          hour :: non_neg_integer(),
          minute :: non_neg_integer(),
          second :: non_neg_integer() | float
        ) :: t()
  def new!(year, month, week, day, hour, minute, second) do
    new!(
      year: year,
      month: month,
      week: week,
      day: day,
      hour: hour,
      minute: minute,
      second: second
    )
  end

  @doc """
  Creates a new period from `durations`. All values in the `durations` must be
  equal or greater `0`.

  ## Examples

      iex> {:ok, period} = Tox.Period.new(day: 4, hour: 5)
      iex> period
      #Tox.Period<P4DT5H>

      iex> Tox.Period.new(minute: -1)
      {:error, :invalid_period}

  """
  @spec new([duration()]) :: {:ok, t()} | {:error, :invalid_period}
  def new(durations) do
    case is_valid?(durations) do
      true -> {:ok, struct(__MODULE__, durations)}
      false -> {:error, :invalid_period}
    end
  end

  @doc """
  Creates a new period from `durations` or raises an error.

  See `new/1` for more informations.

  ## Examples

      iex> Tox.Period.new!(month: 1, minute: 1)
      #Tox.Period<P1MT1M>

      iex> Tox.Period.new!(year: 0.5)
      ** (ArgumentError) cannot create a new period with [year: 0.5], reason: :invalid_period

  """
  @spec new!([duration()]) :: t()
  def new!(durations) do
    case new(durations) do
      {:ok, period} ->
        period

      {:error, reason} ->
        raise ArgumentError,
              "cannot create a new period with #{inspect(durations)}, " <>
                "reason: #{inspect(reason)}"
    end
  end

  @doc """
  Creates a new period from a string.

  A string representation of a period has the format `PiYiMiWiDTiHiMfS`. The `i`
  represents an integer and the `f` a float. All integers and the float must be
  equal or greater as `0`. Leading zeros are not required.  The capital letters
  `P` , `Y`, `M`, `W`, `D`, `T`, `H`, `M`, and `S` are designators for each of
  the date and time elements and are not replaced.

  * P is the period designator (optional).
    * Y is the year designator that follows the value for the number of years.
    * M is the month designator that follows the value for the number of months.
    * W is the week designator that follows the value for the number of weeks.
    * D is the day designator that follows the value for the number of days.
  * T is the time designator that precedes the time components of the representation.
    * H is the hour designator that follows the value for the number of hours.
    * M is the minute designator that follows the value for the number of minutes.
    * S is the second designator that follows the value for the number of seconds.

  ## Examples

      iex> Tox.Period.parse("1Y3M")
      Tox.Period.new(year: 1, month: 3)

      iex> Tox.Period.parse("T12M5.5S")
      Tox.Period.new(minute: 12, second: 5.5)

      iex> Tox.Period.parse("P1Y3MT2H")
      Tox.Period.new(year: 1, month: 3, hour: 2)

      iex> Tox.Period.parse("1y")
      {:error, :invalid_format}

  """
  @spec parse(String.t()) :: {:ok, t()} | {:error, :invalid_format}
  def parse("P" <> string) when is_binary(string), do: parse(string)

  def parse(string) when is_binary(string) do
    with {:ok, durations} <- do_parse(string) do
      new(durations)
    end
  end

  @doc """
  Creates a new period from a string.

  See `parse/1` for more informations.

  ## Examples

      iex> Tox.Period.parse!("T12M5.5S")
      #Tox.Period<PT12M5.5S>

      iex> Tox.Period.parse!("1y")
      ** (ArgumentError) cannot parse "1y" as period, reason: :invalid_format

  """
  @spec parse!(String.t()) :: t()
  def parse!(string) do
    case parse(string) do
      {:ok, period} ->
        period

      {:error, reason} ->
        raise ArgumentError,
              "cannot parse #{inspect(string)} as period, reason: #{inspect(reason)}"
    end
  end

  @doc """
  Returns the `period` as `[Tox.duration]`. The optional `sign` can be `:pos`
  for positive `durations` and `:neg` for negative `durations`, defaults to
  `:pos`. A duration with an amount of `0` will be excluded form the
  `durations`.

  ## Examples

      iex> {:ok, period} = Tox.Period.parse("P1Y3MT2H1.123S")
      iex> Tox.Period.to_durations(period)
      [year: 1, month: 3, hour: 2, second: 1, microsecond: 123000]
      iex> Tox.Period.to_durations(period, :neg)
      [year: -1, month: -3, hour: -2, second: -1, microsecond: -123000]

      iex> {:ok, period} = Tox.Period.parse("1MT1M")
      iex> Tox.Period.to_durations(period)
      [month: 1, minute: 1]

  """
  @spec to_durations(t(), :pos | :neg) :: [Tox.duration()]
  def to_durations(period, sign \\ :pos)

  def to_durations(%__MODULE__{} = period, sign) when sign in [:pos, :neg] do
    Enum.reduce([:second, :minute, :hour, :day, :week, :month, :year], [], fn key, durations ->
      do_to_durations(durations, period, key, sign)
    end)
  end

  # Helpers

  defp is_valid?(durations) do
    Enum.any?(durations, fn {_unit, value} -> value > 0 end) &&
      Enum.all?(durations, fn
        {:second, value} -> is_number(value) && value >= 0
        {_unit, value} -> is_integer(value) && value >= 0
      end)
  end

  defp do_to_durations(durations, %__MODULE__{} = period, :second, sign) do
    value = Map.fetch!(period, :second)
    second = trunc(value)
    microsecond = trunc((value - second) * @microseconds_per_second)

    durations
    |> do_to_durations(microsecond, :microsecond, sign)
    |> do_to_durations(second, :second, sign)
  end

  defp do_to_durations(durations, %__MODULE__{} = period, key, sign) do
    case {Map.fetch!(period, key), sign} do
      {value, :pos} when value > 0 -> Keyword.put(durations, key, value)
      {value, :neg} when value > 0 -> Keyword.put(durations, key, value * -1)
      _zero -> durations
    end
  end

  defp do_to_durations(durations, 0, _key, _sign), do: durations

  defp do_to_durations(durations, value, key, sign) when is_integer(value) do
    value =
      case sign do
        :pos -> value
        :neg -> value * -1
      end

    Keyword.put(durations, key, value)
  end

  defp do_parse(string) when is_binary(string) do
    string
    |> String.split("T")
    |> case do
      [date] ->
        do_parse(date, @key_map_date)

      ["", time] ->
        do_parse(time, @key_map_time)

      [date, time] ->
        with {:ok, durations_date} <- do_parse(date, @key_map_date),
             {:ok, durations_time} <- do_parse(time, @key_map_time) do
          {:ok, Keyword.merge(durations_date, durations_time)}
        end
    end
  end

  defp do_parse(string, key_map) when is_binary(string) do
    designators_list = Map.keys(key_map)

    string
    |> String.to_charlist()
    |> Enum.reduce_while({[], []}, fn char, {designators, num} ->
      cond do
        char == ?. ->
          {:cont, {designators, [char | num]}}

        char in ?0..?9 ->
          {:cont, {designators, [char | num]}}

        char in designators_list ->
          with {:ok, key} <- Map.fetch(key_map, char),
               {:ok, value} <- parse_value(key, Enum.reverse(num)) do
            {:cont, {Keyword.put(designators, key, value), []}}
          else
            :error -> {:halt, :error}
          end

        true ->
          {:halt, :error}
      end
    end)
    |> case do
      {durations, []} -> {:ok, durations}
      _error -> {:error, :invalid_format}
    end
  end

  defp parse_value(:second, num) do
    num
    |> to_string()
    |> Float.parse()
    |> case do
      {value, ""} -> {:ok, value}
      _error -> :error
    end
  end

  defp parse_value(_key, num) do
    num
    |> to_string()
    |> Integer.parse()
    |> case do
      {value, ""} -> {:ok, value}
      _error -> :error
    end
  end

  defimpl Inspect do
    alias Tox.Period

    @spec inspect(Period.t(), Inspect.Opts.t()) :: String.t()
    def inspect(period, _opts) do
      "#Tox.Period<#{to_string(period)}>"
    end
  end

  defimpl String.Chars do
    alias Tox.Period

    @designators %{
      year: 'Y',
      month: 'M',
      week: 'W',
      day: 'D',
      hour: 'H',
      minute: 'M',
      second: 'S'
    }

    @spec to_string(Period.t()) :: String.t()
    def to_string(period) do
      period_date = period_to_string(period, [:year, :month, :week, :day])
      period_time = period_to_string(period, [:hour, :minute, :second])

      if period_time == "", do: "P#{period_date}", else: "P#{period_date}T#{period_time}"
    end

    defp period_to_string(period, keys) do
      Enum.reduce(keys, "", fn key, string ->
        case Map.fetch!(period, key) do
          value when value > 0 -> "#{string}#{value}#{Map.fetch!(@designators, key)}"
          _zero -> string
        end
      end)
    end
  end
end

defmodule Tox.Period.Sigil do
  @moduledoc """
  A `~P` sigil for periods.
  """
  alias Tox.Period

  @doc """
  Handles the sigil `~P` for periods.

  ## Examples

      iex> import Tox.Period.Sigil
      iex> ~P[1Y2DT1H10.10S]
      #Tox.Period<P1Y2DT1H10.1S>
      iex> ~P[1y]
      ** (ArgumentError) cannot parse "1y" as period

  """
  @spec sigil_P(binary(), list()) :: Period.t()
  def sigil_P(string, _modifiers) do
    case Period.parse(string) do
      {:ok, period} -> period
      {:error, _} -> raise ArgumentError, "cannot parse #{inspect(string)} as period"
    end
  end
end