lib/cldr/interval/date_time.ex

defmodule Cldr.DateTime.Interval do
  @moduledoc """
  Interval formats allow for software to format intervals like "Jan 10-12, 2008" as a
  shorter and more natural format than "Jan 10, 2008 - Jan 12, 2008". They are designed
  to take a start and end date, time or datetime plus a formatting pattern
  and use that information to produce a localized format.

  See `Cldr.Interval.to_string/3` and `Cldr.DateTime.Interval.to_string/3`

  """

  import Cldr.Date.Interval,
    only: [
      greatest_difference: 2
    ]

  import Cldr.Calendar,
    only: [
      naivedatetime: 0
    ]

  @default_format :medium
  @formats [:short, :medium, :long]

  if Cldr.Code.ensure_compiled?(CalendarInterval) do
    @doc false
    def to_string(%CalendarInterval{} = interval) do
      {locale, backend} = Cldr.locale_and_backend_from(nil, nil)
      to_string(interval, backend, locale: locale)
    end
  end

  if Cldr.Code.ensure_compiled?(CalendarInterval) do
    @doc false
    def to_string(%CalendarInterval{} = interval, backend) when is_atom(backend) do
      {locale, backend} = Cldr.locale_and_backend_from(nil, backend)
      to_string(interval, backend, locale: locale)
    end

    @doc false
    def to_string(%CalendarInterval{} = interval, options) when is_list(options) do
      {locale, backend} = Cldr.locale_and_backend_from(options)
      to_string(interval, backend, locale: locale)
    end

    @doc """
    Returns a localised string representing the formatted
    `CalendarInterval`.

    ## Arguments

    * `range` is a `CalendarInterval.t`

    * `backend` is any module that includes `use Cldr` and
      is therefore a `Cldr` backend module

    * `options` is a keyword list of options. The default is `[]`.

    ## Options

    * `:format` is one of `:short`, `:medium` or `:long` or a
      specific format type or a string representing of an interval
      format. The default is `:medium`.

    * `locale` is any valid locale name returned by `Cldr.known_locale_names/0`
      or a `Cldr.LanguageTag` struct.  The default is `Cldr.get_locale/0`

    * `number_system:` a number system into which the formatted date digits should
      be transliterated

    ## Returns

    * `{:ok, string}` or

    * `{:error, {exception, reason}}`

    ## Notes

    * `CalendarInterval` support requires adding the
      dependency [calendar_interval](https://hex.pm/packages/calendar_interval)
      to the `deps` configuration in `mix.exs`.

    * For more information on interval format string
      see the `Cldr.Interval`.

    * The available predefined formats that can be applied are the
      keys of the map returned by `Cldr.DateTime.Format.interval_formats("en", :gregorian)`
      where `"en"` can be replaced by any configuration locale name and `:gregorian`
      is the underlying `CLDR` calendar type.

    * In the case where `from` and `to` are equal, a single
      datetime is formatted instead of an interval

    ## Examples

        iex> Cldr.DateTime.Interval.to_string ~I"2020-01-01 10:00/12:00", MyApp.Cldr
        {:ok, "Jan 1, 2020, 10:00:00 AM – 12:00:00 PM"}

    """
    @spec to_string(CalendarInterval.t(), Cldr.backend(), Keyword.t()) ::
            {:ok, String.t()} | {:error, {module, String.t()}}

    def to_string(%CalendarInterval{first: from, last: to, precision: precision}, backend, options)
        when precision in [:year, :month, :day] do
      Cldr.Date.Interval.to_string(from, to, backend, options)
    end

    def to_string(%CalendarInterval{first: from, last: to, precision: precision}, backend, options)
        when precision in [:hour, :minute] do
      from = %{from | second: 0, microsecond: {0, 6}}
      to = %{to | second: 0, microsecond: {0, 6}}
      to_string(from, to, backend, options)
    end

    def to_string(%CalendarInterval{first: from, last: to, precision: precision}, backend, options)
        when precision in [:hour, :minute] do
      from = %{from | microsecond: {0, 6}}
      to = %{to | microsecond: {0, 6}}
      to_string(from, to, backend, options)
    end
  end

  @doc false
  def to_string(unquote(naivedatetime()) = from, unquote(naivedatetime()) = to) do
    {locale, backend} = Cldr.locale_and_backend_from(nil, nil)
    to_string(from, to, backend, locale: locale)
  end

  def to_string(nil = from, unquote(naivedatetime()) = to) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(nil, nil)
    to_string(from, to, backend, locale: locale)
  end

  def to_string(unquote(naivedatetime()) = from, nil = to) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(nil, nil)
    to_string(from, to, backend, locale: locale)
  end

  @doc false
  def to_string(unquote(naivedatetime()) = from, unquote(naivedatetime()) = to, backend)
      when is_atom(backend) do
    {locale, backend} = Cldr.locale_and_backend_from(nil, backend)
    to_string(from, to, backend, locale: locale)
  end

  def to_string(nil = from, unquote(naivedatetime()) = to, backend)
      when is_atom(backend) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(nil, backend)
    to_string(from, to, backend, locale: locale)
  end

  def to_string(unquote(naivedatetime()) = from, nil = to, backend)
      when is_atom(backend) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(nil, backend)
    to_string(from, to, backend, locale: locale)
  end

  @doc false
  def to_string(unquote(naivedatetime()) = from, unquote(naivedatetime()) = to, options)
      when is_list(options) do
    {locale, backend} = Cldr.locale_and_backend_from(options)
    to_string(from, to, backend, locale: locale)
  end

  def to_string(nil = from, unquote(naivedatetime()) = to, options)
      when is_list(options) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(options)
    to_string(from, to, backend, locale: locale)
  end

  def to_string(unquote(naivedatetime()) = from, nil = to, options)
      when is_list(options) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(options)
    to_string(from, to, backend, locale: locale)
  end

  @doc """
  Returns a localised string representing the formatted
  interval formed by two dates.

  ## Arguments

  * `from` is any map that conforms to the
    `Calendar.datetime` type.

  * `to` is any map that conforms to the
    `Calendar.datetime` type. `to` must occur
    on or after `from`.

  * `backend` is any module that includes `use Cldr` and
    is therefore a `Cldr` backend module

  * `options` is a keyword list of options. The default is `[]`.

  Either of `from` or `to` may also be `nil` in which case the
  result is an "open" interval and the non-nil parameter is formatted
  using `Cldr.DateTime.to_string/3`.

  ## Options

  * `:format` is one of `:short`, `:medium` or `:long` or a
    specific format type or a string representing of an interval
    format. The default is `:medium`.

  * `locale` is any valid locale name returned by `Cldr.known_locale_names/0`
    or a `Cldr.LanguageTag` struct.  The default is `Cldr.get_locale/0`

  * `number_system:` a number system into which the formatted date digits should
    be transliterated

  ## Returns

  * `{:ok, string}` or

  * `{:error, {exception, reason}}`

  ## Notes

  * For more information on interval format string
    see the `Cldr.Interval`.

  * The available predefined formats that can be applied are the
    keys of the map returned by `Cldr.DateTime.Format.interval_formats("en", :gregorian)`
    where `"en"` can be replaced by any configuration locale name and `:gregorian`
    is the underlying `CLDR` calendar type.

  * In the case where `from` and `to` are equal, a single
    date is formatted instead of an interval

  ## Examples

      iex> Cldr.DateTime.Interval.to_string ~U[2020-01-01 00:00:00.0Z],
      ...> ~U[2020-12-31 10:00:00.0Z], MyApp.Cldr
      {:ok, "Jan 1, 2020, 12:00:00 AM – Dec 31, 2020, 10:00:00 AM"}

      iex> Cldr.DateTime.Interval.to_string ~U[2020-01-01 00:00:00.0Z], nil, MyApp.Cldr
      {:ok, "Jan 1, 2020, 12:00:00 AM –"}

  """
  @spec to_string(Calendar.datetime() | nil, Calendar.datetime() | nil, Cldr.backend(), Keyword.t()) ::
          {:ok, String.t()} | {:error, {module, String.t()}}

  def to_string(from, to, backend, options \\ [])

  def to_string(unquote(naivedatetime()) = from, unquote(naivedatetime()) = to, backend, options)
      when calendar == Calendar.ISO do
    from = %{from | calendar: Cldr.Calendar.Gregorian}
    to = %{to | calendar: Cldr.Calendar.Gregorian}

    to_string(from, to, backend, options)
  end

  def to_string(nil = from, unquote(naivedatetime()) = to, backend, options)
      when calendar == Calendar.ISO do
    to = %{to | calendar: Cldr.Calendar.Gregorian}

    to_string(from, to, backend, options)
  end

  def to_string(unquote(naivedatetime()) = from, nil = to, backend, options)
      when calendar == Calendar.ISO do
    from = %{from | calendar: Cldr.Calendar.Gregorian}

    to_string(from, to, backend, options)
  end

  def to_string(unquote(naivedatetime()) = from, unquote(naivedatetime()) = to, options, [])
      when is_list(options) do
    {locale, backend} = Cldr.locale_and_backend_from(options)
    to_string(from, to, backend, Keyword.put_new(options, :locale, locale))
  end

  def to_string(unquote(naivedatetime()) = from, nil = to, options, [])
      when is_list(options) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(options)
    to_string(from, to, backend, Keyword.put_new(options, :locale, locale))
  end

  def to_string(nil = from, unquote(naivedatetime()) = to, options, [])
      when is_list(options) do
    _ = calendar

    {locale, backend} = Cldr.locale_and_backend_from(options)
    to_string(from, to, backend, Keyword.put_new(options, :locale, locale))
  end

  def to_string(unquote(naivedatetime()) = from, unquote(naivedatetime()) = to, backend, options) do
    {locale, backend} = Cldr.locale_and_backend_from(options[:locale], backend)
    format = Keyword.get(options, :format, @default_format)
    locale_number_system = Cldr.Number.System.number_system_from_locale(locale, backend)
    number_system = Keyword.get(options, :number_system, locale_number_system)

    options =
      options
      |> Keyword.put(:locale, locale)
      |> Keyword.put(:nunber_system, number_system)

    with {:ok, _} <- from_less_than_or_equal_to(from, to),
         {:ok, backend} <- Cldr.validate_backend(backend),
         {:ok, locale} <- Cldr.validate_locale(locale, backend),
         {:ok, _} <- Cldr.Number.validate_number_system(locale, number_system, backend),
         {:ok, format} <- validate_format(format),
         {:ok, calendar} <- Cldr.Calendar.validate_calendar(from.calendar),
         {:ok, greatest_difference} <- greatest_difference(from, to) do
      options = adjust_options(options, locale, format)
      format_date_time(from, to, locale, backend, calendar, greatest_difference, options)
    else
      {:error, :no_practical_difference} ->
        options = adjust_options(options, locale, format)
        Cldr.DateTime.to_string(from, backend, options)

      other ->
        other
    end
  end

  # Open ended intervals use the `date_time_interval_fallback/0` format
  def to_string(nil, unquote(naivedatetime()) = to, backend, options) do
    {locale, backend} = Cldr.locale_and_backend_from(options[:locale], backend)
    cldr_calendar = calendar.cldr_calendar_type

    with {:ok, formatted} <- Cldr.DateTime.to_string(to, backend, options) do
      pattern = Module.concat(backend, DateTime.Format).date_time_interval_fallback(locale, cldr_calendar)
      result =
        ["", formatted]
        |> Cldr.Substitution.substitute(pattern)
        |> Enum.join()
        |> String.trim_leading()

      {:ok, result}
    end
  end

  def to_string(unquote(naivedatetime()) = from, nil, backend, options) do
    {locale, backend} = Cldr.locale_and_backend_from(options[:locale], backend)
    cldr_calendar = calendar.cldr_calendar_type

    with {:ok, formatted} <- Cldr.DateTime.to_string(from, backend, options) do
      pattern = Module.concat(backend, DateTime.Format).date_time_interval_fallback(locale, cldr_calendar)
      result =
        [formatted, ""]
        |> Cldr.Substitution.substitute(pattern)
        |> Enum.join()
        |> String.trim_trailing()

      {:ok, result}
    end
  end

  if Cldr.Code.ensure_compiled?(CalendarInterval) do
    @doc false
    def to_string!(%CalendarInterval{} = range, backend) when is_atom(backend) do
      {locale, backend} = Cldr.locale_and_backend_from(nil, backend)
      to_string!(range, backend, locale: locale)
    end

    @doc false
    def to_string!(%CalendarInterval{} = range, options) when is_list(options) do
      {locale, backend} = Cldr.locale_and_backend_from(options[:locale], nil)
      options = Keyword.put_new(options, :locale, locale)
      to_string!(range, backend, options)
    end
  end

  if Cldr.Code.ensure_compiled?(CalendarInterval) do
    @doc """
    Returns a localised string representing the formatted
    interval formed by two dates or raises an
    exception.

    ## Arguments

    * `from` is any map that conforms to the
      `Calendar.datetime` type.

    * `to` is any map that conforms to the
      `Calendar.datetime` type. `to` must occur
      on or after `from`.

    * `backend` is any module that includes `use Cldr` and
      is therefore a `Cldr` backend module.

    * `options` is a keyword list of options. The default is `[]`.

    ## Options

    * `:format` is one of `:short`, `:medium` or `:long` or a
      specific format type or a string representing of an interval
      format. The default is `:medium`.

    * `locale` is any valid locale name returned by `Cldr.known_locale_names/0`
      or a `Cldr.LanguageTag` struct.  The default is `Cldr.get_locale/0`.

    * `number_system:` a number system into which the formatted date digits should
      be transliterated.

    ## Returns

    * `string` or

    * raises an exception

    ## Notes

    * For more information on interval format string
      see the `Cldr.Interval`.

    * The available predefined formats that can be applied are the
      keys of the map returned by `Cldr.DateTime.Format.interval_formats("en", :gregorian)`
      where `"en"` can be replaced by any configuration locale name and `:gregorian`
      is the underlying `CLDR` calendar type.

    * In the case where `from` and `to` are equal, a single
      date is formatted instead of an interval

    ## Examples

        iex> use CalendarInterval
        iex> Cldr.DateTime.Interval.to_string! ~I"2020-01-01 00:00/10:00", MyApp.Cldr
        "Jan 1, 2020, 12:00:00 AM – 10:00:59 AM"

    """

    @spec to_string!(CalendarInterval.t(), Cldr.backend(), Keyword.t()) ::
            String.t() | no_return

    def to_string!(%CalendarInterval{first: from, last: to, precision: precision}, backend, options)
        when precision in [:year, :month, :day] do
      Cldr.Date.Interval.to_string!(from, to, backend, options)
    end

    def to_string!(%CalendarInterval{first: from, last: to, precision: precision}, backend, options)
        when precision in [:hour, :minute, :second] do
      to_string!(from, to, backend, options)
    end
  end

  @doc """
  Returns a localised string representing the formatted
  interval formed by two dates or raises an
  exception.

  ## Arguments

  * `from` is any map that conforms to the
    `Calendar.datetime` type.

  * `to` is any map that conforms to the
    `Calendar.datetime` type. `to` must occur
    on or after `from`.

  * `backend` is any module that includes `use Cldr` and
    is therefore a `Cldr` backend module.

  * `options` is a keyword list of options. The default is `[]`.

  ## Options

  * `:format` is one of `:short`, `:medium` or `:long` or a
    specific format type or a string representing of an interval
    format. The default is `:medium`.

  * `locale` is any valid locale name returned by `Cldr.known_locale_names/0`
    or a `Cldr.LanguageTag` struct.  The default is `Cldr.get_locale/0`.

  * `number_system:` a number system into which the formatted date digits should
    be transliterated.

  ## Returns

  * `string` or

  * raises an exception

  ## Notes

  * For more information on interval format string
    see the `Cldr.Interval`.

  * The available predefined formats that can be applied are the
    keys of the map returned by `Cldr.DateTime.Format.interval_formats("en", :gregorian)`
    where `"en"` can be replaced by any configuration locale name and `:gregorian`
    is the underlying `CLDR` calendar type.

  * In the case where `from` and `to` are equal, a single
    date is formatted instead of an interval

  ## Examples

      iex> Cldr.DateTime.Interval.to_string! ~U[2020-01-01 00:00:00.0Z],
      ...> ~U[2020-12-31 10:00:00.0Z], MyApp.Cldr
      "Jan 1, 2020, 12:00:00 AM – Dec 31, 2020, 10:00:00 AM"

  """
  def to_string!(from, to, backend, options \\ []) do
    case to_string(from, to, backend, options) do
      {:ok, string} -> string
      {:error, {exception, reason}} -> raise exception, reason
    end
  end

  defp from_less_than_or_equal_to(%{time_zone: zone} = from, %{time_zone: zone} = to) do
    case DateTime.compare(from, to) do
      comp when comp in [:eq, :lt] -> {:ok, comp}
      _other -> {:error, Cldr.Date.Interval.datetime_order_error(from, to)}
    end
  end

  defp from_less_than_or_equal_to(%{time_zone: _zone1} = from, %{time_zone: _zone2} = to) do
    {:error, Cldr.Date.Interval.datetime_incompatible_timezone_error(from, to)}
  end

  defp from_less_than_or_equal_to(from, to) do
    case NaiveDateTime.compare(from, to) do
      comp when comp in [:eq, :lt] -> {:ok, comp}
      _other -> {:error, Cldr.Date.Interval.datetime_order_error(from, to)}
    end
  end

  @doc false
  def adjust_options(options, locale, format) do
    options
    |> Keyword.put(:locale, locale)
    |> Keyword.put(:format, format)
    |> Keyword.delete(:style)
  end

  defp format_date_time(from, to, locale, backend, calendar, difference, options) do
    backend_format = Module.concat(backend, DateTime.Format)
    {:ok, calendar} = Cldr.DateTime.type_from_calendar(calendar)
    fallback = backend_format.date_time_interval_fallback(locale, calendar)
    format = Keyword.fetch!(options, :format)

    [from_format, to_format] = extract_format(format)
    from_options = Keyword.put(options, :format, from_format)
    to_options = Keyword.put(options, :format, to_format)

    do_format_date_time(from, to, backend, format, difference, from_options, to_options, fallback)
  end

  # The difference is only in the time part
  defp do_format_date_time(from, to, backend, format, difference, from_opts, to_opts, fallback)
       when difference in [:H, :m] do
    with {:ok, from_string} <- Cldr.DateTime.to_string(from, backend, from_opts),
         {:ok, to_string} <- Cldr.Time.to_string(to, backend, to_opts) do
      {:ok, combine_result(from_string, to_string, format, fallback)}
    end
  end

  # The difference is in the date part
  # Format each datetime separately and join with
  # the interval fallback format
  defp do_format_date_time(from, to, backend, format, difference, from_opts, to_opts, fallback)
       when difference in [:y, :M, :d] do
    with {:ok, from_string} <- Cldr.DateTime.to_string(from, backend, from_opts),
         {:ok, to_string} <- Cldr.DateTime.to_string(to, backend, to_opts) do
      {:ok, combine_result(from_string, to_string, format, fallback)}
    end
  end

  defp combine_result(left, right, format, _fallback) when is_list(format) do
    left <> right
  end

  defp combine_result(left, right, format, fallback) when is_atom(format) do
    [left, right]
    |> Cldr.Substitution.substitute(fallback)
    |> Enum.join()
  end

  defp extract_format(format) when is_atom(format) do
    [format, format]
  end

  defp extract_format([from_format, to_format]) do
    [from_format, to_format]
  end

  # Using standard format terms like :short, :medium, :long
  defp validate_format(format) when format in @formats do
    {:ok, format}
  end

  # Direct specification of a format as a string
  @doc false
  defp validate_format(format) when is_binary(format) do
    Cldr.DateTime.Format.split_interval(format)
  end

  @doc false
  def format_error(format) do
    {
      Cldr.DateTime.UnresolvedFormat,
      "The interval format #{inspect(format)} is invalid. " <>
        "Valid formats are #{inspect(@formats)} or an interval format string.}"
    }
  end
end