lib/timezone/timezone_info.ex

defmodule Timex.TimezoneInfo do
  @moduledoc """
  All relevant timezone information for a given period, i.e. Europe/Moscow on March 3rd, 2013

  Notes:
    - `full_name` is the name of the zone, but does not indicate anything about the current period (i.e. CST vs CDT)
    - `abbreviation` is the abbreviated name for the zone in the current period, i.e. "America/Chicago" on 3/30/15 is "CDT"
    - `offset_std` is the offset in seconds from standard time for this period
    - `offset_utc` is the offset in seconds from UTC for this period

  Spec:
    - `day_of_week`: :sunday, :monday, :tuesday, etc
    - `datetime`:    {{year, month, day}, {hour, minute, second}}
    - `from`:      :min | {day_of_week, datetime}, when this zone starts
    - `until`:     :max | {day_of_week, datetime}, when this zone ends
  """

  defstruct full_name: "Etc/UTC",
            abbreviation: "UTC",
            offset_std: 0,
            offset_utc: 0,
            from: :min,
            until: :max

  @valid_day_names [:sunday, :monday, :tuesday, :wednesday, :thursday, :friday, :saturday]
  @max_seconds_in_day 60 * 60 * 24

  @type day_of_week :: :sunday | :monday | :tuesday | :wednesday | :thursday | :friday | :saturday
  @type datetime :: {{non_neg_integer, 1..12, 1..31}, {0..24, 0..59, 0..60}}
  @type offset :: -85399..85399
  @type from_constraint :: :min | {day_of_week, datetime}
  @type until_constraint :: :max | {day_of_week, datetime}
  @type t :: %__MODULE__{
          full_name: String.t(),
          abbreviation: String.t(),
          offset_std: offset,
          offset_utc: offset,
          from: from_constraint,
          until: until_constraint
        }

  @doc """
  Create a custom timezone if a built-in one does not meet your needs.

  You must provide the name, abbreviation, offset from UTC, daylight savings time offset,
  and the from/until reference points for when the zone takes effect and ends.

  To clarify the two offsets, `offset_utc` is the absolute offset relative to UTC,
  `offset_std` is the offset to apply to `offset_utc` which gives us the offset from UTC
  during daylight savings time for this timezone. If DST does not apply for this zone, simply
  set it to 0.

  The from/until reference points must meet the following criteria:

      - Be set to `:min` for from, or `:max` for until, which represent
        "infinity" for the start/end of the zone period.
      - OR, be a tuple of {day_of_week, datetime}, where:
        - `day_of_week` is an atom like `:sunday`
        - `datetime` is an Erlang datetime tuple, e.g. `{{2016,10,8},{2,0,0}}`

  *IMPORTANT*: Offsets are in seconds, not minutes, if you do not ensure they
               are in the correct unit, runtime errors or incorrect results are probable.

  ## Examples

      iex> #{__MODULE__}.create("Etc/Test", "TST", 120*60, 0, :min, :max)
      %TimezoneInfo{full_name: "Etc/Test", abbreviation: "TST", offset_std: 7200, offset_utc: 0, from: :min, until: :max}
      ...> #{__MODULE__}.create("Etc/Test", "TST", 24*60*60, 0, :min, :max)
      {:error, "invalid timezone offset '86400'"}
  """
  @spec create(String.t(), String.t(), offset, offset, from_constraint, until_constraint) ::
          __MODULE__.t() | {:error, String.t()}
  def create(name, abbr, offset_utc, offset_std, from, until) do
    %__MODULE__{
      full_name: name,
      abbreviation: abbr,
      offset_std: offset_std,
      offset_utc: offset_utc,
      from: from || :min,
      until: until || :max
    }
    |> validate_and_return()
  end

  @doc """
  Formats the offset of a `Timex.TimezoneInfo` struct.

  ## Examples

      iex> tzinfo = Timex.Timezone.get("Etc/Greenwich", ~U[2022-01-01 00:00:00Z])
      ...> #{__MODULE__}.format_offset(tzinfo)
      "+00:00:00"

      iex> tzinfo = Timex.Timezone.get("Africa/Nairobi", ~U[2022-01-01 00:00:00Z])
      ...> #{__MODULE__}.format_offset(tzinfo)
      "+03:00:00"

      iex> tzinfo = Timex.Timezone.get("America/New_York", ~U[2022-01-01 00:00:00Z])
      ...> #{__MODULE__}.format_offset(tzinfo)
      "-05:00:00"
  """
  @spec format_offset(tzinfo :: t()) :: String.t()
  def format_offset(tzinfo) do
    total_offset = Timex.Timezone.total_offset(tzinfo)
    do_format_offset(total_offset)
  end

  def from_datetime(%DateTime{
        time_zone: name,
        zone_abbr: abbr,
        std_offset: std_offset,
        utc_offset: utc_offset
      }) do
    %__MODULE__{
      full_name: name,
      abbreviation: abbr,
      offset_std: std_offset,
      offset_utc: utc_offset,
      from: :min,
      until: :max
    }
  end

  @doc false
  def to_period(%__MODULE__{offset_utc: utc, offset_std: std, abbreviation: abbr}) do
    %{std_offset: std, utc_offset: utc, zone_abbr: abbr}
  end

  defp validate_and_return(%__MODULE__{} = tz) do
    with true <- is_valid_name(tz.full_name),
         true <- is_valid_name(tz.abbreviation),
         true <- is_valid_offset(tz.offset_std),
         true <- is_valid_offset(tz.offset_utc),
         true <- is_valid_from_constraint(tz.from),
         true <- is_valid_until_constraint(tz.until),
         do: tz
  end

  defp is_valid_name(name) when is_binary(name), do: true
  defp is_valid_name(name), do: {:error, "invalid timezone name '#{inspect(name)}'!"}

  defp is_valid_offset(offset)
       when is_integer(offset) and
              (offset < @max_seconds_in_day and offset > -@max_seconds_in_day),
       do: true

  defp is_valid_offset(offset), do: {:error, "invalid timezone offset '#{inspect(offset)}'"}

  defp is_valid_from_constraint(:min), do: true

  defp is_valid_from_constraint(:max),
    do: {:error, ":max is not a valid from constraint for timezones"}

  defp is_valid_from_constraint(c), do: is_valid_constraint(c)

  defp is_valid_until_constraint(:min),
    do: {:error, ":min is not a valid until constraint for timezones"}

  defp is_valid_until_constraint(:max), do: true
  defp is_valid_until_constraint(c), do: is_valid_constraint(c)

  defp is_valid_constraint({day_of_week, {{y, m, d}, {h, mm, s}}} = datetime)
       when day_of_week in @valid_day_names do
    cond do
      :calendar.valid_date({y, m, d}) ->
        valid_hour = h >= 1 and h <= 24
        valid_min = mm >= 0 and mm <= 59
        valid_sec = s >= 0 and s <= 59

        cond do
          valid_hour && valid_min && valid_sec ->
            true

          :else ->
            {:error,
             "invalid datetime constraint for timezone: #{inspect(datetime)} (invalid time)"}
        end

      :else ->
        {:error, "invalid datetime constraint for timezone: #{inspect(datetime)} (invalid date)"}
    end
  end

  defp is_valid_constraint(c),
    do: {:error, "'#{inspect(c)}' is not a valid constraint for timezones"}

  defp do_format_offset(offset_seconds) do
    offset_hours = div(offset_seconds, 60 * 60)
    offset_mins = div(rem(offset_seconds, 60 * 60), 60)
    offset_secs = rem(rem(offset_seconds, 60 * 60), 60)
    hour = "#{pad_numeric(offset_hours)}"
    min = "#{pad_numeric(offset_mins)}"
    secs = "#{pad_numeric(offset_secs)}"

    cond do
      offset_hours + offset_mins >= 0 -> "+#{hour}:#{min}:#{secs}"
      true -> "#{hour}:#{min}:#{secs}"
    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)

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