lib/timezone/posix_timezone.ex

defmodule Timex.PosixTimezone do
  @moduledoc """
  Used when parsing POSIX-TZ timezone rules.
  """
  alias Timex.TimezoneInfo

  defstruct name: nil,
            std_abbr: nil,
            std_offset: 0,
            dst_abbr: nil,
            dst_offset: nil,
            dst_start: nil,
            dst_end: nil

  @type rule_bound ::
          {{:julian_leap, 0..365}, Time.t()}
          | {{:julian, 1..365}, Time.t()}
          | {{:mwd, {month :: 1..12, week :: 1..5, day_of_week :: 0..6}}, Time.t()}
          | nil

  @type t :: %__MODULE__{
          name: nil | String.t(),
          std_abbr: nil | String.t(),
          dst_abbr: nil | String.t(),
          std_offset: integer(),
          dst_offset: integer(),
          dst_start: rule_bound(),
          dst_end: rule_bound()
        }

  @doc """
  Obtains a `NaiveDateTime` representing the start of DST for this zone.

  Returns nil if there is no DST period.
  """
  @spec dst_start(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: NaiveDateTime.t() | nil
  def dst_start(posix_tz, date)

  def dst_start(%__MODULE__{dst_start: nil}, _), do: nil

  def dst_start(%__MODULE__{dst_start: dst_start}, %{year: year}) do
    bound_to_naive_datetime(dst_start, year)
  end

  @doc """
  Obtains a `NaiveDateTime` representing the end of DST for this zone.

  Returns nil if there is no DST period.
  """
  @spec dst_end(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: NaiveDateTime.t() | nil
  def dst_end(posix_tz, date)

  def dst_end(%__MODULE__{dst_end: nil}, _), do: nil

  def dst_end(%__MODULE__{dst_end: dst_end}, %{year: year}) do
    bound_to_naive_datetime(dst_end, year)
  end

  @doc """
  Returns a `TimezoneInfo` struct representing this timezone for the given datetime
  """
  @spec to_timezone_info(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: TimezoneInfo.t()
  def to_timezone_info(%__MODULE__{} = tz, date) do
    date = to_naive_datetime(date)

    if is_dst?(tz, date) do
      %TimezoneInfo{
        full_name: tz.name,
        abbreviation: tz.dst_abbr,
        offset_std: tz.dst_offset,
        offset_utc: tz.std_offset,
        from: :min,
        until: :max
      }
    else
      %TimezoneInfo{
        full_name: tz.name,
        abbreviation: tz.std_abbr,
        offset_std: 0,
        offset_utc: tz.std_offset,
        from: :min,
        until: :max
      }
    end
  end

  @doc """
  Returns a `Calendar.TimeZoneDatabase` compatible map, representing this timezone for the given datetime
  """
  def to_period_for_date(%__MODULE__{} = tz, date) do
    date = to_naive_datetime(date)

    if is_dst?(tz, date) do
      std_offset = tz.dst_offset
      utc_offset = tz.std_offset

      %{
        std_offset: std_offset,
        utc_offset: utc_offset,
        zone_abbr: tz.dst_abbr,
        time_zone: tz.name
      }
    else
      %{std_offset: 0, utc_offset: tz.std_offset, zone_abbr: tz.std_abbr, time_zone: tz.name}
    end
  end

  @doc """
  Returns a boolean indicating if the datetime provided occurs during DST of the given POSIX timezone.
  """
  @spec is_dst?(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: boolean
  def is_dst?(%__MODULE__{} = tz, date) do
    with %NaiveDateTime{} = dst_start <- dst_start(tz, date),
         %NaiveDateTime{} = dst_end <- dst_end(tz, date) do
      cond do
        NaiveDateTime.compare(date, dst_start) == :lt ->
          false

        NaiveDateTime.compare(date, dst_end) == :gt ->
          false

        :else ->
          true
      end
    else
      nil ->
        false
    end
  end

  defp bound_to_naive_datetime({{:mwd, month, week, weekday}, time}, year) do
    month_start = Timex.Date.new!(year, month, 1)
    month_start_dow = Timex.Date.day_of_week(month_start, :sunday) - 1

    if weekday == month_start_dow and week == 1 do
      # Got lucky, we're done
      Timex.NaiveDateTime.new!(month_start, time)
    else
      first_week_date =
        if month_start_dow <= weekday do
          # The week starting on the 1st includes our weekday, so it is the first week of the month
          %{month_start | day: month_start.day + (weekday - month_start_dow)}
        else
          # The week starting on the 1st does not include our weekday, so shift forward a week
          eow = Timex.Date.end_of_week(month_start)
          %{eow | day: eow.day + 1 + weekday}
        end

      cond do
        week == 1 ->
          first_week_date

        :else ->
          day_shift = (week - 1) * 7
          day = first_week_date.day + day_shift
          ldom = :calendar.last_day_of_the_month(year, month)

          date =
            if ldom > day do
              # Last occurrence is in week 4, so shift back a week
              %{first_week_date | day: day - 7}
            else
              %{first_week_date | day: day}
            end

          Timex.NaiveDateTime.new!(date, time)
      end
    end
  end

  defp bound_to_naive_datetime({{:julian, day}, time}, year) do
    date = Timex.Calendar.Julian.date_for_day_of_year(day - 1, year, leaps: false)
    Timex.NaiveDateTime.new!(date, time)
  end

  defp bound_to_naive_datetime({{:julian_leap, day}, time}, year) do
    date = Timex.Calendar.Julian.date_for_day_of_year(day, year, leaps: true)
    Timex.NaiveDateTime.new!(date, time)
  end

  defp to_naive_datetime(%NaiveDateTime{} = date), do: date
  defp to_naive_datetime(%DateTime{} = date), do: DateTime.to_naive(date)
  defp to_naive_datetime(%Date{} = date), do: Timex.NaiveDateTime.new!(date, ~T[12:00:00])
end