lib/tox/datetime.ex

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

  All examples are using
  [`TimeZoneInfo.TimeZoneDatabase`](https://hexdocs.pm/time_zone_info/TimeZoneInfo.TimeZoneDatabase.html). But everything also works with any other time zone DB as long as the time
  zones are available in the DB.
  """

  alias Tox.IsoDays

  @utc "Etc/UTC"

  @doc """
  Shifts the `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> datetime = DateTime.from_naive!(~N[1980-11-01 00:00:00], "Europe/Oslo")
      iex> Tox.DateTime.shift(datetime, year: 2)
      #DateTime<1982-11-01 00:00:00+01:00 CET Europe/Oslo>
      iex> Tox.DateTime.shift(datetime, year: -2, month: 1, hour: 48)
      #DateTime<1978-12-03 00:00:00+01:00 CET Europe/Oslo>
      iex> Tox.DateTime.shift(datetime, hour: 10, minute: 10, second: 10)
      #DateTime<1980-11-01 10:10:10+01:00 CET Europe/Oslo>

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

      iex> datetime = DateTime.from_naive!(~N[2000-01-31 00:00:00], "Europe/Oslo")
      iex> Tox.DateTime.shift(datetime, month: 1)
      #DateTime<2000-02-29 00:00:00+01:00 CET Europe/Oslo>

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

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

  Treatment of time gaps. Usually, a transition to a daily-saving-time causing a time gap. For
  example, in the time-zone Europe/Berlin, the clocks are advanced by one hour on the last Sunday
  in March at 02:00. Therefore there is a gap between 02:00 and 03:00. The `shift/3` function will
  adjust this by adding or subtracting the difference from the calculated date.

      # adding a day
      iex> datetime = DateTime.from_naive!(~N[2020-03-28 02:30:00], "Europe/Berlin")
      iex> result = Tox.DateTime.shift(datetime, day: 1)
      #DateTime<2020-03-29 03:30:00+02:00 CEST Europe/Berlin>
      iex> DateTime.diff(result, datetime) == 24 * 60 * 60
      true
      iex> Tox.DateTime.shift(result, day: -1)
      #DateTime<2020-03-28 03:30:00+01:00 CET Europe/Berlin>

      # subtracting a day
      iex> datetime = DateTime.from_naive!(~N[2020-03-30 02:30:00], "Europe/Berlin")
      iex> result = Tox.DateTime.shift(datetime, day: -1)
      #DateTime<2020-03-29 01:30:00+01:00 CET Europe/Berlin>
      iex> DateTime.diff(datetime, result) == 24 * 60 * 60
      true
      iex> Tox.DateTime.shift(result, day: 1)
      #DateTime<2020-03-30 01:30:00+02:00 CEST Europe/Berlin>

  Treatment of ambiguous times. Usually, a transition from daily-saving-time causing an ambiguous
  period.  For example, in the time-zone Europe/Berlin, the clocks are set back one hour on the
  last Sunday in October. Therefore the period from 02:00 to 03:00 exists twice on this day. The
  `shift/3` function will adjust this by checking if the original datetime later or earlier.

      # adding a day
      iex> datetime = DateTime.from_naive!(~N[2020-10-24 02:30:00], "Europe/Berlin")
      iex> result = Tox.DateTime.shift(datetime, day: 1)
      #DateTime<2020-10-25 02:30:00+02:00 CEST Europe/Berlin>
      iex> DateTime.diff(result, datetime) == 24 * 60 * 60
      true

      # subtracting a day
      iex> datetime = DateTime.from_naive!(~N[2020-10-26 02:30:00], "Europe/Berlin")
      iex> result = Tox.DateTime.shift(datetime, day: -1)
      #DateTime<2020-10-25 02:30:00+01:00 CET Europe/Berlin>
      iex> DateTime.diff(datetime, result) == 24 * 60 * 60
      true

  Using `shift/3` with a different calendar.

      iex> datetime =
      ...>   ~N[2020-10-26 02:30:00]
      ...>   |> DateTime.from_naive!("Africa/Nairobi")
      ...>   |> DateTime.convert!(Cldr.Calendar.Ethiopic)
      ...>
      ...> to_string(datetime)
      "2013-02-16 02:30:00+03:00 EAT Africa/Nairobi"
      iex> datetime |> Tox.DateTime.shift(month: 13) |> to_string()
      "2014-02-16 02:30:00+03:00 EAT Africa/Nairobi"

  """
  @spec shift(Calendar.datetime(), [Tox.duration()], Calendar.time_zone_database()) ::
          DateTime.t()
  def shift(datetime, durations, time_zone_database \\ Calendar.get_time_zone_database())

  def shift(datetime, [], _time_zone_database), do: datetime

  def shift(%{time_zone: time_zone} = datetime, durations, time_zone_database) do
    with {:ok, datetime} <- shift_date(datetime, durations, time_zone_database),
         {:ok, datetime} <- DateTime.shift_zone(datetime, @utc, time_zone_database),
         {:ok, datetime} <- shift_time(datetime, durations, time_zone_database),
         {:ok, datetime} <- DateTime.shift_zone(datetime, time_zone, time_zone_database) do
      datetime
    else
      {:error, reason} ->
        raise ArgumentError,
              "cannot shift #{inspect(durations)} to #{inspect(datetime)}, " <>
                "reason: #{inspect(reason)}"
    end
  end

  @doc """
  Returns true if `datetime1` occurs after `datetime2`.

  ## Examples

      iex> Tox.DateTime.after?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z], "Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z], "Etc/UTC")
      ...> )
      true

      iex> Tox.DateTime.after?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z], "Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z], "Etc/UTC")
      ...> )
      false

      iex> Tox.DateTime.after?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC")
      ...> )
      false

      # with time zone Europe/London (UTC+1) and Europe/Berlin (UTC+2)
      iex> Tox.DateTime.after?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000999], "Europe/Berlin"),
      ...>   DateTime.from_naive!(~N[2020-06-14 14:01:43.000001], "Europe/London")
      ...> )
      true

  """
  defmacro after?(datetime1, datetime2) do
    quote do
      DateTime.compare(unquote(datetime1), unquote(datetime2)) == :gt
    end
  end

  @doc """
  Returns true if `datetime1` occurs after `datetime2` or both datetimes are
  equal.

  ## Examples

      iex> Tox.DateTime.after_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC")
      ...> )
      true

      iex> Tox.DateTime.after_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC" )
      ...> )
      true

      iex> Tox.DateTime.after_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC")
      ...> )
      false

      # with time zone Europe/London (UTC+1) and Europe/Berlin (UTC+2)
      iex> Tox.DateTime.after_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000999], "Europe/Berlin"),
      ...>   DateTime.from_naive!(~N[2020-06-14 14:01:43.000001], "Europe/London")
      ...> )
      true

  """
  defmacro after_or_equal?(datetime1, datetime2) do
    quote do
      DateTime.compare(unquote(datetime1), unquote(datetime2)) in [:gt, :eq]
    end
  end

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

  ## Examples

      iex> Tox.DateTime.equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC")
      ...> )
      false

      iex> Tox.DateTime.equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC")
      ...> )
      true

      iex> Tox.DateTime.equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC")
      ...> )
      false

      # with time zone Europe/London (UTC+1) and Europe/Berlin (UTC+2)
      iex> Tox.DateTime.equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000999], "Europe/Berlin"),
      ...>   DateTime.from_naive!(~N[2020-06-14 14:01:43.000999], "Europe/London")
      ...> )
      true

  """
  defmacro equal?(datetime1, datetime2) do
    quote do
      DateTime.compare(unquote(datetime1), unquote(datetime2)) == :eq
    end
  end

  @doc """
  Returns true if `datetime1` occurs before `datetime2`.

  ## Examples

      iex> Tox.DateTime.before?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC")
      ...> )
      true

      iex> Tox.DateTime.before?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC")
      ...> )
      false

      iex> Tox.DateTime.before?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC")
      ...> )
      false

      # with time zone Europe/London (UTC+1) and Europe/Berlin (UTC+2)
      iex> Tox.DateTime.before?(
      ...>   DateTime.from_naive!(~N[2020-06-14 14:01:43.000001], "Europe/London"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000999], "Europe/Berlin")
      ...> )
      true
  """
  defmacro before?(datetime1, datetime2) do
    quote do
      DateTime.compare(unquote(datetime1), unquote(datetime2)) == :lt
    end
  end

  @doc """
  Returns true if `datetime1` occurs before `datetime2` or both datetimes are equal.

  ## Examples

      iex> Tox.DateTime.before_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC")
      ...> )
      true

      iex> Tox.DateTime.before_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43Z],"Etc/UTC")
      ...> )
      true

      iex> Tox.DateTime.before_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999Z],"Etc/UTC"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.000001Z],"Etc/UTC")
      ...> )
      false

      # with time zone Europe/London (UTC+1) and Europe/Berlin (UTC+2)
      iex> Tox.DateTime.before_or_equal?(
      ...>   DateTime.from_naive!(~N[2020-06-14 14:01:43.000000], "Europe/London"),
      ...>   DateTime.from_naive!(~N[2020-06-14 15:01:43.999999], "Europe/Berlin")
      ...> )
      true
  """
  defmacro before_or_equal?(datetime1, datetime2) do
    quote do
      DateTime.compare(unquote(datetime1), unquote(datetime2)) in [:lt, :eq]
    end
  end

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

  ## Examples

      iex> ~N[2020-11-11 11:11:11]
      iex> |> DateTime.from_naive!("Europe/Berlin")
      iex> |> Tox.DateTime.beginning_of_year()
      #DateTime<2020-01-01 00:00:00+01:00 CET Europe/Berlin>

      iex> ~N[1969-11-11 12:00:00]
      iex> |> DateTime.from_naive!("Antarctica/Casey")
      iex> |> Tox.DateTime.beginning_of_year()
      #DateTime<1969-01-01 08:00:00+08:00 +08 Antarctica/Casey>

  """
  @spec beginning_of_year(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def beginning_of_year(datetime, time_zone_database \\ Calendar.get_time_zone_database()) do
    beginning_of_day(%{datetime | month: 1, day: 1}, time_zone_database)
  end

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

  ## Examples

      iex> ~N[2020-11-11 11:11:11]
      iex> |> DateTime.from_naive!("Europe/Berlin")
      iex> |> Tox.DateTime.beginning_of_month()
      #DateTime<2020-11-01 00:00:00+01:00 CET Europe/Berlin>

      iex> ~N[1969-01-11 12:00:00]
      iex> |> DateTime.from_naive!("Antarctica/Casey")
      iex> |> Tox.DateTime.beginning_of_month()
      #DateTime<1969-01-01 08:00:00+08:00 +08 Antarctica/Casey>

  """
  @spec beginning_of_month(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def beginning_of_month(datetime, time_zone_database \\ Calendar.get_time_zone_database()) do
    beginning_of_day(%{datetime | day: 1}, time_zone_database)
  end

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

  ## Examples

      iex> ~N[2020-07-22 11:11:11]
      iex> |> DateTime.from_naive!("Europe/Berlin")
      iex> |> Tox.DateTime.beginning_of_week()
      #DateTime<2020-07-20 00:00:00+02:00 CEST Europe/Berlin>

  """
  @spec beginning_of_week(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def beginning_of_week(
        %{calendar: calendar, time_zone: time_zone} = datetime,
        time_zone_database \\ Calendar.get_time_zone_database()
      ) do
    datetime
    |> Tox.Date.beginning_of_week()
    |> to_datetime(0, time_zone, calendar, time_zone_database)
  end

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

  ## Examples

      iex> ~N[2020-03-29 12:00:00]
      ...> |> DateTime.from_naive!("Europe/Berlin")
      ...> |> Tox.DateTime.beginning_of_day()
      #DateTime<2020-03-29 00:00:00+01:00 CET Europe/Berlin>

  On a day starting with a gap

      iex> ~N[2011-04-03 12:00:00]
      iex> |> DateTime.from_naive!("Africa/El_Aaiun")
      iex> |> Tox.DateTime.beginning_of_day()
      #DateTime<2011-04-03 01:00:00+01:00 +01 Africa/El_Aaiun>

  On a day starting with an ambiguous period

      iex> datetime = DateTime.from_naive!(~N[2020-10-25 12:00:00], "America/Scoresbysund")
      #DateTime<2020-10-25 12:00:00-01:00 -01 America/Scoresbysund>
      iex> Tox.DateTime.beginning_of_day(datetime)
      #DateTime<2020-10-25 00:00:00+00:00 +00 America/Scoresbysund>

  """
  @spec beginning_of_day(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def beginning_of_day(
        %{
          time_zone: time_zone,
          calendar: calendar,
          year: year,
          month: month,
          day: day,
          microsecond: {_, precision}
        },
        time_zone_database \\ Calendar.get_time_zone_database()
      ) do
    to_datetime(year, month, day, precision, time_zone, calendar, time_zone_database)
  end

  @doc """
  Returns a boolean indicating whether `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     = DateTime.from_naive!(~N[2020-04-05 12:30:00], "Asia/Omsk")
      iex> to       = DateTime.from_naive!(~N[2020-04-15 12:30:00], "Asia/Omsk")
      iex> datetime = DateTime.from_naive!(~N[2020-04-01 12:30:00], "Asia/Omsk")
      iex> Tox.DateTime.between?(datetime, from, to)
      false
      iex> datetime = DateTime.from_naive!(~N[2020-04-11 12:30:00], "Asia/Omsk")
      iex> Tox.DateTime.between?(datetime, from, to)
      true
      iex> datetime = DateTime.from_naive!(~N[2020-04-21 12:30:00], "Asia/Omsk")
      iex> Tox.DateTime.between?(datetime, from, to)
      false
      iex> Tox.DateTime.between?(from, from, to)
      true
      iex> Tox.DateTime.between?(to, from, to)
      false
      iex> Tox.DateTime.between?(from, from, to, :open)
      false
      iex> Tox.DateTime.between?(to, from, to, :open)
      false
      iex> Tox.DateTime.between?(from, from, to, :closed)
      true
      iex> Tox.DateTime.between?(to, from, to, :closed)
      true
      iex> Tox.DateTime.between?(from, from, to, :left_open)
      false
      iex> Tox.DateTime.between?(to, from, to, :left_open)
      true
      iex> Tox.DateTime.between?(datetime, to, from)
      ** (ArgumentError) from is equal or greater as to

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

    case {DateTime.compare(datetime, from), DateTime.compare(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 datetime representing the end of the year.

  ## Examples

      iex> ~N[2020-03-29 01:00:00]
      iex> |> DateTime.from_naive!("Europe/Berlin")
      iex> |> Tox.DateTime.end_of_year()
      #DateTime<2020-12-31 23:59:59.999999+01:00 CET Europe/Berlin>

  With the Ethiopic calendar.

      iex> datetime =
      ...>   ~N[2020-10-26 02:30:00]
      ...>   |> DateTime.from_naive!("Africa/Nairobi")
      ...>   |> DateTime.convert!(Cldr.Calendar.Ethiopic)
      ...>
      ...> to_string(datetime)
      "2013-02-16 02:30:00+03:00 EAT Africa/Nairobi"
      iex> datetime |> Tox.DateTime.end_of_year() |> to_string()
      "2013-13-05 23:59:59.999999+03:00 EAT Africa/Nairobi"

  """
  @spec end_of_year(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def end_of_year(
        %{calendar: calendar, year: year} = datetime,
        time_zone_database \\ Calendar.get_time_zone_database()
      ) do
    month = calendar.months_in_year(year)
    day = calendar.days_in_month(year, month)
    end_of_day(%{datetime | month: month, day: day}, time_zone_database)
  end

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

  ## Examples

      iex> ~N[2020-11-11 11:11:11]
      ...> |> DateTime.from_naive!("Europe/Amsterdam")
      ...> |> Tox.DateTime.end_of_month()
      #DateTime<2020-11-30 23:59:59.999999+01:00 CET Europe/Amsterdam>

  """
  @spec end_of_month(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def end_of_month(
        %{calendar: calendar, year: year, month: month} = datetime,
        time_zone_database \\ Calendar.get_time_zone_database()
      ) do
    day = calendar.days_in_month(year, month)
    end_of_day(%{datetime | day: day}, time_zone_database)
  end

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

  ## Examples

      iex> ~N[2020-07-22 11:11:11]
      ...> |> DateTime.from_naive!("Europe/Berlin")
      ...> |> Tox.DateTime.end_of_week()
      #DateTime<2020-07-26 23:59:59.999999+02:00 CEST Europe/Berlin>

  """
  @spec end_of_week(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def end_of_week(
        %{calendar: calendar, year: year, month: month, day: day} = datetime,
        time_zone_database \\ Calendar.get_time_zone_database()
      ) do
    day = Tox.days_per_week() - Tox.day_of_week(calendar, year, month, day)

    datetime
    |> shift(day: day)
    |> end_of_day(time_zone_database)
  end

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

  ## Examples

      iex> ~N[2020-03-29 01:00:00]
      ...> |> DateTime.from_naive!("Europe/Berlin")
      ...> |> Tox.DateTime.end_of_day()
      #DateTime<2020-03-29 23:59:59.999999+02:00 CEST Europe/Berlin>

  On a day ending with a gap.

      iex> ~N[1916-06-14 12:00:00]
      ...> |> DateTime.from_naive!("Africa/Algiers")
      ...> |> Tox.DateTime.end_of_day()
      #DateTime<1916-06-14 22:59:59.999999+00:00 WET Africa/Algiers>
  """
  @spec end_of_day(Calendar.datetime(), Calendar.time_zone_database()) :: DateTime.t()
  def end_of_day(
        %{
          time_zone: time_zone,
          calendar: calendar,
          year: year,
          month: month,
          day: day
        } = datetime,
        time_zone_database \\ Calendar.get_time_zone_database()
      ) do
    {hour, minute, second, microsecond} = Tox.Time.max_tuple(calendar)

    with {:ok, naive_datetime} <-
           NaiveDateTime.new(year, month, day, hour, minute, second, microsecond, calendar),
         {:ok, new_datetime} <- DateTime.from_naive(naive_datetime, time_zone, time_zone_database) do
      new_datetime
    else
      {:gap, new_datetime, _} ->
        new_datetime

      {:ambiguous, _, new_datetime} ->
        new_datetime

      {:error, reason} ->
        raise ArgumentError,
              "cannot set #{inspect(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> ~N[2017-01-01 01:00:00]
      ...> |> DateTime.from_naive!("Europe/Berlin")
      ...> |> Tox.DateTime.week()
      {2016, 52}

      iex> ~N[2020-01-01 01:00:00]
      ...> |> DateTime.from_naive!("Europe/Berlin")
      ...> |> Tox.DateTime.week()
      {2020, 1}

      iex> ~N[2019-12-31 01:00:00]
      ...> |> DateTime.from_naive!("Europe/Berlin")
      ...> |> Tox.DateTime.week()
      {2020, 1}

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

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

  ## Helpers

  defp shift_date(%{time_zone: time_zone} = datetime, durations, time_zone_database) do
    datetime
    |> Tox.Date.shift(durations)
    |> Tox.NaiveDateTime.from_date_time(datetime)
    |> adjust_datetime(datetime, time_zone, time_zone_database)
  end

  defp shift_time(
         %{calendar: calendar, microsecond: {_, precision}} = datetime,
         durations,
         time_zone_database
       ) do
    datetime
    |> IsoDays.from_datetime()
    |> IsoDays.add(IsoDays.from_durations_time(durations, calendar, precision))
    |> from_iso_days(calendar, precision, @utc, time_zone_database)
  end

  defp adjust_datetime(naive_datetime, from_datetime, time_zone, time_zone_database) do
    case time_zone_database.time_zone_periods_from_wall_datetime(naive_datetime, time_zone) do
      {:ok, _} ->
        DateTime.from_naive(naive_datetime, time_zone, time_zone_database)

      {_, _, _} = gap_or_ambiguous ->
        adjust_datetime(
          gap_or_ambiguous,
          naive_datetime,
          from_datetime,
          time_zone,
          time_zone_database
        )

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

  defp adjust_datetime(
         {:gap, {%{std_offset: std_offset1, utc_offset: utc_offset1}, _},
          {%{std_offset: std_offset2, utc_offset: utc_offset2}, _}},
         naive_datetime,
         from_datetime,
         time_zone,
         time_zone_database
       ) do
    diff =
      case NaiveDateTime.compare(from_datetime, naive_datetime) do
        :gt ->
          utc_offset1 + std_offset1 - (utc_offset2 + std_offset2)

        :lt ->
          utc_offset2 + std_offset2 - (utc_offset1 + std_offset1)
      end

    naive_datetime
    |> NaiveDateTime.add(diff)
    |> DateTime.from_naive(time_zone, time_zone_database)
  end

  defp adjust_datetime(
         {:ambiguous, _, _},
         naive_datetime,
         from_datetime,
         time_zone,
         time_zone_database
       ) do
    case {NaiveDateTime.compare(from_datetime, naive_datetime),
          DateTime.from_naive(naive_datetime, time_zone, time_zone_database)} do
      {:eq, _} -> {:ok, from_datetime}
      {:lt, {:ambiguous, datetime, _}} -> {:ok, datetime}
      {:gt, {:ambiguous, _, datetime}} -> {:ok, datetime}
    end
  end

  {:ambiguous,
   %{
     std_offset: 3600,
     utc_offset: 3600,
     wall_period: {~N[2019-03-31 03:00:00], ~N[2019-10-27 03:00:00]},
     zone_abbr: "CEST"
   },
   %{
     std_offset: 0,
     utc_offset: 3600,
     wall_period: {~N[2019-10-27 02:00:00], ~N[2020-03-29 02:00:00]},
     zone_abbr: "CET"
   }}

  defp from_iso_days(iso_days, calendar, precision, time_zone, time_zone_database) do
    iso_days
    |> Tox.NaiveDateTime.from_iso_days(calendar, precision)
    |> DateTime.from_naive(time_zone, time_zone_database)
  end

  defp to_datetime(
         %{year: year, month: month, day: day},
         precision,
         time_zone,
         calendar,
         time_zone_database
       ) do
    to_datetime(year, month, day, precision, time_zone, calendar, time_zone_database)
  end

  defp to_datetime(year, month, day, precision, time_zone, calendar, time_zone_database) do
    with {:ok, naive_datetime} <-
           NaiveDateTime.new(year, month, day, 0, 0, 0, {0, precision}, calendar),
         {:ok, datetime} <-
           DateTime.from_naive(naive_datetime, time_zone, time_zone_database) do
      datetime
    else
      {:gap, _, datetime} ->
        datetime

      {:ambiguous, datetime, _} ->
        %{
          datetime
          | year: year,
            month: month,
            day: day,
            hour: 0,
            minute: 0,
            second: 0,
            microsecond: {0, 0}
        }

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