lib/timezone/database.ex

defmodule Timex.Timezone.Database do
  @behaviour Calendar.TimeZoneDatabase

  alias Timex.Timezone
  alias Timex.TimezoneInfo

  @impl true
  @doc false
  def time_zone_period_from_utc_iso_days(iso_days, time_zone) do
    db = Tzdata.TimeZoneDatabase

    case db.time_zone_period_from_utc_iso_days(iso_days, time_zone) do
      {:error, :time_zone_not_found} ->
        # Get a NaiveDateTime for time_zone_periods_from_wall_datetime
        {year, month, day, hour, minute, second, microsecond} =
          Calendar.ISO.naive_datetime_from_iso_days(iso_days)

        with {:ok, naive} <-
               NaiveDateTime.new(year, month, day, hour, minute, second, microsecond) do
          time_zone_periods_from_wall_datetime(naive, time_zone)
        else
          {:error, _} ->
            {:error, :time_zone_not_found}
        end

      result ->
        result
    end
  end

  @impl true
  @doc false
  def time_zone_periods_from_wall_datetime(naive, time_zone) do
    db = Tzdata.TimeZoneDatabase

    if Tzdata.zone_exists?(time_zone) do
      case db.time_zone_periods_from_wall_datetime(naive, time_zone) do
        {:error, :time_zone_not_found} ->
          time_zone_periods_from_wall_datetime_fallback(naive, time_zone)

        result ->
          result
      end
    else
      time_zone_periods_from_wall_datetime_fallback(naive, time_zone)
    end
  end

  # Fallback method which looks for a desired timezone in the process state
  defp time_zone_periods_from_wall_datetime_fallback(naive, time_zone) do
    # Try to pop the time zone from process state, validate the desired datetime falls
    # within the bounds of the time zone, and return its period description if so
    case Process.put(__MODULE__, nil) do
      %TimezoneInfo{from: from, until: until} = tz ->
        with {:ok, range_start} <- period_boundary_to_naive(from),
             {:ok, range_end} <- period_boundary_to_naive(until) do
          cond do
            range_start == :min and range_end == :max ->
              {:ok, TimezoneInfo.to_period(tz)}

            range_start == :min and NaiveDateTime.compare(naive, range_end) in [:lt, :eq] ->
              {:ok, TimezoneInfo.to_period(tz)}

            range_end == :max and NaiveDateTime.compare(naive, range_start) in [:gt, :eq] ->
              {:ok, TimezoneInfo.to_period(tz)}

            range_start != :min and range_end != :max and
              NaiveDateTime.compare(naive, range_start) in [:gt, :eq] and
                NaiveDateTime.compare(naive, range_end) in [:lt, :eq] ->
              {:ok, TimezoneInfo.to_period(tz)}

            :else ->
              {:error, :time_zone_not_found}
          end
        else
          {:error, _} ->
            {:error, :time_zone_not_found}
        end

      nil ->
        time_zone_periods_from_wall_datetime_by_name(naive, time_zone)
    end
  end

  # Fallback method which attempts to lookup the timezone by name
  defp time_zone_periods_from_wall_datetime_by_name(naive, time_zone) do
    with %TimezoneInfo{} = tz <- Timezone.get(time_zone, naive) do
      {:ok, TimezoneInfo.to_period(tz)}
    end
  end

  defp period_boundary_to_naive(:min), do: {:ok, :min}
  defp period_boundary_to_naive(:max), do: {:ok, :max}

  defp period_boundary_to_naive({_, {{y, m, d}, {hh, mm, ss}}}) do
    NaiveDateTime.new(y, m, d, hh, mm, ss)
  end

  defp period_boundary_to_naive(_), do: {:error, :invalid_period}
end