lib/zonex/meta_zones.ex

defmodule Zonex.MetaZones do
  @moduledoc """
  Meta zone data.
  """

  use GenServer
  import SweetXml
  alias Zonex.MetaZones.Rule

  @type meta_zone_name :: String.t()
  @type territory :: String.t()

  @doc """
  Lists the rules for a given time zone.
  """
  @spec rules_for_zone(zone_name :: Calendar.time_zone()) :: [Rule.t()]
  def rules_for_zone("Etc/UTC") do
    [
      %Rule{
        from: beginning_of_time(),
        to: end_of_time(),
        mzone: "GMT"
      }
    ]
  end

  def rules_for_zone(zone_name) do
    rules()[zone_name] || []
  end

  @doc """
  Resolves the correct meta zone name at a particular instant.
  """
  @spec resolve(rules :: [Rule.t()], instant :: DateTime.t()) ::
          {:ok, meta_zone_name()} | {:error, term()}
  def resolve([_ | _] = rules, %DateTime{} = instant) do
    rules
    |> Enum.find(&between?(&1, instant))
    |> then(fn
      %Rule{mzone: mzone} -> {:ok, mzone}
      _ -> {:error, :meta_zone_not_found}
    end)
  end

  def resolve(_, _), do: {:error, :meta_zone_not_found}

  @doc """
  Gets the territories for a time zone (and its current meta zone).
  """
  @spec territories(zone_name :: Calendar.time_zone(), mzone :: meta_zone_name()) :: [territory()]
  def territories(zone_name, mzone) do
    values = territories_map()[zone_name] || []

    Enum.reduce(values, [], fn value, acc ->
      if value.mzone == mzone do
        [value.territory | acc]
      else
        acc
      end
    end)
  end

  # Client

  @doc """
  Starts the process.
  """
  def start_link([]) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  # Server

  @impl GenServer
  def init(_arg) do
    path = Application.app_dir(:zonex, "priv/metaZones.xml")
    contents = File.read!(path)
    {:ok, %{rules: parse_rules(contents), territories: parse_territories(contents)}}
  end

  @impl GenServer
  def handle_call(:rules, _from, state) do
    {:reply, state[:rules], state}
  end

  @impl GenServer
  def handle_call(:territories, _from, state) do
    {:reply, state[:territories], state}
  end

  # Private helpers

  defp rules do
    GenServer.call(__MODULE__, :rules)
  end

  defp territories_map do
    GenServer.call(__MODULE__, :territories)
  end

  defp parse_rules(xml) do
    xml
    |> parse_xml()
    |> xpath(~x"//supplementalData/metaZones/metazoneInfo/timezone"el,
      name: ~x"./@type"s,
      rules: [
        ~x"./usesMetazone"l,
        mzone: ~x"./@mzone"s,
        from: ~x"./@from"s |> transform_by(&parse_date/1),
        to: ~x"./@to"s |> transform_by(&parse_date/1)
      ]
    )
    |> Enum.map(&{&1[:name], parse_rule_list(&1[:rules])})
    |> Map.new()
  end

  defp parse_territories(xml) do
    xml
    |> parse_xml()
    |> xpath(~x"//supplementalData/metaZones/mapTimezones/mapZone"el,
      zone_name: ~x"./@type"s,
      territory: ~x"./@territory"s,
      mzone: ~x"./@other"s
    )
    |> Enum.reduce(%{}, fn %{zone_name: zone_name, territory: territory, mzone: mzone}, acc ->
      territories = Map.get(acc, zone_name, [])
      Map.put(acc, zone_name, [%{mzone: mzone, territory: territory} | territories])
    end)
  end

  defp parse_xml(xml) do
    {:ok, root} = Saxmerl.parse_string(xml, dynamic_atoms: true)
    root
  end

  defp parse_date(""), do: nil

  defp parse_date(value) when is_binary(value) do
    case DateTime.from_iso8601("#{value}:00Z") do
      {:ok, datetime, _} -> datetime
      err -> err
    end
  end

  defp parse_date(_), do: nil

  defp parse_rule_list(data) do
    Enum.map(data, &parse_rule/1)
  end

  defp parse_rule(data) do
    %Rule{
      from: data[:from] || beginning_of_time(),
      to: data[:to] || end_of_time(),
      mzone: data[:mzone]
    }
  end

  defp beginning_of_time do
    ~U[0000-01-01 00:00:00Z]
  end

  defp end_of_time do
    ~U[9999-01-01 00:00:00Z]
  end

  defp between?(rule, instant) do
    DateTime.compare(instant, rule.from) in [:gt, :eq] and
      DateTime.compare(instant, rule.to) in [:lt]
  end
end