# encoding: UTF-8
# frozen_string_literal: true

require 'strscan'

module TZInfo
  # An {InvalidPosixTimeZone} exception is raised if an invalid POSIX-style
  # time zone string is encountered.
  #
  # @private
  class InvalidPosixTimeZone < StandardError #:nodoc:
  end

  # A parser for POSIX-style TZ strings used in zoneinfo files and specified
  # by tzfile.5 and tzset.3.
  #
  # @private
  class PosixTimeZoneParser #:nodoc:
    # Parses a POSIX-style TZ string, returning either a TimezoneOffset or
    # an AnnualRules instance.
    def parse(tz_string)
      raise InvalidPosixTimeZone unless tz_string.kind_of?(String)
      return nil if tz_string.empty?

      s = StringScanner.new(tz_string)
      check_scan(s, /([^-+,\d<][^-+,\d]*) | <([^>]+)>/x)
      std_abbrev = s[1] || s[2]
      check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
      std_offset = get_offset_from_hms(s[1], s[2], s[3])

      if s.scan(/([^-+,\d<][^-+,\d]*) | <([^>]+)>/x)
        dst_abbrev = s[1] || s[2]

        if s.scan(/([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
          dst_offset = get_offset_from_hms(s[1], s[2], s[3])
        else
          # POSIX is negative for ahead of UTC.
          dst_offset = std_offset - 3600
        end

        dst_difference = std_offset - dst_offset

        start_rule = parse_rule(s, 'start')
        end_rule = parse_rule(s, 'end')

        raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." if s.rest?

        if start_rule.is_always_first_day_of_year? && start_rule.transition_at == 0 &&
              end_rule.is_always_last_day_of_year? && end_rule.transition_at == 86400 + dst_difference
          # Constant daylight savings time.
          # POSIX is negative for ahead of UTC.
          TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev.to_sym)
        else
          AnnualRules.new(
            TimezoneOffset.new(-std_offset, 0, std_abbrev.to_sym),
            TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev.to_sym),
            start_rule,
            end_rule)
        end
      elsif !s.rest?
        # Constant standard time.
        # POSIX is negative for ahead of UTC.
        TimezoneOffset.new(-std_offset, 0, std_abbrev.to_sym)
      else
        raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'."
      end
    end

    private

    # Parses the rule from the TZ string, returning a TransitionRule.
    def parse_rule(s, type)
      check_scan(s, /,(?: (?: J(\d+) ) | (\d+) | (?: M(\d+)\.(\d)\.(\d) ) )/x)
      julian_day_of_year = s[1]
      absolute_day_of_year = s[2]
      month = s[3]
      week = s[4]
      day_of_week = s[5]

      if s.scan(/\//)
        check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
        transition_at = get_seconds_after_midnight_from_hms(s[1], s[2], s[3])
      else
        transition_at = 7200
      end

      begin
        if julian_day_of_year
          JulianDayOfYearTransitionRule.new(julian_day_of_year.to_i, transition_at)
        elsif absolute_day_of_year
          AbsoluteDayOfYearTransitionRule.new(absolute_day_of_year.to_i, transition_at)
        elsif week == '5'
          LastDayOfMonthTransitionRule.new(month.to_i, day_of_week.to_i, transition_at)
        else
          DayOfMonthTransitionRule.new(month.to_i, week.to_i, day_of_week.to_i, transition_at)
        end
      rescue ArgumentError => e
        raise InvalidPosixTimeZone, "Invalid #{type} rule in POSIX-style time zone string: #{e}"
      end
    end

    # Returns an offset in seconds from hh:mm:ss values. The value can be
    # negative. -02:33:12 would represent 2 hours, 33 minutes and 12 seconds
    # ahead of UTC.
    def get_offset_from_hms(h, m, s)
      h = h.to_i
      m = m.to_i
      s = s.to_i
      raise InvalidPosixTimeZone, "Invalid minute #{m} in offset for POSIX-style time zone string." if m > 59
      raise InvalidPosixTimeZone, "Invalid second #{s} in offset for POSIX-style time zone string." if s > 59
      magnitude = (h.abs * 60 + m) * 60 + s
      h < 0 ? -magnitude : magnitude
    end

    # Returns the seconds from midnight from hh:mm:ss values. Hours can exceed
    # 24 for a time on the following day. Hours can be negative to subtract
    # hours from midnight on the given day. -02:33:12 represents 22:33:12 on
    # the prior day.
    def get_seconds_after_midnight_from_hms(h, m, s)
      h = h.to_i
      m = m.to_i
      s = s.to_i
      raise InvalidPosixTimeZone, "Invalid minute #{m} in time for POSIX-style time zone string." if m > 59
      raise InvalidPosixTimeZone, "Invalid second #{s} in time for POSIX-style time zone string." if s > 59
      (h * 3600) + m * 60 + s
    end

    # Scans for a pattern and raises an exception if the pattern does not
    # match the input.
    def check_scan(s, pattern)
      result = s.scan(pattern)
      raise InvalidPosixTimeZone, "Expected '#{s.rest}' to match #{pattern} in POSIX-style time zone string." unless result
      result
    end
  end
end
