defmodule Tz.PeriodsBuilder do
  @moduledoc false

  def build_periods(
        zone_lines,
        rule_records,
        mode \\ :compilation,
        prev_period \\ nil,
        periods \\ []
      )

  def build_periods([], _rule_records, _mode, _prev_period, periods), do: Enum.reverse(periods)

  def build_periods([zone_line | rest_zone_lines], rule_records, mode, prev_period, periods) do
    rules = Map.get(rule_records, zone_line.rules, zone_line.rules)

    periods =
      build_periods_for_zone_line(zone_line, rules, mode, prev_period)
      |> concat_dedup_periods(periods)

    build_periods(rest_zone_lines, rule_records, mode, hd(periods), periods)
  end

  defp concat_dedup_periods(periods, []), do: periods

  defp concat_dedup_periods(periods1, [first_period2 | tail_period2] = periods2) do
    last_period1 = List.last(periods1)
    compare_keys = [:std_offset_from_utc_time, :local_offset_from_std_time, :zone_abbr]

    cond do
      Map.take(last_period1, compare_keys) == Map.take(first_period2, compare_keys) ->
        (periods1 |> Enum.reverse() |> tl() |> Enum.reverse()) ++
          [%{first_period2 | to: last_period1.to} | tail_period2]

      true ->
        periods1 ++ periods2
    end
  end

  defp offset_diff_from_prev_period(_zone_line, _local_offset, nil), do: 0

  defp offset_diff_from_prev_period(zone_line, local_offset, prev_period) do
    total_offset = zone_line.std_offset_from_utc_time + local_offset

    prev_total_offset =
      prev_period.std_offset_from_utc_time + prev_period.local_offset_from_std_time

    total_offset - prev_total_offset
  end

  defp build_periods_for_zone_line(zone_line, offset, _mode, prev_period)
       when is_integer(offset) do
    if zone_line.from != :min && prev_period != nil do
      {zone_from, zone_from_modifier} = zone_line.from

      if prev_period.to[zone_from_modifier] != zone_from do
        raise "logic error"
      end
    end

    offset_diff = offset_diff_from_prev_period(zone_line, offset, prev_period)

    period_from =
      if zone_line.from == :min do
        :min
      else
        add_to_and_convert_date_tuple(
          {prev_period.to.wall, :wall},
          offset_diff,
          zone_line.std_offset_from_utc_time,
          offset
        )
      end

    [
      %{
        from: period_from,
        to: convert_date_tuple(zone_line.to, zone_line.std_offset_from_utc_time, offset),
        std_offset_from_utc_time: zone_line.std_offset_from_utc_time,
        local_offset_from_std_time: offset,
        zone_abbr: zone_abbr(zone_line, offset)
      }
    ]
  end

  defp build_periods_for_zone_line(zone_line, rules, mode, prev_period) when is_list(rules) do
    if zone_line.from != :min && prev_period != nil do
      {zone_from, zone_from_modifier} = zone_line.from

      if prev_period.to[zone_from_modifier] != zone_from do
        raise "logic error"
      end
    end

    if mode == :dynamic_far_future do
      rules
    else
      rules
      |> filter_rules_for_zone_line(
        zone_line,
        prev_period,
        if(prev_period == nil, do: 0, else: prev_period.local_offset_from_std_time)
      )
      |> maybe_pad_left_rule(zone_line, prev_period)
      |> trim_zone_rules(zone_line, prev_period)
    end
    |> do_build_periods_for_zone_line(zone_line, prev_period, [])
  end

  defp filter_rules_for_zone_line(
         rules,
         zone_line,
         prev_period,
         prev_local_offset_from_std_time,
         filtered_rules \\ []
       )

  defp filter_rules_for_zone_line(rules, %{from: :min, to: :max}, _, _, _), do: rules

  defp filter_rules_for_zone_line([], _zone_line, _, _, filtered_rules),
    do: Enum.reverse(filtered_rules)

  defp filter_rules_for_zone_line(
         [rule | rest_rules],
         zone_line,
         prev_period,
         prev_local_offset_from_std_time,
         filtered_rules
       ) do
    is_rule_included =
      cond do
        zone_line.to == :max && rule.to == :max ->
          true

        zone_line.to == :max ->
          {rule_to, rule_to_modifier} = rule.to

          prev_period == nil ||
            NaiveDateTime.compare(prev_period.to[rule_to_modifier], rule_to) == :lt

        zone_line.from == :min || rule.to == :max ->
          {zone_to, zone_to_modifier} = zone_line.to

          rule_from =
            convert_date_tuple(
              rule.from,
              prev_period.std_offset_from_utc_time,
              prev_local_offset_from_std_time
            )

          NaiveDateTime.compare(zone_to, rule_from[zone_to_modifier]) == :gt

        true ->
          {zone_to, zone_to_modifier} = zone_line.to
          {rule_to, rule_to_modifier} = rule.to

          rule_from =
            convert_date_tuple(
              rule.from,
              prev_period.std_offset_from_utc_time,
              prev_local_offset_from_std_time
            )

          NaiveDateTime.compare(prev_period.to[rule_to_modifier], rule_to) == :lt &&
            NaiveDateTime.compare(zone_to, rule_from[zone_to_modifier]) == :gt
      end

    if is_rule_included do
      filter_rules_for_zone_line(
        rest_rules,
        zone_line,
        prev_period,
        rule.local_offset_from_std_time,
        [rule | filtered_rules]
      )
    else
      filter_rules_for_zone_line(
        rest_rules,
        zone_line,
        prev_period,
        prev_local_offset_from_std_time,
        filtered_rules
      )
    end
  end

  defp trim_zone_rules([], _zone_line, _), do: []

  defp trim_zone_rules([first_rule | tail_rules] = rules, zone_line, prev_period) do
    rules =
      if rule_starts_before_zone_line_range?(
           zone_line,
           first_rule,
           if(prev_period == nil, do: 0, else: prev_period.local_offset_from_std_time)
         ) do
        [%{first_rule | from: zone_line.from} | tail_rules]
      else
        rules
      end

    last_rule = List.last(rules)

    if rule_ends_after_zone_line_range?(zone_line, last_rule) do
      [%{last_rule | to: zone_line.to} | Enum.reverse(rules) |> tl()]
      |> Enum.reverse()
    else
      rules
    end
  end

  defp rule_starts_before_zone_line_range?(%{from: :min}, _rule, _), do: false

  defp rule_starts_before_zone_line_range?(zone_line, rule, prev_local_offset_from_std_time) do
    rule_from =
      convert_date_tuple(
        rule.from,
        zone_line.std_offset_from_utc_time,
        prev_local_offset_from_std_time
      )

    %{from: {zone_from, zone_from_modifier}} = zone_line
    NaiveDateTime.compare(rule_from[zone_from_modifier], zone_from) == :lt
  end

  defp rule_ends_after_zone_line_range?(%{to: :max}, _rule), do: false
  defp rule_ends_after_zone_line_range?(_zone_line, %{to: :max}), do: true

  defp rule_ends_after_zone_line_range?(zone_line, rule) do
    rule_to =
      convert_date_tuple(
        rule.to,
        zone_line.std_offset_from_utc_time,
        rule.local_offset_from_std_time
      )

    %{to: {zone_to, zone_to_modifier}} = zone_line
    NaiveDateTime.compare(rule_to[zone_to_modifier], zone_to) == :gt
  end

  defp maybe_pad_left_rule([], _zone_line, _), do: []

  defp maybe_pad_left_rule([first_rule | _] = rules, %{from: :min}, _) do
    rule = %{
      record_type: :rule,
      from: :min,
      name: "",
      local_offset_from_std_time: 0,
      letter: Enum.find(rules, &(&1.local_offset_from_std_time == 0)).letter,
      to: first_rule.from
    }

    [rule | rules]
  end

  defp maybe_pad_left_rule(rules, _zone_line, nil), do: rules

  defp maybe_pad_left_rule([first_rule | _] = rules, zone_line, prev_period) do
    {rule_from, rule_from_modifier} = first_rule.from

    if NaiveDateTime.compare(prev_period.to[rule_from_modifier], rule_from) == :lt do
      # find first rule with local offset to 0
      letter =
        case Enum.find(rules, &(&1.local_offset_from_std_time == 0)) do
          %{letter: letter} -> letter
          _ -> ""
        end

      rule = %{
        record_type: :rule,
        from: zone_line.from,
        name: first_rule.name,
        local_offset_from_std_time: 0,
        letter: letter,
        to: first_rule.from
      }

      [rule | rules]
    else
      rules
    end
  end

  defp do_build_periods_for_zone_line([], _zone_line, _prev_period, periods), do: periods

  defp do_build_periods_for_zone_line([rule | rest_rules], zone_line, prev_period, periods) do
    offset_diff =
      offset_diff_from_prev_period(zone_line, rule.local_offset_from_std_time, prev_period)

    period_from =
      case prev_period do
        nil ->
          convert_date_tuple(zone_line.from, zone_line.std_offset_from_utc_time, 0)

        %{to: :max} ->
          convert_date_tuple(
            rule.from,
            zone_line.std_offset_from_utc_time,
            prev_period.local_offset_from_std_time
          )

        _ ->
          add_to_and_convert_date_tuple(
            {prev_period.to.wall, :wall},
            offset_diff,
            zone_line.std_offset_from_utc_time,
            rule.local_offset_from_std_time
          )
      end

    period_to =
      convert_date_tuple(
        rule.to,
        zone_line.std_offset_from_utc_time,
        rule.local_offset_from_std_time
      )

    if period_from != :min && period_to != :max &&
         period_from.utc_gregorian_seconds == period_to.utc_gregorian_seconds do
      raise "logic error"
    end

    period = %{
      from: period_from,
      to: period_to,
      std_offset_from_utc_time: zone_line.std_offset_from_utc_time,
      local_offset_from_std_time: rule.local_offset_from_std_time,
      zone_abbr: zone_abbr(zone_line, rule.local_offset_from_std_time, rule.letter),
      rules_and_template:
        if(period_to == :max && prev_period && prev_period.to == :max) do
          {zone_line.rules, zone_line.format_time_zone_abbr}
        end
    }

    periods = concat_dedup_periods([period], periods)

    do_build_periods_for_zone_line(rest_rules, zone_line, period, periods)
  end

  defp zone_abbr(zone_line, offset, letter \\ "") do
    is_standard_time = offset == 0

    zone_abbr =
      cond do
        String.contains?(zone_line.format_time_zone_abbr, "/") ->
          [zone_abbr_std_time, zone_abbr_dst_time] =
            String.split(zone_line.format_time_zone_abbr, "/")

          if(is_standard_time, do: zone_abbr_std_time, else: zone_abbr_dst_time)

        String.contains?(zone_line.format_time_zone_abbr, "%z") ->
          total_seconds = zone_line.std_offset_from_utc_time + offset
          hours = div(total_seconds, 3600)
          minutes = rem(abs(total_seconds), 3600) |> div(60)

          sign = if hours >= 0, do: "+", else: "-"

          if minutes > 0 do
            "#{sign}#{abs(hours) |> to_string() |> String.pad_leading(2, "0")}#{abs(minutes) |> to_string() |> String.pad_leading(2, "0")}"
          else
            "#{sign}#{abs(hours) |> to_string() |> String.pad_leading(2, "0")}"
          end

        String.contains?(zone_line.format_time_zone_abbr, "%s") ->
          String.replace(zone_line.format_time_zone_abbr, "%s", letter)

        true ->
          zone_line.format_time_zone_abbr
      end

    unless String.length(zone_abbr) >= 3 and Regex.match?(~r/^[A-Za-z0-9\+\-]+$/, zone_abbr) do
      raise "invalid time zone abbreviation #{zone_abbr} for #{zone_line.name}"
    end

    zone_abbr
  end

  defp add_to_and_convert_date_tuple(
         {date, time_modifier},
         add_seconds,
         std_offset_from_utc_time,
         local_offset_from_std_time
       ) do
    date = NaiveDateTime.add(date, add_seconds, :second)

    convert_date_tuple(
      {date, time_modifier},
      std_offset_from_utc_time,
      local_offset_from_std_time
    )
  end

  defp convert_date_tuple(:min, _, _), do: :min
  defp convert_date_tuple(:max, _, _), do: :max

  defp convert_date_tuple(
         {date, time_modifier},
         std_offset_from_utc_time,
         local_offset_from_std_time
       ) do
    utc =
      convert_date(
        date,
        std_offset_from_utc_time,
        local_offset_from_std_time,
        time_modifier,
        :utc
      )

    %{
      utc: utc,
      wall:
        convert_date(
          date,
          std_offset_from_utc_time,
          local_offset_from_std_time,
          time_modifier,
          :wall
        ),
      standard:
        convert_date(
          date,
          std_offset_from_utc_time,
          local_offset_from_std_time,
          time_modifier,
          :standard
        )
    }
    |> Map.put(:utc_gregorian_seconds, naive_datetime_to_gregorian_seconds(utc))
  end

  def periods_to_tuples_and_reverse(periods, periods_as_tuples \\ [], prev_period \\ nil)

  def periods_to_tuples_and_reverse([], periods_as_tuples, _), do: periods_as_tuples

  def periods_to_tuples_and_reverse([period | tail], periods_as_tuples, prev_period) do
    period = {
      if(period.from == :min, do: 0, else: period.from.utc_gregorian_seconds),
      {
        period.std_offset_from_utc_time,
        period.local_offset_from_std_time,
        period.zone_abbr
      },
      prev_period && elem(prev_period, 1),
      period[:rules_and_template]
    }

    periods_to_tuples_and_reverse(tail, [period | periods_as_tuples], period)
  end

  defp convert_date(ndt, _, _, modifier, modifier), do: ndt

  defp convert_date(
         ndt,
         standard_offset_from_utc_time,
         local_offset_from_standard_time,
         :wall,
         :utc
       ) do
    NaiveDateTime.add(
      ndt,
      -1 * (standard_offset_from_utc_time + local_offset_from_standard_time),
      :second
    )
  end

  defp convert_date(
         ndt,
         _standard_offset_from_utc_time,
         local_offset_from_standard_time,
         :wall,
         :standard
       ) do
    NaiveDateTime.add(ndt, -1 * local_offset_from_standard_time, :second)
  end

  defp convert_date(
         ndt,
         standard_offset_from_utc_time,
         local_offset_from_standard_time,
         :utc,
         :wall
       ) do
    NaiveDateTime.add(
      ndt,
      standard_offset_from_utc_time + local_offset_from_standard_time,
      :second
    )
  end

  defp convert_date(
         ndt,
         standard_offset_from_utc_time,
         _local_offset_from_standard_time,
         :utc,
         :standard
       ) do
    NaiveDateTime.add(ndt, standard_offset_from_utc_time, :second)
  end

  defp convert_date(
         ndt,
         standard_offset_from_utc_time,
         _local_offset_from_standard_time,
         :standard,
         :utc
       ) do
    NaiveDateTime.add(ndt, -1 * standard_offset_from_utc_time, :second)
  end

  defp convert_date(
         ndt,
         _standard_offset_from_utc_time,
         local_offset_from_standard_time,
         :standard,
         :wall
       ) do
    NaiveDateTime.add(ndt, local_offset_from_standard_time, :second)
  end

  defp naive_datetime_to_gregorian_seconds(datetime) do
    NaiveDateTime.to_erl(datetime)
    |> :calendar.datetime_to_gregorian_seconds()
  end
end
