defmodule Tz.Compiler do
  @moduledoc false

  require Logger
  require Tz.IanaFileParser
  require Tz.PeriodsBuilder

  alias Tz.IanaDataDir
  alias Tz.IanaFileParser
  alias Tz.PeriodsBuilder

  def compile() do
    {tzdata_dir_path, tzdata_version} = tzdata_dir_and_version()

    validate_tzdata_dir_has_required_files(tzdata_dir_path)

    {periods_and_links, ongoing_rules} =
      for filename <-
            ~w(africa antarctica asia australasia backward etcetera europe northamerica southamerica)s do
        {zone_records, rule_records, link_records, ongoing_rules} =
          IanaFileParser.parse(Path.join(tzdata_dir_path, filename))

        Enum.each(rule_records, fn {rule_name, rules} ->
          ongoing_rules_count = Enum.count(rules, &(&1.to == :max))

          if ongoing_rules_count > 2 do
            raise "unexpected case: #{ongoing_rules_count} ongoing rules for rule \"#{rule_name}\""
          end
        end)

        periods =
          for {zone_name, zone_lines} <- zone_records do
            periods =
              PeriodsBuilder.build_periods(zone_lines, rule_records)
              |> PeriodsBuilder.periods_to_tuples_and_reverse()
              |> reject_periods_before_year()

            if length(periods) == 0 do
              raise "no periods for time zone #{zone_name}"
            end

            {:periods, zone_name, periods}
          end

        links =
          for link <- link_records do
            {:link, link.link_zone_name, link.canonical_zone_name}
          end

        {periods ++ links, ongoing_rules}
      end
      |> Enum.reduce(
        {[], %{}},
        fn {periods_and_links, ongoing_rules}, {all_periods_and_links, all_ongoing_rules} ->
          {periods_and_links ++ all_periods_and_links,
           Map.merge(ongoing_rules, all_ongoing_rules)}
        end
      )

    compile_periods(periods_and_links, tzdata_version)

    compile_map_ongoing_changing_rules(ongoing_rules)
  end

  defp tzdata_dir_and_version() do
    IanaDataDir.maybe_copy_iana_files_to_custom_dir()

    cond do
      tzdata_dir_path = IanaDataDir.relevant_tzdata_dir_path() ->
        "tzdata" <> tzdata_version = Path.basename(tzdata_dir_path)

        {tzdata_dir_path, tzdata_version}

      IanaDataDir.forced_iana_version() == nil ->
        raise "tzdata files not found"

      tzdata_dir_path = IanaDataDir.latest_tzdata_dir_path() ->
        "tzdata" <> installed_iana_version = Path.basename(tzdata_dir_path)

        forced_iana_version = IanaDataDir.forced_iana_version()

        raise(
          "Missing tzdata#{forced_iana_version} files.\n" <>
            "1. Temporarily remove the :iana_version config\n" <>
            "2. Download version #{forced_iana_version} " <>
            "by running: mix tz.download #{forced_iana_version}\n" <>
            "3. Restore the :iana_version config\n" <>
            "4. Recompile the time zone periods " <>
            "by running: mix deps.compile tz --force\n" <>
            "5. Make sure the periods are compiled with tzdata#{forced_iana_version} " <>
            "by running: Tz.iana_version()"
        )

        {tzdata_dir_path, installed_iana_version}

      true ->
        raise "tzdata files not found"
    end
  end

  defp reject_periods_before_year(periods) do
    case Application.get_env(:tz, :reject_periods_before_year) do
      nil ->
        periods

      reject_before_year ->
        filtered_periods =
          Enum.reject(periods, fn {secs, _, _, _} ->
            %{year: year} = gregorian_seconds_to_naive_datetime(secs)
            year < reject_before_year
          end)

        if length(filtered_periods) > 0 do
          filtered_periods
        else
          periods
        end
    end
  end

  defp gregorian_seconds_to_naive_datetime(seconds) do
    :calendar.gregorian_seconds_to_datetime(seconds)
    |> NaiveDateTime.from_erl!()
  end

  def compile_periods(periods_and_links, tzdata_version) do
    quoted = [
      quote do
        @moduledoc false

        def iana_version() do
          unquote(tzdata_version)
        end

        def compiled_at() do
          unquote(Macro.escape(DateTime.utc_now()))
        end

        def next_period(%DateTime{} = datetime) do
          {gregorian_secs, _} = DateTime.to_gregorian_seconds(datetime)

          {:ok, periods} = periods(datetime.time_zone)
          reversed_periods = Enum.reverse(periods)

          period = Enum.find(reversed_periods, fn {from, _, _, _} -> gregorian_secs < from end)

          if period do
            period
          else
            case hd(periods) do
              {_, _, _, nil} ->
                nil

              {utc_secs, {utc_to_std_offset, _, _}, _, rules_and_template} ->
                periods =
                  Tz.TimeZoneDatabase.generate_dynamic_periods(
                    utc_secs,
                    utc_to_std_offset,
                    rules_and_template
                  )

                reversed_periods = Enum.reverse(periods)

                Enum.find(reversed_periods, fn {from, _, _, _} -> gregorian_secs < from end)
            end
          end
        end
      end,
      for period_or_link <- periods_and_links do
        case period_or_link do
          {:link, link_zone_name, canonical_zone_name} ->
            quote do
              def periods(unquote(link_zone_name)) do
                periods(unquote(Macro.escape(canonical_zone_name)))
              end
            end

          {:periods, zone_name, periods} ->
            quote do
              def periods(unquote(zone_name)) do
                {:ok, unquote(Macro.escape(periods))}
              end
            end
        end
      end,
      quote do
        def periods(_) do
          {:error, :time_zone_not_found}
        end
      end
    ]

    module = :"Elixir.Tz.PeriodsProvider"
    Module.create(module, quoted, Macro.Env.location(__ENV__))
    :code.purge(module)
  end

  defp compile_map_ongoing_changing_rules(ongoing_rules) do
    quoted = [
      quote do
        @moduledoc false
      end,
      for {rule_name, rules} <- ongoing_rules do
        quote do
          def rules(unquote(rule_name)) do
            unquote(Macro.escape(rules))
          end
        end
      end
    ]

    module = :"Elixir.Tz.OngoingChangingRulesProvider"
    Module.create(module, quoted, Macro.Env.location(__ENV__))
    :code.purge(module)
  end

  defp validate_tzdata_dir_has_required_files(tzdata_dir_path) do
    required_files = [
      "africa",
      "antarctica",
      "asia",
      "australasia",
      "backward",
      "etcetera",
      "europe",
      "northamerica",
      "southamerica",
      "iso3166.tab",
      "zone1970.tab"
    ]

    missing_files = Enum.filter(required_files, &(!File.exists?(Path.join(tzdata_dir_path, &1))))

    if length(missing_files) > 0 do
      raise "files #{inspect(missing_files)} are missing in #{tzdata_dir_path}"
    end
  end
end
