lib/cldr/time.ex

defmodule Cldr.Time do
  @moduledoc """
  Provides localization and formatting of a `Time`
  struct or any map with the keys `:hour`, `:minute`,
  `:second` and optionlly `:microsecond`.

  `Cldr.Time` provides support for the built-in calendar
  `Calendar.ISO` or any calendars defined with
  [ex_cldr_calendars](https://hex.pm/packages/ex_cldr_calendars)

  CLDR provides standard format strings for `Time` which
  are reresented by the names `:short`, `:medium`, `:long`
  and `:full`.  This allows for locale-independent
  formatting since each locale may define the underlying
  format string as appropriate.

  """

  alias Cldr.DateTime.Format
  alias Cldr.LanguageTag

  @style_types [:short, :medium, :long, :full]
  @default_type :medium

  defmodule Styles do
    @moduledoc false
    defstruct Module.get_attribute(Cldr.Time, :style_types)
  end

  @doc """
  Formats a time according to a format string
  as defined in CLDR and described in [TR35](http://unicode.org/reports/tr35/tr35-dates.html)

  ## Returns

  * `{:ok, formatted_time}` or

  * `{:error, reason}`.

  ## Arguments

  * `time` is a `%DateTime{}` or `%NaiveDateTime{}` struct or any map that contains the keys
    `hour`, `minute`, `second` and optionally `calendar` and `microsecond`

  * `backend` is any module that includes `use Cldr` and therefore
    is a `Cldr` backend module. The default is `Cldr.default_backend/0`.

  * `options` is a keyword list of options for formatting.

  ## Options

  * `format:` `:short` | `:medium` | `:long` | `:full` or a format string.
     The default is `:medium`

  * `locale:` any locale returned by `Cldr.known_locale_names/1`.  The default is `
    Cldr.get_locale()`

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

  * `era: :variant` will use a variant for the era is one is available in the locale.
    In the "en" locale, for example, `era: :variant` will return "BCE" instead of "BC".

  * `period: :variant` will use a variant for the time period and flexible time period if
    one is available in the locale.  For example, in the "en" locale `period: :variant` will
    return "pm" instead of "PM"

  ## Examples

      iex> Cldr.Time.to_string ~T[07:35:13.215217], MyApp.Cldr
      {:ok, "7:35:13 AM"}

      iex> Cldr.Time.to_string ~T[07:35:13.215217], MyApp.Cldr, format: :short
      {:ok, "7:35 AM"}

      iex> Cldr.Time.to_string ~T[07:35:13.215217], MyApp.Cldr, format: :medium, locale: "fr"
      {:ok, "07:35:13"}

      iex> Cldr.Time.to_string ~T[07:35:13.215217], MyApp.Cldr, format: :medium
      {:ok, "7:35:13 AM"}

      iex> {:ok, datetime} = DateTime.from_naive(~N[2000-01-01 23:59:59.0], "Etc/UTC")
      iex> Cldr.Time.to_string datetime, MyApp.Cldr, format: :long
      {:ok, "11:59:59 PM UTC"}

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

  def to_string(time, backend \\ Cldr.Date.default_backend(), options \\ [])

  def to_string(%{calendar: Calendar.ISO} = time, backend, options) do
    %{time | calendar: Cldr.Calendar.Gregorian}
    |> to_string(backend, options)
  end

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

  def to_string(%{hour: _hour, minute: _minute} = time, backend, options) do
    options = normalize_options(backend, options)
    calendar = Map.get(time, :calendar) || Cldr.Calendar.Gregorian
    format_backend = Module.concat(backend, DateTime.Formatter)
    number_system = Map.get(options, :number_system)

    with {:ok, locale} <- Cldr.validate_locale(options[:locale], backend),
         {:ok, cldr_calendar} <- Cldr.DateTime.type_from_calendar(calendar),
         {:ok, _} <- Cldr.Number.validate_number_system(locale, number_system, backend),
         {:ok, format_string} <- format_string(options[:format], locale, cldr_calendar, backend),
         {:ok, formatted} <- format_backend.format(time, format_string, locale, options) do
      {:ok, formatted}
    end
  rescue
    e in [Cldr.DateTime.UnresolvedFormat] ->
      {:error, {e.__struct__, e.message}}
  end

  def to_string(time, _backend, _options) do
    error_return(time, [:hour, :minute, :second])
  end

  # TODO deprecate :style in version 3.0
  defp normalize_options(_backend, %{} = options) do
    options
  end

  defp normalize_options(backend, []) do
    {locale, _backend} = Cldr.locale_and_backend_from(nil, backend)
    number_system = Cldr.Number.System.number_system_from_locale(locale, backend)

    %{locale: locale, number_system: number_system, format: @default_type}
  end

  defp normalize_options(backend, options) do
    {locale, _backend} = Cldr.locale_and_backend_from(options[:locale], backend)
    format = options[:format] || options[:stylet] || @default_type
    locale_number_system = Cldr.Number.System.number_system_from_locale(locale, backend)
    number_system = Keyword.get(options, :number_system, locale_number_system)

    options
    |> Keyword.put(:locale, locale)
    |> Keyword.put(:format, format)
    |> Keyword.delete(:style)
    |> Keyword.put_new(:number_system, number_system)
    |> Map.new()
  end

  @doc """
  Formats a time according to a format string
  as defined in CLDR and described in [TR35](http://unicode.org/reports/tr35/tr35-dates.html).

  ## Arguments

  * `time` is a `%DateTime{}` or `%NaiveDateTime{}` struct or any map that contains the keys
    `hour`, `minute`, `second` and optionally `calendar` and `microsecond`

  * `backend` is any module that includes `use Cldr` and therefore
    is a `Cldr` backend module. The default is `Cldr.default_backend/0`.

  * `options` is a keyword list of options for formatting.

  ## Options

    * `format:` `:short` | `:medium` | `:long` | `:full` or a format string.
       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

    * `era: :variant` will use a variant for the era is one is available in the locale.
      In the "en" locale, for example, `era: :variant` will return "BCE" instead of "BC".

    * `period: :variant` will use a variant for the time period and flexible time period if
      one is available in the locale.  For example, in the "en" locale `period: :variant` will
      return "pm" instead of "PM"

  ## Returns

  * `formatted_time_string` or

  * raises an exception.

  ## Examples

      iex> Cldr.Time.to_string! ~T[07:35:13.215217], MyApp.Cldr
      "7:35:13 AM"

      iex> Cldr.Time.to_string! ~T[07:35:13.215217], MyApp.Cldr, format: :short
      "7:35 AM"

      iex> Cldr.Time.to_string ~T[07:35:13.215217], MyApp.Cldr, format: :short, period: :variant
      {:ok, "7:35 AM"}

      iex> Cldr.Time.to_string! ~T[07:35:13.215217], MyApp.Cldr, format: :medium, locale: "fr"
      "07:35:13"

      iex> Cldr.Time.to_string! ~T[07:35:13.215217], MyApp.Cldr, format: :medium
      "7:35:13 AM"

      iex> {:ok, datetime} = DateTime.from_naive(~N[2000-01-01 23:59:59.0], "Etc/UTC")
      iex> Cldr.Time.to_string! datetime, MyApp.Cldr, format: :long
      "11:59:59 PM UTC"

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

  def to_string!(time, backend \\ Cldr.Date.default_backend(), options \\ [])

  def to_string!(time, backend, options) do
    case to_string(time, backend, options) do
      {:ok, string} -> string
      {:error, {exception, message}} -> raise exception, message
    end
  end

  defp format_string(style, %LanguageTag{cldr_locale_name: locale_name}, calendar, backend)
       when style in @style_types do
    with {:ok, styles} <- Format.time_formats(locale_name, calendar, backend) do
      {:ok, Map.get(styles, style)}
    end
  end

  defp format_string(%{number_system: number_system, format: style}, locale, calendar, backend) do
    {:ok, format_string} = format_string(style, locale, calendar, backend)
    {:ok, %{number_system: number_system, format: format_string}}
  end

  defp format_string(style, _locale, _backend, _calendar) when is_atom(style) do
    {:error,
     {Cldr.DateTime.InvalidStyle,
      "Invalid time format style.  " <> "The valid styles are #{inspect(@style_types)}."}}
  end

  defp format_string(format_string, _locale, _calendar, _backend)
       when is_binary(format_string) do
    {:ok, format_string}
  end

  @doc """
  Return the preferred time format for a locale.

  ## Arguments

  * `language_tag` is any language tag returned by `Cldr.Locale.new/2`
    or any `locale_name` returned by `Cldr.known_locale_names/1`

  ## Returns

  * The hour format as an atom to be used for localization purposes. The
    return value is used as a function name in `Cldr.DateTime.Formatter`

  ## Notes

  * The `hc` key of the `u` extension is honoured and will
    override the default preferences for a locale or territory.
    See the last example below.

  * Different locales and territories present the hour
    of day in different ways. These are represented
    in `Cldr.DateTime.Formatter` in the following way:

  | Symbol  | Midn.  |  Morning  | Noon  |  Afternoon | Midn. |
  | :----:  | :---:  | :-----:   | :--:  | :--------: | :---: |
  |   h     |  12    | 1...11    |  12   |   1...11   |  12   |
  |   K     |   0    | 1...11    |   0   |   1...11   |   0   |
  |   H     |   0    | 1...11    |  12   |  13...23   |   0   |
  |   k     |  24    | 1...11    |  12   |  13...23   |  24   |

  ## Examples

      iex> Cldr.Time.hour_format_from_locale "en-AU"
      :h12

      iex> Cldr.Time.hour_format_from_locale "fr"
      :h23

      iex> Cldr.Time.hour_format_from_locale "fr-u-hc-h12"
      :h12

  """
  def hour_format_from_locale(%LanguageTag{locale: %{hc: hour_cycle}})
      when not is_nil(hour_cycle) do
    hour_cycle
  end

  def hour_format_from_locale(%LanguageTag{} = locale) do
    preferences = time_preferences()
    territory = Cldr.Locale.territory_from_locale(locale)

    preference =
      preferences[locale.cldr_locale_name] ||
        preferences[territory] ||
        preferences[Cldr.the_world()]

    Map.fetch!(time_symbols(), preference.preferred)
  end

  def hour_format_from_locale(locale_name, backend \\ Cldr.Date.default_backend()) do
    with {:ok, locale} <- Cldr.validate_locale(locale_name, backend) do
      hour_format_from_locale(locale)
    end
  end

  @time_preferences Cldr.Config.time_preferences()
  defp time_preferences do
    @time_preferences
  end

  # | Symbol   | Midn.  |  Morning  | Noon  | Afternoon  | Midn. | Code
  # | :----:   | :---:  | :-----:   | :--:  | :--------: | :---: | :--:
  # |   h      |  12    | 1...11    |  12   |   1...11   |  12   | :h12
  # |   K      |   0    | 1...11    |   0   |   1...11   |   0   | :h11
  # |   H      |   0    | 1...11    |  12   |  13...23   |   0   | :h23
  # |   k      |  24    | 1...11    |  12   |  13...23   |  24   | :h24
  #
  defp time_symbols do
    %{
      "h" => :h12, # :hour_1_12,
      "K" => :h11, # :hour_0_11,
      "H" => :h23, # :hour_0_23,
      "k" => :h24, # :hour_1_24.
    }
  end

  defp error_return(map, requirements) do
    requirements =
      requirements
      |> Enum.map(&inspect/1)
      |> Cldr.DateTime.Formatter.join_requirements()

    {:error,
     {ArgumentError,
      "Invalid time. Time is a map that contains at least #{requirements} fields. " <>
        "Found: #{inspect(map)}"}}
  end
end