lib/datix/time.ex

defmodule Datix.Time do
  @moduledoc """
  A `Time` parser using `Calendar.strftime/3` format-string.
  """

  alias Datix.ValidationError

  @doc """
  Parses a time string according to the given `format`.

  See the `Calendar.strftime/3` documentation for how to specify a format string.

  ## Options

    * `:calendar` - the calendar to build the `Time`, defaults to `Calendar.ISO`

    * `:preferred_time` - a string for the preferred format to show times,
      it can't contain the `%X` format and defaults to `"%H:%M:%S"`
      if the option is not received

    * `:am_pm_names` - a keyword list with the names of the period of the day,
      defaults to `[am: "am", pm: "pm"]`.

  Missing values will be set to minimum.

  ## Examples

      iex> Datix.Time.parse("11:12:55", "%X")
      {:ok, ~T[11:12:55]}

      iex> format = Datix.compile!("%X")
      iex> Datix.Time.parse("11:12:55", format)
      {:ok, ~T[11:12:55]}

      iex> Datix.Time.parse("10 PM", "%I %p")
      {:ok, ~T[22:00:00]}

  """
  @spec parse(String.t(), String.t() | Datix.compiled(), list()) ::
          {:ok, Time.t()}
          | {:error,
             Datix.FormatStringError.t()
             | Datix.ValidationError.t()
             | Datix.ParseError.t()
             | Datix.OptionError.t()}
  def parse(time_str, format, opts \\ []) do
    with {:ok, data} <- Datix.strptime(time_str, format, sweep(opts)) do
      new(data, opts)
    end
  end

  @doc """
  Parses a date string according to the given `format`, erroring out for
  invalid arguments.

  ## Options

  Accepts the same options as listed for `parse/3`.
  """
  @spec parse!(String.t(), String.t() | Datix.compiled(), list()) :: Time.t()
  def parse!(time_str, format, opts \\ []) do
    case parse(time_str, format, opts) do
      {:ok, time} -> time
      {:error, error} when is_exception(error) -> raise error
    end
  end

  @doc false
  def new(%{hour: hour, hour_12: hour_12} = data, opts) do
    with {:ok, hour_24} <- to_hour_24(hour_12, Map.get(data, :am_pm)) do
      case hour == hour_24 do
        true -> data |> Map.delete(:hour_12) |> new(opts)
        false -> {:error, %ValidationError{reason: :invalid_time, module: __MODULE__}}
      end
    end
  end

  def new(%{hour: h, minute: m, second: s, microsecond: ms}, opts) do
    case Time.new(h, m, s, microsecond(ms), Datix.calendar(opts)) do
      {:ok, time} ->
        {:ok, time}

      {:error, :invalid_time} ->
        {:error, %ValidationError{reason: :invalid_time, module: __MODULE__}}
    end
  end

  def new(%{hour_12: h_12, minute: m, second: s, microsecond: ms} = data, opts) do
    with {:ok, h} <- to_hour_24(h_12, Map.get(data, :am_pm)) do
      case Time.new(h, m, s, microsecond(ms), Datix.calendar(opts)) do
        {:ok, time} ->
          {:ok, time}

        {:error, :invalid_time} ->
          {:error, %ValidationError{reason: :invalid_time, module: __MODULE__}}
      end
    end
  end

  def new(data, opts), do: data |> Datix.assume(Time) |> new(opts)

  defp to_hour_24(_hour_12, nil),
    do: {:error, %ValidationError{reason: :invalid_time, module: __MODULE__}}

  defp to_hour_24(12, :am), do: {:ok, 0}
  defp to_hour_24(12, :pm), do: {:ok, 12}
  defp to_hour_24(hour_12, :am), do: {:ok, hour_12}
  defp to_hour_24(hour_12, :pm), do: {:ok, hour_12 + 12}

  defp microsecond(ms) when is_tuple(ms), do: ms

  defp microsecond(ms) do
    digits = Integer.digits(ms)
    precision = length(digits)
    new_ms = Integer.undigits(digits ++ List.duplicate(0, max(0, 6 - precision)))
    {new_ms, precision}
  end

  defp sweep(opts), do: Keyword.delete(opts, :calendar)
end