lib/zonex.ex

defmodule Zonex do
  @moduledoc """
  Zonex is a library for compiling enriched time zone information.
  """

  alias Zonex.Aliases
  alias Zonex.Zone
  alias Zonex.MetaZones
  alias Zonex.MetaZones.MetaZone
  alias Zonex.MetaZones.MetaZone.Variants
  alias Zonex.WindowsZones
  alias Zonex.WindowsZones.WindowsZone

  @doc """
  Lists all canonical time zones from the IANA database.

  Since names and UTC offsets vary depending on time of year
  (due to daylight saving time), you need to specify the `instant`
  at which the time zone should be expressed.

  ## Options

  * `:locale` is any locale or locale name validated
    by `Cldr.validate_locale/2`. The default is
    `Cldr.get_locale()` which returns the locale
    set for the current process
  """
  @spec list_canonical(instant :: DateTime.t(), opts :: Keyword.t()) :: [Zone.t()]
  def list_canonical(%DateTime{} = instant, opts \\ []) do
    aliases = Aliases.forward_mapping()

    Tzdata.canonical_zone_list()
    |> Enum.map(&cast(&1, instant, aliases, opts))
  end

  @doc """
  Gets a zone for a given IANA time zone name.

  If the time zone is an alias (not canonical), the canonical zone
  will be returned instead.

  Since names and UTC offsets vary depending on time of year
  (due to daylight saving time), you need to specify the `instant`
  at which the time zone should be expressed.

  ## Options

  * `:locale` is any locale or locale name validated
    by `Cldr.validate_locale/2`. The default is
    `Cldr.get_locale()` which returns the locale
    set for the current process
  """
  @spec get_canonical(zone_name :: String.t(), instant :: DateTime.t(), opts :: Keyword.t()) ::
          {:ok, Zone.t()} | {:error, :zone_not_found}
  def get_canonical(zone_name, instant, opts \\ [])

  def get_canonical("UTC", %DateTime{} = instant, opts) do
    get_canonical("Etc/UTC", instant, opts)
  end

  def get_canonical(zone_name, %DateTime{} = instant, opts) do
    if Tzdata.canonical_zone?(zone_name) do
      {:ok, cast(zone_name, instant, Aliases.forward_mapping(), opts)}
    else
      instant
      |> list_canonical()
      |> Enum.find(&(zone_name in &1.aliases))
      |> after_find()
    end
  end

  defp after_find(%Zone{} = zone), do: {:ok, zone}
  defp after_find(_), do: {:error, :zone_not_found}

  @doc """
  Gets a zone for a given IANA time zone name and raises if not found.

  If the time zone is an alias (not canonical), the canonical zone
  will be returned instead.

  Since names and UTC offsets vary depending on time of year
  (due to daylight saving time), you need to specify the `instant`
  at which the time zone should be expressed.

  ## Options

  * `:locale` is any locale or locale name validated
    by `Cldr.validate_locale/2`. The default is
    `Cldr.get_locale()` which returns the locale
    set for the current process
  """
  @spec get_canonical!(zone_name :: String.t(), instant :: DateTime.t(), opts :: Keyword.t()) ::
          Zone.t() | no_return()
  def get_canonical!(zone_name, %DateTime{} = instant, opts \\ []) do
    case get_canonical(zone_name, instant, opts) do
      {:ok, zone} -> zone
      _ -> raise "zone not found: #{zone_name}"
    end
  end

  @doc """
  Determines if a zone name is legacy.

      iex> Zonex.legacy?("America/Chicago")
      false

      iex> Zonex.legacy?("WET")
      true
  """
  @spec legacy?(zone_name :: Calendar.time_zone()) :: boolean()
  def legacy?(zone_name) do
    # Include legacy time zones, like "EST".
    # Olson time zones (e.g. "America/Chicago") always
    # contain a /, so this is a decent enough proxy.
    !String.contains?(zone_name, "/")
  end

  # Private helpers

  defp cast(name, datetime, aliases, opts) do
    zone = Timex.Timezone.get(name, datetime)
    offset = Timex.Timezone.total_offset(zone)
    formatted_offset = format_offset(offset)
    dst = dst?(name, datetime)
    maybe_meta_zone = build_meta_zone(name, datetime, dst, opts)
    maybe_windows_zone = build_windows_zone(name)
    long_name = build_long_name(name, maybe_meta_zone, maybe_windows_zone)
    generic_long_name = build_generic_long_name(name, maybe_meta_zone, maybe_windows_zone)

    %Zone{
      name: name,
      meta_zone: maybe_meta_zone,
      windows_zone: maybe_windows_zone,
      long_name: long_name,
      generic_long_name: generic_long_name,
      exemplar_city: exemplar_city(maybe_meta_zone),
      abbreviation: zone.abbreviation,
      aliases: Map.get(aliases, name, []),
      zone: zone,
      offset: offset,
      formatted_offset: formatted_offset,
      golden: golden?(name, maybe_meta_zone),
      legacy: legacy?(name),
      dst: dst,
      canonical: true
    }
  end

  defp build_generic_long_name(_, %{long: %{generic: name}}, _)
       when is_binary(name),
       do: name

  defp build_generic_long_name(_, %{long: %{standard: name}}, _)
       when is_binary(name),
       do: name

  defp build_generic_long_name(_, _, %{name: name}) when is_binary(name), do: name
  defp build_generic_long_name(name, _, _), do: name

  defp build_long_name(_, %{long: %{current: name}}, _)
       when is_binary(name),
       do: name

  defp build_long_name(_, %{long: %{generic: name}}, _)
       when is_binary(name),
       do: name

  defp build_long_name(_, _, %{name: name}) when is_binary(name), do: name
  defp build_long_name(name, _, _), do: name

  defp exemplar_city(%{exemplar_city: city}), do: city
  defp exemplar_city(_), do: nil

  defp dst?(zone_name, datetime) do
    time_point = elem(DateTime.to_gregorian_seconds(datetime), 0)

    case Tzdata.periods_for_time(zone_name, time_point, :utc) do
      [period | _] -> period[:std_off] != 0
      _ -> false
    end
  end

  defp build_windows_zone(zone_name) do
    if name = WindowsZones.standard_name(zone_name) do
      %WindowsZone{name: name}
    else
      nil
    end
  end

  defp build_meta_zone(zone_name, datetime, dst, opts) do
    rules = MetaZones.rules_for_zone(zone_name)

    with {:ok, mzone} <- MetaZones.resolve(rules, datetime),
         {:ok, info} <- name_info(zone_name, mzone, opts) do
      %MetaZone{
        name: mzone,
        territories: MetaZones.territories(zone_name, mzone),
        long: build_name_variants(info.long, dst),
        short: build_name_variants(info.short, dst),
        exemplar_city: info.exemplar_city
      }
    else
      _ -> nil
    end
  end

  defp build_name_variants(%_{} = data, dst) do
    %Variants{
      generic: data.generic,
      standard: data.standard,
      daylight: data.daylight,
      current: current_name(data, dst)
    }
  end

  defp build_name_variants(_, _), do: nil

  defp name_info(zone_name, mzone, opts) do
    tz_name_backend().resolve(zone_name, String.downcase(mzone), opts)
  end

  defp current_name(%{daylight: daylight}, true) when is_binary(daylight), do: daylight
  defp current_name(%{standard: standard}, false) when is_binary(standard), do: standard
  defp current_name(%{generic: generic}, _), do: generic

  defp golden?(_, %{territories: territories}), do: "001" in territories
  defp golden?(_, _), do: false

  # Logic borrowed from Timex inspect logic:
  # https://github.com/bitwalker/timex/blob/45424fa293066b210eaf94dd650707343583d085/lib/timezone/inspect.ex#L6
  defp format_offset(total_offset) do
    offset_hours = div(total_offset, 60 * 60)
    offset_mins = div(rem(total_offset, 60 * 60), 60)
    hour = "#{pad_numeric(offset_hours)}"
    min = "#{pad_numeric(abs(offset_mins))}"

    if offset_hours + offset_mins >= 0 do
      "+#{hour}:#{min}"
    else
      "#{hour}:#{min}"
    end
  end

  defp pad_numeric(number) when is_integer(number), do: pad_numeric("#{number}")

  defp pad_numeric(<<?-, number_str::binary>>) do
    res = pad_numeric(number_str)
    <<?-, res::binary>>
  end

  defp pad_numeric(number_str) do
    min_width = 2
    len = String.length(number_str)

    if len < min_width do
      String.duplicate("0", min_width - len) <> number_str
    else
      number_str
    end
  end

  defp cldr_backend do
    Application.fetch_env!(:zonex, :cldr_backend)
  end

  defp tz_name_backend do
    Module.concat(cldr_backend(), TimeZoneName)
  end
end