require 'date'
require 'fileutils'

module TZInfo
  module Data

    # Utility methods used by TZDataParser and associated classes.
    #
    # @private
    module TZDataParserUtils #:nodoc:
      # Wrapper for File.open that supports passing hash options for specifying
      # encodings on Ruby 1.9+. The options are ignored on earlier versions of
      # Ruby.
      if RUBY_VERSION =~ /\A1\.[0-8]\./
        def open_file(file_name, mode, opts, &block)
          File.open(file_name, mode, &block)
        end
      elsif RUBY_VERSION =~ /\A1\.9\./
        def open_file(file_name, mode, opts, &block)
          File.open(file_name, mode, opts, &block)
        end
      else
        # Evaluate method as a string because **opts isn't valid syntax prior to
        # Ruby 2.0.
        eval(<<-EOF
          def open_file(file_name, mode, opts, &block)
            File.open(file_name, mode, **opts, &block)
          end
        EOF
        )
      end

      private :open_file

      private

        # Parses a month specified in the tz data and converts it to a number
        # between 1 and 12 representing January to December.
        def parse_month(month)
          lower = month.downcase
          if lower =~ /^jan/
            @month = 1
          elsif lower =~ /^feb/
            @month = 2
          elsif lower =~ /^mar/
            @month = 3
          elsif lower =~ /^apr/
            @month = 4
          elsif lower =~ /^may/
            @month = 5
          elsif lower =~ /^jun/
            @month = 6
          elsif lower =~ /^jul/
            @month = 7
          elsif lower =~ /^aug/
            @month = 8
          elsif lower =~ /^sep/
            @month = 9
          elsif lower =~ /^oct/
            @month = 10
          elsif lower =~ /^nov/
            @month = 11
          elsif lower =~ /^dec/
            @month = 12
          else
            raise "Invalid month: #{month}"
          end
        end

        # Parses an offset string [-]h:m:s (minutes and seconds are optional). Returns
        # the offset in seconds.
        def parse_offset(offset)
          raise "Invalid time: #{offset}" if offset !~ /^(-)?(?:([0-9]+)(?::([0-9]+)(?::([0-9]+))?)?)?$/

          negative = !$1.nil?
          hour = $2.nil? ? 0 : $2.to_i
          minute = $3.nil? ? 0 : $3.to_i
          second = $4.nil? ? 0 : $4.to_i

          seconds = hour
          seconds = seconds * 60
          seconds = seconds + minute
          seconds = seconds * 60
          seconds = seconds + second
          seconds = -seconds if negative
          seconds
        end

        # Encloses the string in single quotes and escapes any single quotes in
        # the content.
        def quote_str(str)
          "'#{str.gsub('\'', '\\\\\'')}'"
        end
    end

    # Parses Time Zone Data from the IANA Time Zone Database and transforms it
    # into a set of Ruby modules that can be used with TZInfo.
    #
    # Normally, this class wouldn't be used. It is only run to update the
    # timezone data and index modules.
    class TZDataParser
      include TZDataParserUtils

      # Default earliest year that will be considered.
      DEFAULT_MIN_YEAR = 1800

      # Default number of future years data to generate (not including the
      # current year).
      DEFAULT_FUTURE_YEARS = 50

      # Earliest year that will be considered. Defaults to DEFAULT_MIN_YEAR.
      attr_accessor :min_year

      # Latest year that will be considered. Defaults to the current year plus
      # FUTURE_YEARS.
      attr_accessor :max_year

      # Whether to generate zone definitions (set to false to stop zones being
      # generated).
      attr_accessor :generate_zones

      # Whether to generate country definitions (set to false to stop countries
      # being generated).
      attr_accessor :generate_countries

      # Limit the set of zones to generate (set to an array containing zone
      # identifiers).
      attr_accessor :only_zones

      # Zones to exclude from generation when not using only_zones (set to an
      # array containing zone identifiers).
      attr_accessor :exclude_zones

      # Initializes a new TZDataParser. input_dir must contain the extracted
      # tzdata tarball. output_dir is the location to output the modules
      # (in definitions and indexes directories).
      def initialize(input_dir, output_dir)
        super()
        @input_dir = input_dir
        @output_dir = output_dir
        @min_year = DEFAULT_MIN_YEAR
        @max_year = Time.now.year + DEFAULT_FUTURE_YEARS
        @rule_sets = {}
        @zones = {}
        @countries = {}
        @no_rules = TZDataNoRules.new
        @generate_zones = true
        @generate_countries = true
        @only_zones = []
        @exclude_zones = []
      end

      # Reads the tzdata source and generates the classes. Progress information
      # is written to standard out.
      def execute
        # Note that the backzone file is ignored. backzone contains alternative
        # definitions of certain zones, primarily for pre-1970 data. It is not
        # recommended for ordinary use and the tzdata Makefile does not
        # install its entries by default.

        files = Dir.entries(@input_dir).select do |file|
          file =~ /\A[^\.]+\z/ &&
            !%w(backzone calendars leapseconds CONTRIBUTING LICENSE Makefile NEWS README SECURITY SOURCE Theory version).include?(file) &&
            File.file?(File.join(@input_dir, file))
        end

        files.each {|file| load_rules(file) }
        files.each {|file| load_zones(file) }
        files.each {|file| load_links(file) }

        load_countries

        if @generate_zones
          modules = []

          if @only_zones.nil? || @only_zones.empty?
            @zones.each_value {|zone|
              zone.write_module(@output_dir) unless @exclude_zones.include?(zone.name)
            }
          else
            @only_zones.each {|id|
              zone = @zones[id]
              zone.write_module(@output_dir)
            }
          end

          write_timezones_index
        end

        if @generate_countries
          write_countries_index
        end
      end

      private
        # Loads all the Rule definitions from the tz data and stores them in
        # the rule_sets instance variable.
        def load_rules(file)
          puts 'load_rules: ' + file

          # Files are in ASCII, but may change to UTF-8 (a superset of ASCII)
          # in the future.
          open_file(File.join(@input_dir, file), 'r', :external_encoding => 'UTF-8', :internal_encoding => 'UTF-8') do |file|
            file.each_line do |line|
              line = line.gsub(/#.*$/, '')
              line = line.gsub(/\s+$/, '')

              if line =~ /^Rule\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/

                name = $1

                if @rule_sets[name].nil?
                  @rule_sets[name] = TZDataRuleSet.new(name)
                end

                @rule_sets[name].add_rule(TZDataRule.new($2, $3, $4, $5, $6, $7, $8, $9))
              end
            end
          end
        end

        # Gets a rules object for the given reference. Might be a named rule set,
        # a fixed offset or an empty ruleset.
        def get_rules(ref)
          if ref == '-'
            @no_rules
          elsif ref =~ /^-?[0-9]+:[0-9]+$/
            TZDataFixedOffsetRules.new(parse_offset(ref))
          else
            rule_set = @rule_sets[ref]
            raise "Ruleset not found: #{ref}" if rule_set.nil?
            rule_set
          end
        end

        # Loads in the Zone definitions from the tz data and stores them in @zones.
        def load_zones(file)
          puts 'load_zones: ' + file

          in_zone = nil

          # Files are in ASCII, but may change to UTF-8 (a superset of ASCII)
          # in the future.
          open_file(File.join(@input_dir, file), 'r', :external_encoding => 'UTF-8', :internal_encoding => 'UTF-8') do |file|
            file.each_line do |line|
              line = line.gsub(/#.*$/, '')
              line = line.gsub(/\s+$/, '')

              if in_zone
                if line =~ /^\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)(\s+([0-9]+(\s+.*)?))?$/

                  in_zone.add_observance(TZDataObservance.new($1, get_rules($2), $3, $5))

                  in_zone = nil if $4.nil?
                end
              else
                if line =~ /^Zone\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)(\s+([0-9]+(\s+.*)?))?$/
                  name = $1

                  if @zones[name].nil?
                    @zones[name] = TZDataZone.new(name, @min_year..@max_year)
                  end

                  @zones[name].add_observance(TZDataObservance.new($2, get_rules($3), $4, $6))

                  in_zone = @zones[name] if !$5.nil?
                end
              end
            end
          end
        end

        # Loads in the links and stores them in @zones.
        def load_links(file)
          puts 'load_links: ' + file

          # Files are in ASCII, but may change to UTF-8 (a superset of ASCII)
          # in the future.
          open_file(File.join(@input_dir, file), 'r', :external_encoding => 'UTF-8', :internal_encoding => 'UTF-8') do |file|
            file.each_line do |line|
              line = line.gsub(/#.*$/, '')
              line = line.gsub(/\s+$/, '')

              if line =~ /^Link\s+([^\s]+)\s+([^\s]+)/
                name = $2
                link_to = @zones[$1]
                raise "Link to zone not found (#{name}->#{link_to})" if link_to.nil?
                raise "Zone already defined: #{name}" if !@zones[name].nil?
                @zones[name] = TZDataLink.new(name, link_to)
              end
            end
          end
        end

        # Loads countries from iso3166.tab and zone1970.tab and stores the
        # result in the countries instance variable.
        def load_countries
          puts 'load_countries'

          # iso3166.tab is ASCII encoded, but is planned to change to UTF-8 (a
          # superset of ASCII) in the future.
          open_file(File.join(@input_dir, 'iso3166.tab'), 'r', :external_encoding => 'UTF-8', :internal_encoding => 'UTF-8') do |file|
            file.each_line do |line|

              if line =~ /^([A-Z]{2})\t(.*)$/
                code = $1
                name = $2
                @countries[code] = TZDataCountry.new(code, name)
              end
            end
          end

          primary_zones = {}
          secondary_zones = {}

          # zone1970.tab is UTF-8 encoded.
          open_file(File.join(@input_dir, 'zone1970.tab'), 'r', :external_encoding => 'UTF-8', :internal_encoding => 'UTF-8') do |file|
            file.each_line do |line|

              line.chomp!

              if line =~ /^([A-Z]{2}(?:,[A-Z]{2})*)\t([^\t]+)\t([^\t]+)(\t(.*))?$/
                codes = $1
                location_str = $2
                zone_name = $3
                description = $5

                location = TZDataLocation.new(location_str)

                zone = @zones[zone_name]
                raise "Zone not found: #{zone_name}" if zone.nil?

                description = nil if description == ''

                country_timezone = TZDataCountryTimezone.new(zone, description, location)

                codes = codes.split(',')

                (primary_zones[codes.first] ||= []) << country_timezone

                codes[1..-1].each do |code|
                  (secondary_zones[code] ||= []) << country_timezone
                end
              end
            end
          end

          [primary_zones, secondary_zones].each do |zones|
            zones.each_pair do |code, country_timezones|
              country = @countries[code]
              raise "Country not found: #{code}" if country.nil?

              country_timezones.each do |country_timezone|
                country.add_zone(country_timezone)
              end
            end
          end
        end

        # Writes a country index file.
        def write_countries_index
          dir = File.join(@output_dir, 'indexes')
          FileUtils.mkdir_p(dir)

          open_file(File.join(dir, 'countries.rb'), 'w', :external_encoding => 'UTF-8', :universal_newline => true) do |file|
            file.puts('# encoding: UTF-8')
            file.puts('')
            file.puts('# This file contains data derived from the IANA Time Zone Database')
            file.puts('# (https://www.iana.org/time-zones).')
            file.puts('')

            file.puts('module TZInfo')
            file.puts('  module Data')
            file.puts('    module Indexes')
            file.puts('      module Countries')
            file.puts('        include CountryIndexDefinition')
            file.puts('')

            countries = @countries.values.sort {|c1,c2| c1.code <=> c2.code}
            countries.each {|country| country.write_index_record(file)}

            file.puts('      end') # end module Countries
            file.puts('    end') # end module Indexes
            file.puts('  end') # end module Data
            file.puts('end') # end module TZInfo
          end
        end

        # Writes a timezone index file.
        def write_timezones_index
          dir = File.join(@output_dir, 'indexes')
          FileUtils.mkdir_p(dir)

          open_file(File.join(dir, 'timezones.rb'), 'w', :external_encoding => 'UTF-8', :universal_newline => true) do |file|
            file.puts('# encoding: UTF-8')
            file.puts('')
            file.puts('# This file contains data derived from the IANA Time Zone Database')
            file.puts('# (https://www.iana.org/time-zones).')
            file.puts('')

            file.puts('module TZInfo')
            file.puts('  module Data')
            file.puts('    module Indexes')
            file.puts('      module Timezones')
            file.puts('        include TimezoneIndexDefinition')
            file.puts('')

            zones = @zones.values.sort {|t1,t2| t1.name <=> t2.name}
            zones.each {|zone| zone.write_index_record(file)}

            file.puts('      end') # end module Timezones
            file.puts('    end') # end module Indexes
            file.puts('  end') # end module Data
            file.puts('end') # end module TZInfo
          end

        end
    end

    # Base class for all rule sets.
    #
    # @private
    class TZDataRules #:nodoc:
      # Name of the rule set, e.g. EU.
      attr_reader :name

      def initialize(name)
        @name = name
      end

      def count
        0
      end
    end

    # Empty rule set with a fixed daylight savings (std) offset.
    #
    # @private
    class TZDataFixedOffsetRules < TZDataRules #:nodoc:
      attr_reader :offset

      def initialize(offset)
        super(offset.to_s)
        @offset = offset
      end
    end

    # An empty set of rules.
    #
    # @private
    class TZDataNoRules < TZDataRules #:nodoc:
      def initialize
        super('-')
      end
    end

    # A rule set (as defined by Rule name in the tz data).
    #
    # @private
    class TZDataRuleSet < TZDataRules #:nodoc:
      attr_reader :rules

      def initialize(name)
        super
        @rules = []
      end

      # Adds a new rule to the set.
      def add_rule(rule)
        @rules << rule
      end

      def count
        @rules.length
      end

      def each
        @rules.each {|rule| yield rule}
      end
    end

    # A rule in a RuleSet (a single Rule line in the tz data).
    #
    # @private
    class TZDataRule #:nodoc:
      include TZDataParserUtils

      attr_reader :from
      attr_reader :to
      attr_reader :type
      attr_reader :in_month
      attr_reader :on_day
      attr_reader :at_time
      attr_reader :save
      attr_reader :letter

      def initialize(from, to, type, in_month, on_day, at_time, save, letter)
        @from = parse_from(from)
        @to = parse_to(to)

        # replace a to of :only with the from year
        raise 'to cannot be only if from is minimum' if @to == :only && @from == :min
        @to = @from if @to == :only

        @type = parse_type(type)
        @in_month = parse_month(in_month)
        @on_day = TZDataDayOfMonth.new(on_day)
        @at_time = TZDataTime.new(at_time)
        @save = parse_offset(save)
        @letter = parse_letter(letter)
      end

      def activate(year)
        # The following test ignores yearistype at present (currently unused in
        # the data. parse_type currently excepts on encountering a year type.
        if (@from == :min || @from <= year) && (@to == :max || @to >= year)
          TZDataActivatedRule.new(self, year)
        else
          nil
        end
      end

      def at_utc_time(year, utc_offset, std_offset)
        absolute = @on_day.to_absolute(year, @in_month)
        @at_time.to_utc(utc_offset, std_offset,
          absolute.year, absolute.month, absolute.day)
      end

      private
        def parse_from(from)
          lower = from.downcase
          if lower =~ /^min/
            :min
          elsif lower =~ /^[0-9]+$/
            lower.to_i
          else
            raise "Invalid from: #{from}"
          end
        end

        def parse_to(to)
          lower = to.downcase
          if lower =~ /^max/
            :max
          elsif lower =~ /^o/
            :only
          elsif lower =~ /^[0-9]+$/
            lower.to_i
          else
            raise "Invalid to: #{to}"
          end
        end

        def parse_type(type)
          raise "Unsupported rule type: #{type}" if type != '-'
          nil
        end

        def parse_letter(letter)
          if letter == '-'
            nil
          else
            letter
          end
        end
    end

    # Base class for Zones and Links.
    #
    # @private
    class TZDataDefinition #:nodoc:
      include TZDataParserUtils

      attr_reader :name
      attr_reader :name_elements
      attr_reader :path_elements

      def initialize(name)
        @name = name

        # + and - aren't allowed in class names
        @name_elements = name.gsub(/-/, '__m__').gsub(/\+/, '__p__').split(/\//)
        @path_elements = @name_elements.clone
        @path_elements.pop
      end

      # Creates necessary directories, the file, writes the class header and footer
      # and yields to a block to write the content.
      def create_file(output_dir)
        dir = File.join(output_dir, 'definitions', @path_elements.join(File::SEPARATOR))
        FileUtils.mkdir_p(dir)

        open_file(File.join(output_dir, 'definitions', @name_elements.join(File::SEPARATOR)) + '.rb', 'w', :external_encoding => 'UTF-8', :universal_newline => true) do |file|

          file.instance_variable_set(:@tz_indent, 0)

          def file.indent(by)
            @tz_indent += by
          end

          def file.puts(s)
            super("#{' ' * @tz_indent}#{s}")
          end

          file.puts('# encoding: UTF-8')
          file.puts('')
          file.puts('# This file contains data derived from the IANA Time Zone Database')
          file.puts('# (https://www.iana.org/time-zones).')
          file.puts('')
          file.puts('module TZInfo')
          file.indent(2)
          file.puts('module Data')
          file.indent(2)
          file.puts('module Definitions')
          file.indent(2)

          @name_elements.each do |part|
            file.puts("module #{part}")
            file.indent(2)
          end

          file.puts('include TimezoneDefinition')
          file.puts('')

          yield file

          @name_elements.each do
            file.indent(-2)
            file.puts('end')
          end
          file.indent(-2)
          file.puts('end') # end module Definitions
          file.indent(-2)
          file.puts('end') # end module Data
          file.indent(-2)
          file.puts('end') # end module TZInfo
        end
      end
    end

    # A tz data Link.
    #
    # @private
    class TZDataLink < TZDataDefinition #:nodoc:
      include TZDataParserUtils

      attr_reader :link_to

      def initialize(name, link_to)
        super(name)
        @link_to = link_to
      end

      # Writes a module for this link.
      def write_module(output_dir)
        puts "writing link #{name}"

        create_file(output_dir) {|file|
          file.puts("linked_timezone #{quote_str(@name)}, #{quote_str(@link_to.name)}")
        }
      end

      # Writes an index record for this link.
      def write_index_record(file)
        file.puts("        linked_timezone #{quote_str(@name)}")
      end
    end

    # A tz data Zone. Each line from the tz data is loaded as a TZDataObservance.
    #
    # @private
    class TZDataZone < TZDataDefinition #:nodoc:
      include TZDataParserUtils

      attr_reader :observances

      def initialize(name, years)
        super(name)
        @years = years
        @observances = []
      end

      def add_observance(observance)
        @observances << observance
      end

      # Writes the module for the zone. Iterates all the periods and asks them
      # to write all periods in the timezone.
      def write_module(output_dir)
        puts "writing zone #{name}"

        create_file(output_dir) {|file|

          file.puts("timezone #{quote_str(@name)} do |tz|")
          file.indent(2)

          transitions = find_transitions
          transitions.output_module(file)

          file.indent(-2)
          file.puts('end')
        }
      end

      # Writes an index record for this zone.
      def write_index_record(file)
        file.puts("        timezone #{quote_str(@name)}")
      end

      private
        def find_transitions
          transitions = TZDataTransitions.new(@years)

          # algorithm from zic.c outzone

          start_time = nil
          until_time = nil


          @observances.each_with_index {|observance, i|
            std_offset = 0
            use_start = i > 0
            use_until = i < @observances.length - 1
            utc_offset = observance.utc_offset
            start_zone_id = nil
            start_utc_offset = observance.utc_offset
            start_std_offset = 0

            if observance.rule_set.count == 0
              std_offset = observance.std_offset
              start_zone_id = observance.format.expand(utc_offset, std_offset, nil)

              if use_start
                transitions << TZDataTransition.new(start_time, utc_offset, std_offset, start_zone_id)
                use_start = false
              else
                # zic algorithm only outputs this if std_offset is non-zero
                # to get the initial LMT range, we output this regardless
                transitions << TZDataTransition.new(nil, utc_offset, std_offset, start_zone_id)
              end
            else
              @years.each {|year|
                if use_until && year > observance.valid_until.year
                  break
                end

                activated_rules = []

                observance.rule_set.each {|rule|
                  activated_rule = rule.activate(year)
                  activated_rules << activated_rule unless activated_rule.nil?
                }

                while true
                  # turn until_time into UTC using the current utc_offset and std_offset
                  until_time = observance.valid_until.to_utc(utc_offset, std_offset) if use_until

                  earliest = nil

                  activated_rules.each {|activated_rule|
                    # recalculate the time using the current std_offset
                    activated_rule.calculate_time(utc_offset, std_offset)
                    earliest = activated_rule if earliest.nil? || activated_rule.at < earliest.at
                  }

                  break if earliest.nil?
                  activated_rules.delete(earliest)
                  break if use_until && earliest.at >= until_time
                  std_offset = earliest.rule.save
                  use_start = false if use_start && earliest.at == start_time

                  if use_start
                    if earliest.at < start_time
                      start_utc_offset = observance.utc_offset
                      start_std_offset = std_offset
                      start_zone_id = observance.format.expand(start_utc_offset, earliest.rule.save, earliest.rule.letter)
                      next
                    end

                    if start_zone_id.nil? && start_utc_offset + start_std_offset == observance.utc_offset + std_offset
                      start_zone_id = observance.format.expand(start_utc_offset, earliest.rule.save, earliest.rule.letter)
                    end
                  end

                  zone_id = observance.format.expand(observance.utc_offset, earliest.rule.save, earliest.rule.letter)
                  transitions << TZDataTransition.new(earliest.at, observance.utc_offset, earliest.rule.save, zone_id)
                end
              }
            end

            if use_start
              start_zone_id = observance.format.expand(nil, nil, nil) if start_zone_id.nil? && observance.format.fixed?
              raise 'Could not determine time zone abbreviation to use just after until time' if start_zone_id.nil?
              transitions << TZDataTransition.new(start_time, start_utc_offset, start_std_offset, start_zone_id)
            end

            start_time = observance.valid_until.to_utc(utc_offset, std_offset) if use_until
          }

          transitions
        end
    end

    # A observance within a zone (a line within the zone definition).
    #
    # @private
    class TZDataObservance #:nodoc:
      include TZDataParserUtils

      attr_reader :utc_offset
      attr_reader :rule_set
      attr_reader :format
      attr_reader :valid_until

      def initialize(utc_offset, rule_set, format, valid_until)
        @utc_offset = parse_offset(utc_offset)
        @rule_set = rule_set
        @format = TZDataFormat.new(format)
        @valid_until = valid_until.nil? ? nil : TZDataUntil.new(valid_until)
      end

      def std_offset
        if @rule_set.kind_of?(TZDataFixedOffsetRules)
          @rule_set.offset
        else
          0
        end
      end
    end

    # Collection of TZDataTransition instances used when building a zone class.
    #
    # @private
    class TZDataTransitions #:nodoc:
      include TZDataParserUtils

      def initialize(years)
        @years = years
        @transitions = []
      end

      def << (transition)
        @transitions << transition
      end

      def output_module(file)
        optimize

        # Try and end on a transition to std if one happens in the last year.
        if @transitions.length > 1 &&
            @transitions.last.std_offset != 0 &&
            @transitions[@transitions.length - 2].std_offset == 0 &&
            @transitions[@transitions.length - 2].at_utc.year == @years.max

          transitions = @transitions[0..@transitions.length - 2]
        else
          transitions = @transitions
        end

        process_offsets(file)
        file.puts('')

        transitions.each do |t|
          t.write(file)
        end
      end

      private
        def optimize
          @transitions.sort!

          # Optimization logic from zic.c writezone.

          from_i = 0
          to_i = 0

          while from_i < @transitions.length
            if to_i > 1 &&
                !@transitions[from_i].at_utc.nil?  &&
                !@transitions[to_i - 1].at_utc.nil? &&
                @transitions[from_i].at_utc + Rational(@transitions[to_i - 1].total_offset, 86400) <=
                @transitions[to_i - 1].at_utc + Rational(@transitions[to_i - 2].total_offset, 86400)

              @transitions[to_i - 1] = @transitions[from_i].clone_with_at(@transitions[to_i - 1].at_utc)
              from_i += 1
              next
            end

            # Shuffle transitions up, eliminating any redundant transitions
            # along the way.
            if to_i == 0 ||
                @transitions[to_i - 1].utc_offset != @transitions[from_i].utc_offset ||
                @transitions[to_i - 1].std_offset != @transitions[from_i].std_offset ||
                @transitions[to_i - 1].zone_id != @transitions[from_i].zone_id

              @transitions[to_i] = @transitions[from_i]
              to_i += 1
            end

            from_i += 1
          end

          if to_i > 0
            @transitions = @transitions[0..to_i - 1]
          else
            @transitions = []
          end
        end

        def quote_zone_id(zone_id)
          if zone_id =~ %r{[\-+']}
            ":#{quote_str(zone_id)}"
          else
            ":#{zone_id}"
          end
        end

        def process_offsets(file)
          # A bit of a hack at the moment. The offset used to be output with
          # each period (pair of transitions). They are now separated from the
          # transition data. The code should probably be changed at some point to
          # setup the offsets at an earlier stage.

          # Assume that when this is called, the first transition is the Local
          # Mean Time initial rule or a transition with no time that defines the
          # offset for the entire zone.

          offsets = []

          # Find the first std offset. Timezones always start in std.
          @transitions.each do |t|
            if t.std_offset == 0
              offset = {:utc_offset => t.utc_offset,
                :std_offset => t.std_offset,
                :zone_id => t.zone_id,
                :name => 'o0'}

              offsets << offset
              break
            end
          end

          @transitions.each do |t|
            offset = offsets.find do |o|
              o[:utc_offset] == t.utc_offset &&
                o[:std_offset] == t.std_offset &&
                o[:zone_id] == t.zone_id
            end

            unless offset
              offset = {:utc_offset => t.utc_offset,
                :std_offset => t.std_offset,
                :zone_id => t.zone_id,
                :name => "o#{offsets.length}"}

              offsets << offset
            end

            t.offset_name = offset[:name]
          end

          offsets.each do |offset|
            file.puts("tz.offset :#{offset[:name]}, #{offset[:utc_offset]}, #{offset[:std_offset]}, #{quote_zone_id(offset[:zone_id])}")
          end

        end
    end

    # A transition that will be used to write the periods in a zone class.
    #
    # @private
    class TZDataTransition #:nodoc:
      include Comparable

      EPOCH = DateTime.new(1970, 1, 1)

      attr_reader :at_utc
      attr_reader :utc_offset
      attr_reader :std_offset
      attr_reader :zone_id
      attr_accessor :offset_name

      def initialize(at_utc, utc_offset, std_offset, zone_id)
        @at_utc = at_utc
        @utc_offset = utc_offset
        @std_offset = std_offset
        @zone_id = zone_id
        @offset_name = nil
      end

      def to_s
        "At #{at_utc} UTC switch to UTC offset #{@utc_offset} with std offset #{@std_offset}, zone id #{@zone_id}"
      end

      def <=>(transition)
        if @at_utc == transition.at_utc
          0
        elsif @at_utc.nil?
          -1
        elsif transition.nil?
          1
        else
          @at_utc - transition.at_utc
        end
      end

      def total_offset
        @utc_offset + @std_offset
      end

      def clone_with_at(at_utc)
        TZDataTransition.new(at_utc, @utc_offset, @std_offset, @zone_id)
      end

      def write(file)
        if @at_utc
          file.puts "tz.transition #{@at_utc.year}, #{@at_utc.mon}, :#{@offset_name}, #{timestamp_parameters(@at_utc)}"
        end
      end

      private
        def timestamp_parameters(datetime)
          timestamp = ((datetime - EPOCH) * 86400).to_i

          if timestamp < 0 || timestamp > 2147483647
            # Output DateTime parameters as well as a timestamp for platforms
            # where Time doesn't support negative or 64-bit values.
            "#{timestamp}, #{datetime.ajd.numerator}, #{datetime.ajd.denominator}"
          else
            timestamp
          end
        end
    end

    # An instance of a rule for a year.
    #
    # @private
    class TZDataActivatedRule #:nodoc:
      attr_reader :rule
      attr_reader :year
      attr_reader :at

      def initialize(rule, year)
        @rule = rule
        @year = year
        @at = nil
      end

      def calculate_time(utc_offset, std_offset)
        @at = @rule.at_utc_time(@year, utc_offset, std_offset)
      end
    end

    # A tz data time definition - a sign (1 or -1), hour, minute, second and
    # reference (:utc, :standard or :wall_clock).
    #
    # @private
    class TZDataTime #:nodoc:
      attr_reader :sign
      attr_reader :hour
      attr_reader :minute
      attr_reader :second
      attr_reader :ref

      def initialize(spec)
        raise "Invalid time: #{spec}" if spec !~ /^(-?)([0-9]+)(:([0-9]+)(:([0-9]+))?)?([wguzs])?$/

        @sign = $1 == '-' ? -1 : 1
        @hour = $2.to_i
        @minute = $4.nil? ? 0 : $4.to_i
        @second = $6.nil? ? 0 : $6.to_i

        if $7 == 's'
          @ref = :standard
        elsif $7 == 'g' || $7 == 'u' || $7 == 'z'
          @ref = :utc
        else
          @ref = :wall_clock
        end
      end

      # Converts the time to UTC given a utc_offset and std_offset.
      def to_utc(utc_offset, std_offset, year, month, day)
        result = if @hour > 24 || @hour == 24 && (@minute > 0 || @second > 0) || @sign != 1
          DateTime.new(year, month, day, 0, 0, 0) + Rational(@sign * (@second + (@minute + @hour * 60) * 60), 86400)
        else
          DateTime.new(year, month, day, @hour, @minute, @second)
        end

        offset = 0
        offset = offset + utc_offset if @ref == :standard || @ref == :wall_clock
        offset = offset + std_offset if @ref == :wall_clock
        result - Rational(offset, 86400)
      end
    end

    # A tz data day of the month reference. Can either be an absolute day,
    # a last week day or a week day >= or <= than a specific day of month. Can
    # result in a day in the neighbouring month.
    #
    # @private
    class TZDataDayOfMonth #:nodoc:
      attr_reader :type
      attr_reader :day_of_month
      attr_reader :day_of_week
      attr_reader :operator

      def initialize(spec)
        raise "Invalid on: #{spec}" if spec !~ /^([0-9]+)|(last([A-Za-z]+))|(([A-Za-z]+)([<>]=)([0-9]+))$/

        if $1
          @type = :absolute
          @day_of_month = $1.to_i
        elsif $3
          @type = :last
          @day_of_week = parse_day_of_week($3)
        else
          @type = :comparison
          @day_of_week = parse_day_of_week($5)
          @operator = parse_operator($6)
          @day_of_month = $7.to_i
        end
      end

      # Returns the absolute date for the given year and month.
      def to_absolute(year, month)
        case @type
          when :last
            last_day_in_month = (Date.new(year, month, 1) >> 1) - 1
            offset = last_day_in_month.wday - @day_of_week
            offset = offset + 7 if offset < 0
            last_day_in_month - offset
          when :comparison
            pivot = Date.new(year, month, @day_of_month)
            offset = @day_of_week - pivot.wday
            offset = -offset if @operator == :less_equal
            offset = offset + 7 if offset < 0
            offset = -offset if @operator == :less_equal
            pivot + offset
          else #absolute
            Date.new(year, month, @day_of_month)
        end
      end

      private
        def parse_day_of_week(day_of_week)
          lower = day_of_week.downcase
          if lower =~ /^mon/
            1
          elsif lower =~ /^tue/
            2
          elsif lower =~ /^wed/
            3
          elsif lower =~ /^thu/
            4
          elsif lower =~ /^fri/
            5
          elsif lower =~ /^sat/
            6
          elsif lower =~ /^sun/
            0
          else
            raise "Invalid day of week: #{day_of_week}"
          end
        end

        def parse_operator(operator)
          if operator == '>='
            :greater_equal
          elsif operator == '<='
            :less_equal
          else
            raise "Invalid operator: #{operator}"
          end
        end
    end

    # A tz data Zone until reference.
    #
    # @private
    class TZDataUntil #:nodoc:
      include TZDataParserUtils

      attr_reader :year
      attr_reader :month
      attr_reader :day
      attr_reader :time

      def initialize(spec)
        parts = spec.split(/\s+/)
        raise "Invalid until: #{spec}" if parts.length < 1

        @year = parts[0].to_i
        @month = parts.length > 1 ? parse_month(parts[1]) : 1
        @day = TZDataDayOfMonth.new(parts.length > 2 ? parts[2] : '1')
        @time = TZDataTime.new(parts.length > 3 ? parts[3] : '00:00')
      end

      # Converts the reference to a UTC DateTime.
      def to_utc(utc_offset, std_offset)
        absolute = @day.to_absolute(@year, @month)
        @time.to_utc(utc_offset, std_offset, absolute.year, absolute.month, absolute.day)
      end
    end

    # A tz data Zone format string. Either alternate standard/daylight-savings,
    # substitution (%s) format or a fixed string.
    #
    # @private
    class TZDataFormat #:nodoc:
      def initialize(spec)
        if spec =~ /\A([^\/]*)\/(.*)\z/
          @type = :alternate
          @standard_abbrev = $1
          @daylight_abbrev = $2
        elsif spec =~ /%[sz]/
          @type = :subst
          @abbrev = spec
        else
          @type = :fixed
          @abbrev = spec
        end
      end

      # Expands given the base offset to UTC, the current daylight savings
      # offset and rule string.
      def expand(utc_offset, std_offset, rule_string)
        if @type == :alternate
          if std_offset == 0
            @standard_abbrev
          else
            @daylight_abbrev
          end
        elsif @type == :subst
          @abbrev.gsub(/%([sz])/) do
            $1 == 's' ? rule_string : format_offset(utc_offset + std_offset)
          end
        else
          @abbrev
        end
      end

      # True if the format requires substitutions (%s or %z).
      def subst?
        @type == :subst
      end

      # Is a fixed format string.
      def fixed?
        @type == :fixed
      end

      private

        # Formats an offset according to the rules for %z.
        def format_offset(offset)
          if offset < 0
            offset = -offset
            sign = '-'
          else
            sign = '+'
          end

          offset, seconds = offset.divmod(60)
          offset, minutes = offset.divmod(60)

          if offset > 99
            # unsupported
            '%z'
          else
            result = "#{sign}#{offset.to_s.rjust(2, '0')}"

            if minutes != 0 || seconds != 0
              result << minutes.to_s.rjust(2, '0')

              if seconds != 0
                result << seconds.to_s.rjust(2, '0')
              end
            end

            result
          end
        end
    end

    # A location (latitude + longitude)
    #
    # @private
    class TZDataLocation #:nodoc:
      attr_reader :latitude
      attr_reader :longitude

      # Constructs a new TZDataLocation from a string in ISO 6709
      # sign-degrees-minutes-seconds format, either +-DDMM+-DDDMM
      # or +-DDMMSS+-DDDMMSS, first latitude (+ is north),
      # then longitude (+ is east).
      def initialize(coordinates)
        if coordinates !~ /^([+\-])([0-9]{2})([0-9]{2})([0-9]{2})?([+\-])([0-9]{3})([0-9]{2})([0-9]{2})?$/
          raise "Invalid coordinates: #{coordinates}"
        end

        @latitude = Rational($2.to_i) + Rational($3.to_i, 60)
        @latitude += Rational($4.to_i, 3600) unless $4.nil?
        @latitude = -@latitude if $1 == '-'

        @longitude = Rational($6.to_i) + Rational($7.to_i, 60)
        @longitude += Rational($8.to_i, 3600) unless $8.nil?
        @longitude = -@longitude if $5 == '-'
      end
    end

    # @private
    TZDataCountryTimezone = Struct.new(:timezone, :description, :location)  #:nodoc:

    # An ISO 3166 country.
    #
    # @private
    class TZDataCountry #:nodoc:
      include TZDataParserUtils

      attr_reader :code
      attr_reader :name
      attr_reader :zones

      def initialize(code, name)
        @code = code
        @name = name
        @zones = []
      end

      # Adds a TZDataCountryTimezone
      def add_zone(zone)
        @zones << zone
      end

      def write_index_record(file)
        s = "        country #{quote_str(@code)}, #{quote_str(@name)}"
        s << ' do |c|' if @zones.length > 0

        file.puts s

        @zones.each do |zone|
          file.puts "          c.timezone #{quote_str(zone.timezone.name)}, #{zone.location.latitude.numerator}, #{zone.location.latitude.denominator}, #{zone.location.longitude.numerator}, #{zone.location.longitude.denominator}#{zone.description.nil? ? '' : ', ' + quote_str(zone.description)}"
        end

        file.puts '        end' if @zones.length > 0
      end
    end
  end
end
