lib/tox/naive_datetime.ex

defmodule Tox.NaiveDateTime do
  @moduledoc """
  A set of functions to work with `NaiveDateTime`.
  """

  alias Tox.IsoDays

  @doc """
  Shifts the `naive_datetime` by the given `duration`.


  The `durations` is a keyword list of one or more durations of the type
  `Tox.duration` e.g. `[year: 1, day: 5, minute: 500]`. All values will be
  shifted from the largest to the smallest unit.

  ## Examples

      iex> naive_datetime = ~N[2000-01-01 00:00:00]
      iex> Tox.NaiveDateTime.shift(naive_datetime, year: 2)
      ~N[2002-01-01 00:00:00]
      iex> Tox.NaiveDateTime.shift(naive_datetime, year: -2, month: 1, hour: 48)
      ~N[1998-02-03 00:00:00]
      iex> Tox.NaiveDateTime.shift(naive_datetime, hour: 10, minute: 10, second: 10)
      ~N[2000-01-01 10:10:10]

  Adding a month at the end of the month can update the day too.

      iex> Tox.NaiveDateTime.shift(~N[2000-01-31 00:00:00], month: 1)
      ~N[2000-02-29 00:00:00]

  For that reason it is important to know that all values will be shifted from the
  largest to the smallest unit.

      iex> naive_datetime = DateTime.from_naive!(~N[2000-01-30 00:00:00], "Europe/Oslo")
      iex> Tox.NaiveDateTime.shift(naive_datetime, month: 1, day: 1)
      ~N[2000-03-01 00:00:00+01:00]
      iex> naive_datetime |> Tox.NaiveDateTime.shift(month: 1) |> Tox.NaiveDateTime.shift(day: 1)
      ~N[2000-03-01 00:00:00+01:00]
      iex> naive_datetime |> Tox.NaiveDateTime.shift(day: 1) |> Tox.NaiveDateTime.shift(month: 1)
      ~N[2000-02-29 00:00:00+01:00]

  Using `shift/2` with a different calendar.

      iex> ~N[2012-09-03 02:30:00]
      ...> |> NaiveDateTime.convert!(Cldr.Calendar.Ethiopic)
      ...> |> Tox.NaiveDateTime.shift(day: 6)
      %NaiveDateTime{
        calendar: Cldr.Calendar.Ethiopic,
        year: 2004,
        month: 13,
        day: 4,
        hour: 2,
        minute: 30,
        second: 0,
        microsecond: {0, 0}
      }

  """
  @spec shift(Calendar.naive_datetime(), [Tox.duration()]) :: NaiveDateTime.t()
  def shift(%{calendar: calendar, microsecond: {_, precision}} = naive_datetime, durations) do
    naive_datetime
    |> Tox.Date.shift(durations)
    |> from_date_time(naive_datetime)
    |> IsoDays.from_naive_datetime()
    |> IsoDays.add(IsoDays.from_durations_time(durations, calendar, precision))
    |> from_iso_days(calendar, precision)
  end

  @doc """
  Returns true if `naive_datetime1` occurs after `naive_datetime2`.

  ## Examples

      iex> Tox.NaiveDateTime.after?(
      ...>   ~N[2020-06-14 15:01:43.999999],
      ...>   ~N[2020-06-14 15:01:43.000001]
      ...> )
      true

      iex> Tox.NaiveDateTime.after?(
      ...>   ~N[2020-06-14 15:01:43],
      ...>   ~N[2020-06-14 15:01:43]
      ...> )
      false

      iex> Tox.NaiveDateTime.after?(
      ...>   ~N[2020-06-14 15:01:43.000001],
      ...>   ~N[2020-06-14 15:01:43.999999]
      ...> )
      false

  """
  defmacro after?(naive_datetime1, naive_datetime2) do
    quote do
      NaiveDateTime.compare(unquote(naive_datetime1), unquote(naive_datetime2)) == :gt
    end
  end

  @doc """
  Returns true if `naive_datetime1` occurs after `naive_datetime2` or both naive
  datetimes are equal.

  ## Examples

      iex> Tox.NaiveDateTime.after_or_equal?(
      ...>   ~N[2020-06-14 15:01:43.999999],
      ...>   ~N[2020-06-14 15:01:43.000001]
      ...> )
      true

      iex> Tox.NaiveDateTime.after_or_equal?(
      ...>   ~N[2020-06-14 15:01:43],
      ...>   ~N[2020-06-14 15:01:43]
      ...> )
      true

      iex> Tox.NaiveDateTime.after_or_equal?(
      ...>   ~N[2020-06-14 15:01:43.000001],
      ...>   ~N[2020-06-14 15:01:43.999999]
      ...> )
      false

  """
  defmacro after_or_equal?(naive_datetime1, naive_datetime2) do
    quote do
      NaiveDateTime.compare(unquote(naive_datetime1), unquote(naive_datetime2)) in [:gt, :eq]
    end
  end

  @doc """
  Returns true if both naive datetimes are equal.

  ## Examples

      iex> Tox.NaiveDateTime.equal?(
      ...>   ~N[2020-06-14 15:01:43.999999],
      ...>   ~N[2020-06-14 15:01:43.000001]
      ...> )
      false

      iex> Tox.NaiveDateTime.equal?(
      ...>   ~N[2020-06-14 15:01:43],
      ...>   ~N[2020-06-14 15:01:43]
      ...> )
      true

      iex> Tox.NaiveDateTime.equal?(
      ...>   ~N[2020-06-14 15:01:43.000001],
      ...>   ~N[2020-06-14 15:01:43.999999]
      ...> )
      false

  """
  defmacro equal?(naive_datetime1, naive_datetime2) do
    quote do
      NaiveDateTime.compare(unquote(naive_datetime1), unquote(naive_datetime2)) == :eq
    end
  end

  @doc """
  Returns true if `naive_datetime1` occurs before `naive_datetime2`.

  ## Examples

      iex> Tox.NaiveDateTime.before?(
      ...>   ~N[2020-06-14 15:01:43.000001],
      ...>   ~N[2020-06-14 15:01:43.999999]
      ...> )
      true

      iex> Tox.NaiveDateTime.before?(
      ...>   ~N[2020-06-14 15:01:43],
      ...>   ~N[2020-06-14 15:01:43]
      ...> )
      false

      iex> Tox.NaiveDateTime.before?(
      ...>   ~N[2020-06-14 15:01:43.999999],
      ...>   ~N[2020-06-14 15:01:43.000001]
      ...> )
      false

  """
  defmacro before?(naive_datetime1, naive_datetime2) do
    quote do
      NaiveDateTime.compare(unquote(naive_datetime1), unquote(naive_datetime2)) == :lt
    end
  end

  @doc """
  Returns true if `naive_datetime1` occurs before `naive_datetime2` or both
  naive datetimes are equal.

  ## Examples

      iex> Tox.NaiveDateTime.before_or_equal?(
      ...>   ~N[2020-06-14 15:01:43.000001],
      ...>   ~N[2020-06-14 15:01:43.999999]
      ...> )
      true

      iex> Tox.NaiveDateTime.before_or_equal?(
      ...>   ~N[2020-06-14 15:01:43],
      ...>   ~N[2020-06-14 15:01:43]
      ...> )
      true

      iex> Tox.NaiveDateTime.before_or_equal?(
      ...>   ~N[2020-06-14 15:01:43.999999],
      ...>   ~N[2020-06-14 15:01:43.000001]
      ...> )
      false

  """
  defmacro before_or_equal?(naive_datetime1, naive_datetime2) do
    quote do
      NaiveDateTime.compare(unquote(naive_datetime1), unquote(naive_datetime2)) in [:lt, :eq]
    end
  end

  @doc """
  Returns a naive datetime representing the start of the year.

  ## Examples

      iex> Tox.NaiveDateTime.beginning_of_year(~N[2020-11-11 11:11:11])
      ~N[2020-01-01 00:00:00]

  """
  @spec beginning_of_year(Calendar.naive_datetime()) :: Calendar.naive_datetime()
  def beginning_of_year(naive_datetime),
    do: beginning_of_day(%{naive_datetime | month: 1, day: 1})

  @doc """
  Returns a naive datetime representing the start of the month.

  ## Examples

      iex> Tox.NaiveDateTime.beginning_of_month(~N[2020-11-11 11:11:11])
      ~N[2020-11-01 00:00:00]

  """
  @spec beginning_of_month(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def beginning_of_month(naive_datetime) do
    beginning_of_day(%{naive_datetime | day: 1})
  end

  @doc """
  Returns a naive datetime representing the start of the week.

  ## Examples

      iex> Tox.NaiveDateTime.beginning_of_week(~N[2020-07-22 11:11:11])
      ~N[2020-07-20 00:00:00]

  """
  @spec beginning_of_week(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def beginning_of_week(naive_datetime) do
    naive_datetime
    |> shift(day: Tox.Calendar.beginning_of_week(naive_datetime))
    |> beginning_of_day()
  end

  @doc """
  Returns a naive datetime} representing the start of the day.

  ## Examples

      iex> Tox.NaiveDateTime.beginning_of_day(~N[2020-03-29 13:00:00.123456])
      ~N[2020-03-29 00:00:00.000000]

  """
  @spec beginning_of_day(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def beginning_of_day(
        %{
          calendar: calendar,
          year: year,
          month: month,
          day: day,
          microsecond: {_, precision}
        } = naive_datetime
      ) do
    case NaiveDateTime.new(year, month, day, 0, 0, 0, {0, precision}, calendar) do
      {:ok, new_naive_datetime} ->
        new_naive_datetime

      {:error, reason} ->
        raise ArgumentError,
              "cannot set #{inspect(naive_datetime)} to beginning of day, " <>
                "reason: #{inspect(reason)}"
    end
  end

  @doc """
  Returns a boolean indicating whether `naive_datetime` occurs between `from`
  and `to`. The optional `boundaries` specifies whether `from` and `to` are
  included or not. The possible value for `boundaries` are:

  * `:open`: `from` and `to` are excluded
  * `:closed`: `from` and `to` are included
  * `:left_open`: `from` is excluded and `to` is included
  * `:right_open`: `from` is included and `to` is excluded

  ## Examples

      iex> from     = ~N[2020-04-05 12:30:00]
      iex> to       = ~N[2020-04-15 12:30:00]
      iex> Tox.NaiveDateTime.between?(~N[2020-04-01 12:00:00], from, to)
      false
      iex> Tox.NaiveDateTime.between?(~N[2020-04-11 12:30:00], from, to)
      true
      iex> Tox.NaiveDateTime.between?(~N[2020-04-21 12:30:00], from, to)
      false
      iex> Tox.NaiveDateTime.between?(from, from, to)
      true
      iex> Tox.NaiveDateTime.between?(to, from, to)
      false
      iex> Tox.NaiveDateTime.between?(from, from, to, :open)
      false
      iex> Tox.NaiveDateTime.between?(to, from, to, :open)
      false
      iex> Tox.NaiveDateTime.between?(from, from, to, :closed)
      true
      iex> Tox.NaiveDateTime.between?(to, from, to, :closed)
      true
      iex> Tox.NaiveDateTime.between?(from, from, to, :left_open)
      false
      iex> Tox.NaiveDateTime.between?(to, from, to, :left_open)
      true
      iex> Tox.NaiveDateTime.between?(~N[1900-01-01 00:00:00], to, from)
      ** (ArgumentError) from is equal or greater as to

  """
  @spec between?(
          Calendar.naive_datetime(),
          Calendar.naive_datetime(),
          Calendar.naive_datetime(),
          Tox.boundaries()
        ) ::
          boolean()
  def between?(naive_datetime, from, to, boundaries \\ :right_open)
      when boundaries in [:closed, :left_open, :right_open, :open] do
    if NaiveDateTime.compare(from, to) in [:gt, :eq],
      do: raise(ArgumentError, "from is equal or greater as to")

    case {
      NaiveDateTime.compare(naive_datetime, from),
      NaiveDateTime.compare(naive_datetime, to),
      boundaries
    } do
      {:lt, _, _} -> false
      {_, :gt, _} -> false
      {:eq, _, :closed} -> true
      {:eq, _, :right_open} -> true
      {_, :eq, :closed} -> true
      {_, :eq, :left_open} -> true
      {:gt, :lt, _} -> true
      {_, _, _} -> false
    end
  end

  @doc """
  Returns a naive datetime representing the end of the year.

  ## Examples

      iex> Tox.NaiveDateTime.end_of_year(~N[2020-03-29 01:00:00])
      ~N[2020-12-31 23:59:59.999999]

  With the Ethiopic calendar.

      iex> naive_datetime = NaiveDateTime.convert!(~N[2020-10-26 02:30:00], Cldr.Calendar.Ethiopic)
      iex> to_string(naive_datetime)
      "2013-02-16 02:30:00"
      iex> naive_datetime |> Tox.NaiveDateTime.end_of_year() |> to_string()
      "2013-13-05 23:59:59.999999"

  """
  @spec end_of_year(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def end_of_year(%{calendar: calendar, year: year} = naive_datetime) do
    month = calendar.months_in_year(year)
    day = calendar.days_in_month(year, month)
    end_of_day(%{naive_datetime | month: month, day: day})
  end

  @doc """
  Returns a datetime} representing the end of the month.

  ## Examples

      iex> Tox.NaiveDateTime.end_of_month(~N[2020-11-11 11:11:11])
      ~N[2020-11-30 23:59:59.999999]

  """
  @spec end_of_month(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def end_of_month(%{calendar: calendar, year: year, month: month} = naive_datetime) do
    day = calendar.days_in_month(year, month)
    end_of_day(%{naive_datetime | day: day})
  end

  @doc """
  Returns a datetime representing the end of the week.

  ## Examples

      iex> Tox.NaiveDateTime.end_of_week(~N[2020-07-22 11:11:11])
      ~N[2020-07-26 23:59:59.999999]

  """
  @spec end_of_week(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def end_of_week(%{calendar: calendar, year: year, month: month, day: day} = naive_datetime) do
    day = Tox.days_per_week() - Tox.day_of_week(calendar, year, month, day)

    naive_datetime
    |> shift(day: day)
    |> end_of_day()
  end

  @doc """
  Returns datetime representing the end of the day.

  ## Examples

      iex> Tox.NaiveDateTime.end_of_day(~N[2020-03-29 01:00:00])
      ~N[2020-03-29 23:59:59.999999]

  """
  @spec end_of_day(Calendar.naive_datetime()) :: NaiveDateTime.t()
  def end_of_day(%{calendar: calendar, year: year, month: month, day: day} = naive_datetime) do
    {hour, minute, second, microsecond} = Tox.Time.max_tuple(calendar)

    case NaiveDateTime.new(year, month, day, hour, minute, second, microsecond, calendar) do
      {:ok, new_naive_datetime} ->
        new_naive_datetime

      {:error, reason} ->
        raise ArgumentError,
              "cannot set #{inspect(naive_datetime)} to end of day, " <>
                "reason: #{inspect(reason)}"
    end
  end

  @doc """
  Returns an `{year, week}` representing the ISO week number for the specified
  date.

  This function is just defined for datetimes with `Calendar.ISO`.

  ## Example

      iex> Tox.NaiveDateTime.week(~N[2017-01-01 01:00:00])
      {2016, 52}
      iex> Tox.NaiveDateTime.week(~N[2019-12-31 01:00:00])
      {2020, 1}
      iex> Tox.NaiveDateTime.week(~N[2020-01-01 01:00:00])
      {2020, 1}

      iex> ~N[2020-06-04 11:12:13]
      ...> |> NaiveDateTime.convert(Cldr.Calendar.Coptic)
      ...> |> Tox.NaiveDateTime.week()
      ** (FunctionClauseError) no function clause matching in Tox.NaiveDateTime.week/1

  """
  @spec week(Calendar.datetime()) :: {Calendar.year(), non_neg_integer}
  def week(%{calendar: Calendar.ISO} = naive_datetime), do: Tox.week(naive_datetime)

  ## Helpers

  @doc false
  @spec from_date_time(Calendar.date(), Calendar.time()) :: NaiveDateTime.t()
  def from_date_time(
        %{calendar: calendar, year: year, month: month, day: day},
        %{
          calendar: calendar,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond
        }
      ) do
    {:ok, naive_datetime} =
      NaiveDateTime.new(year, month, day, hour, minute, second, microsecond, calendar)

    naive_datetime
  end

  @doc false
  @spec from_iso_days(Calendar.iso_days(), Calendar.calendar(), non_neg_integer) ::
          NaiveDateTime.t()
  def from_iso_days(iso_days, calendar, precision) do
    {year, month, day, hour, minute, second, {microsecond, _}} =
      calendar.naive_datetime_from_iso_days(iso_days)

    %NaiveDateTime{
      calendar: calendar,
      year: year,
      month: month,
      day: day,
      hour: hour,
      minute: minute,
      second: second,
      microsecond: {microsecond, precision}
    }
  end
end