module Puppet::Pops
module Time
  NSECS_PER_USEC = 1000
  NSECS_PER_MSEC = NSECS_PER_USEC * 1000
  NSECS_PER_SEC  = NSECS_PER_MSEC * 1000
  NSECS_PER_MIN  = NSECS_PER_SEC  * 60
  NSECS_PER_HOUR = NSECS_PER_MIN  * 60
  NSECS_PER_DAY  = NSECS_PER_HOUR * 24

  KEY_STRING = 'string'.freeze
  KEY_FORMAT = 'format'.freeze
  KEY_NEGATIVE = 'negative'.freeze
  KEY_DAYS = 'days'.freeze
  KEY_HOURS = 'hours'.freeze
  KEY_MINUTES = 'minutes'.freeze
  KEY_SECONDS = 'seconds'.freeze
  KEY_MILLISECONDS = 'milliseconds'.freeze
  KEY_MICROSECONDS = 'microseconds'.freeze
  KEY_NANOSECONDS = 'nanoseconds'.freeze

  # TimeData is a Numeric that stores its value internally as nano-seconds but will be considered to be seconds and fractions of
  # seconds when used in arithmetic or comparison with other Numeric types.
  #
  class TimeData < Numeric
    include LabelProvider

    attr_reader :nsecs

    def initialize(nanoseconds)
      @nsecs = nanoseconds
    end

    def <=>(o)
      case o
      when self.class
        @nsecs <=> o.nsecs
      when Integer
        to_int <=> o
      when Float
        to_f <=> o
      else
        nil
      end
    end

    def label(o)
      Utils.name_to_segments(o.class.name).last
    end

    # @return [Float] the number of seconds
    def to_f
      @nsecs.fdiv(NSECS_PER_SEC)
    end

    # @return [Integer] the number of seconds with fraction part truncated
    def to_int
      @nsecs / NSECS_PER_SEC
    end

    def to_i
      to_int
    end

    # @return [Complex] short for `#to_f.to_c`
    def to_c
      to_f.to_c
    end

    # @return [Rational] initial numerator is nano-seconds and denominator is nano-seconds per second
    def to_r
      Rational(@nsecs, NSECS_PER_SEC)
    end

    undef_method :phase, :polar, :rect, :rectangular
  end

  class Timespan < TimeData
    def self.from_fields(negative, days, hours, minutes, seconds, milliseconds = 0, microseconds = 0, nanoseconds = 0)
      ns = (((((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000 + milliseconds) * 1000 + microseconds) * 1000 + nanoseconds
      new(negative ? -ns : ns)
    end

    def self.from_hash(hash)
      hash.include?('string') ? from_string_hash(hash) : from_fields_hash(hash)
    end

    def self.from_string_hash(hash)
      parse(hash[KEY_STRING], hash[KEY_FORMAT] || Format::DEFAULTS)
    end

    def self.from_fields_hash(hash)
      from_fields(
        hash[KEY_NEGATIVE] || false,
        hash[KEY_DAYS] || 0,
        hash[KEY_HOURS] || 0,
        hash[KEY_MINUTES] || 0,
        hash[KEY_SECONDS] || 0,
        hash[KEY_MILLISECONDS] || 0,
        hash[KEY_MICROSECONDS] || 0,
        hash[KEY_NANOSECONDS] || 0)
    end

    def self.parse(str, format = Format::DEFAULTS)
      if format.is_a?(::Array)
        format.each do |fmt|
          fmt = FormatParser.singleton.parse_format(fmt) unless fmt.is_a?(Format)
          begin
            return fmt.parse(str)
          rescue ArgumentError
          end
        end
        raise ArgumentError, _("Unable to parse '%{str}' using any of the formats %{formats}") % { str: str, formats: format.join(', ') }
      end
      format = FormatParser.singleton.parse_format(format) unless format.is_a?(Format)
      format.parse(str)
    end

    # @return [true] if the stored value is negative
    def negative?
      @nsecs < 0
    end

    def +(o)
      case o
      when Timestamp
        Timestamp.new(@nsecs + o.nsecs)
      when Timespan
        Timespan.new(@nsecs + o.nsecs)
      when Integer, Float
        # Add seconds
        Timespan.new(@nsecs + (o * NSECS_PER_SEC).to_i)
      else
        raise ArgumentError, _("%{klass} cannot be added to a Timespan") % { klass: a_an_uc(o) } unless o.is_a?(Timespan)
      end
    end

    def -(o)
      case o
      when Timespan
        Timespan.new(@nsecs - o.nsecs)
      when Integer, Float
        # Subtract seconds
        Timespan.new(@nsecs - (o * NSECS_PER_SEC).to_i)
      else
        raise ArgumentError, _("%{klass} cannot be subtracted from a Timespan") % { klass: a_an_uc(o) }
      end
    end

    def -@
      Timespan.new(-@nsecs)
    end

    def *(o)
      case o
      when Integer, Float
        Timespan.new((@nsecs * o).to_i)
      else
        raise ArgumentError, _("A Timestamp cannot be multiplied by %{klass}") % { klass: a_an(o) }
      end
    end

    def divmod(o)
      case o
      when Integer
        to_i.divmod(o)
      when Float
        to_f.divmod(o)
      else
        raise ArgumentError, _("Can not do modulus on a Timespan using a %{klass}") % { klass: a_an(o) }
      end
    end

    def modulo(o)
      divmod(o)[1]
    end

    def %(o)
      modulo(o)
    end

    def div(o)
      case o
      when Timespan
        # Timespan/Timespan yields a Float
        @nsecs.fdiv(o.nsecs)
      when Integer, Float
        Timespan.new(@nsecs.div(o))
      else
        raise ArgumentError, _("A Timespan cannot be divided by %{klass}") % { klass: a_an(o) }
      end
    end

    def /(o)
      div(o)
    end

    # @return [Integer] a positive integer denoting the number of days
    def days
      total_days
    end

    # @return [Integer] a positive integer, 0 - 23 denoting hours of day
    def hours
      total_hours % 24
    end

    # @return [Integer] a positive integer, 0 - 59 denoting minutes of hour
    def minutes
      total_minutes % 60
    end

    # @return [Integer] a positive integer, 0 - 59 denoting seconds of minute
    def seconds
      total_seconds % 60
    end

    # @return [Integer] a positive integer, 0 - 999 denoting milliseconds of second
    def milliseconds
      total_milliseconds % 1000
    end

    # @return [Integer] a positive integer, 0 - 999.999.999 denoting nanoseconds of second
    def nanoseconds
      total_nanoseconds % NSECS_PER_SEC
    end

    # Formats this timestamp into a string according to the given `format`
    #
    # @param [String,Format] format The format to use when producing the string
    # @return [String] the string representing the formatted timestamp
    # @raise [ArgumentError] if the format is a string with illegal format characters
    # @api public
    def format(format)
      format = FormatParser.singleton.parse_format(format) unless format.is_a?(Format)
      format.format(self)
    end

    # Formats this timestamp into a string according to {Format::DEFAULTS[0]}
    #
    # @return [String] the string representing the formatted timestamp
    # @api public
    def to_s
      format(Format::DEFAULTS[0])
    end

    def to_hash(compact = false)
      result = {}
      n = nanoseconds
      if compact
        s = total_seconds
        result[KEY_SECONDS] = negative? ? -s : s
        result[KEY_NANOSECONDS] = negative? ? -n : n unless n == 0
      else
        add_unless_zero(result, KEY_DAYS, days)
        add_unless_zero(result, KEY_HOURS, hours)
        add_unless_zero(result, KEY_MINUTES, minutes)
        add_unless_zero(result, KEY_SECONDS, seconds)
        unless n == 0
          add_unless_zero(result, KEY_NANOSECONDS, n % 1000)
          n /= 1000
          add_unless_zero(result, KEY_MICROSECONDS, n % 1000)
          add_unless_zero(result, KEY_MILLISECONDS, n /= 1000)
        end
        result[KEY_NEGATIVE] = true if negative?
      end
      result
    end

    def add_unless_zero(result, key, value)
      result[key] = value unless value == 0
    end
    private :add_unless_zero

    # @api private
    def total_days
      total_nanoseconds / NSECS_PER_DAY
    end

    # @api private
    def total_hours
      total_nanoseconds / NSECS_PER_HOUR
    end

    # @api private
    def total_minutes
      total_nanoseconds / NSECS_PER_MIN
    end

    # @api private
    def total_seconds
      total_nanoseconds / NSECS_PER_SEC
    end

    # @api private
    def total_milliseconds
      total_nanoseconds / NSECS_PER_MSEC
    end

    # @api private
    def total_microseconds
      total_nanoseconds / NSECS_PER_USEC
    end

    # @api private
    def total_nanoseconds
      @nsecs.abs
    end

    # Represents a compiled Timestamp format. The format is used both when parsing a timestamp
    # in string format and when producing a string from a timestamp instance.
    #
    class Format
      # A segment is either a string that will be represented literally in the formatted timestamp
      # or a value that corresponds to one of the possible format characters.
      class Segment
        def append_to(bld, ts)
          raise NotImplementedError, "'#{self.class.name}' should implement #append_to"
        end

        def append_regexp(bld, ts)
          raise NotImplementedError, "'#{self.class.name}' should implement #append_regexp"
        end

        def multiplier
          raise NotImplementedError, "'#{self.class.name}' should implement #multiplier"
        end
      end

      class LiteralSegment < Segment
        def append_regexp(bld)
          bld << "(#{Regexp.escape(@literal)})"
        end

        def initialize(literal)
          @literal = literal
        end

        def append_to(bld, ts)
          bld << @literal
        end

        def concat(codepoint)
          @literal.concat(codepoint)
        end

        def nanoseconds
          0
        end
      end

      class ValueSegment < Segment
        def initialize(padchar, width, default_width)
          @use_total = false
          @padchar = padchar
          @width = width
          @default_width = default_width
          @format = create_format
        end

        def create_format
          case @padchar
          when nil
            '%d'
          when ' '
            "%#{@width || @default_width}d"
          else
            "%#{@padchar}#{@width || @default_width}d"
          end
        end

        def append_regexp(bld)
          if @width.nil?
            case @padchar
            when nil
              bld << (use_total? ? '([0-9]+)' : "([0-9]{1,#{@default_width}})")
            when '0'
              bld << (use_total? ? '([0-9]+)' : "([0-9]{1,#{@default_width}})")
            else
              bld << (use_total? ? '\s*([0-9]+)' : "([0-9\\s]{1,#{@default_width}})")
            end
          else
            case @padchar
            when nil
              bld << "([0-9]{1,#{@width}})"
            when '0'
              bld << "([0-9]{#{@width}})"
            else
              bld << "([0-9\\s]{#{@width}})"
            end
          end
        end

        def nanoseconds(group)
          group.to_i * multiplier
        end

        def multiplier
          0
        end

        def set_use_total
          @use_total = true
        end

        def use_total?
          @use_total
        end

        def append_value(bld, n)
          bld << sprintf(@format, n)
        end
      end

      class DaySegment < ValueSegment
        def initialize(padchar, width)
          super(padchar, width, 1)
        end

        def multiplier
          NSECS_PER_DAY
        end

        def append_to(bld, ts)
          append_value(bld, ts.days)
        end
      end

      class HourSegment < ValueSegment
        def initialize(padchar, width)
          super(padchar, width, 2)
        end

        def multiplier
          NSECS_PER_HOUR
        end

        def append_to(bld, ts)
          append_value(bld, use_total? ? ts.total_hours : ts.hours)
        end
      end

      class MinuteSegment < ValueSegment
        def initialize(padchar, width)
          super(padchar, width, 2)
        end

        def multiplier
          NSECS_PER_MIN
        end

        def append_to(bld, ts)
          append_value(bld, use_total? ? ts.total_minutes : ts.minutes)
        end
      end

      class SecondSegment < ValueSegment
        def initialize(padchar, width)
          super(padchar, width, 2)
        end

        def multiplier
          NSECS_PER_SEC
        end

        def append_to(bld, ts)
          append_value(bld, use_total? ? ts.total_seconds : ts.seconds)
        end
      end

      # Class that assumes that leading zeroes are significant and that trailing zeroes are not and left justifies when formatting.
      # Applicable after a decimal point, and hence to the %L and %N formats.
      class FragmentSegment < ValueSegment
        def nanoseconds(group)
          # Using %L or %N to parse a string only makes sense when they are considered to be fractions. Using them
          # as a total quantity would introduce ambiguities.
          raise ArgumentError, _('Format specifiers %L and %N denotes fractions and must be used together with a specifier of higher magnitude') if use_total?
          n = group.to_i
          p = 9 - group.length
          p <= 0 ? n : n * 10 ** p
        end

        def create_format
          if @padchar.nil?
            '%d'
          else
            "%-#{@width || @default_width}d"
          end
        end

        def append_value(bld, n)
          # Strip trailing zeroes when default format is used
          n = n.to_s.sub(/\A([0-9]+?)0*\z/, '\1').to_i unless use_total? || @padchar == '0'
          super(bld, n)
        end
      end

      class MilliSecondSegment < FragmentSegment
        def initialize(padchar, width)
          super(padchar, width, 3)
        end

        def multiplier
          NSECS_PER_MSEC
        end

        def append_to(bld, ts)
          append_value(bld, use_total? ? ts.total_milliseconds : ts.milliseconds)
        end
      end

      class NanoSecondSegment < FragmentSegment
        def initialize(padchar, width)
          super(padchar, width, 9)
        end

        def multiplier
          width = @width || @default_width
          if width < 9
            10 ** (9 - width)
          else
            1
          end
        end

        def append_to(bld, ts)
          ns = ts.total_nanoseconds
          width = @width || @default_width
          if width < 9
            # Truncate digits to the right, i.e. let %6N reflect microseconds
            ns /= 10 ** (9 - width)
            ns %= 10 ** width unless use_total?
          else
            ns %= NSECS_PER_SEC unless use_total?
          end
          append_value(bld, ns)
        end
      end

      def initialize(format, segments)
        @format = format.freeze
        @segments = segments.freeze
      end

      def format(timespan)
        bld = timespan.negative? ? '-' : ''
        @segments.each { |segment| segment.append_to(bld, timespan) }
        bld
      end

      def parse(timespan)
        md = regexp.match(timespan)
        raise ArgumentError, _("Unable to parse '%{timespan}' using format '%{format}'") % { timespan: timespan, format: @format } if md.nil?
        nanoseconds = 0
        md.captures.each_with_index do |group, index|
          segment = @segments[index]
          next if segment.is_a?(LiteralSegment)
          group.lstrip!
          raise ArgumentError, _("Unable to parse '%{timespan}' using format '%{format}'") % { timespan: timespan, format: @format } unless group =~ /\A[0-9]+\z/
          nanoseconds += segment.nanoseconds(group)
        end
        Timespan.new(timespan.start_with?('-') ? -nanoseconds : nanoseconds)
      end

      def to_s
        @format
      end

      private

      def regexp
        @regexp ||= build_regexp
      end

      def build_regexp
        bld = '\A-?'
        @segments.each { |segment| segment.append_regexp(bld) }
        bld << '\z'
        Regexp.new(bld)
      end
    end

    # Parses a string into a Timestamp::Format instance
    class FormatParser
      def self.singleton
        @singleton
      end

      def initialize
        @formats = Hash.new { |hash, str| hash[str] = internal_parse(str) }
      end

      def parse_format(format)
        @formats[format]
      end

      private

      NSEC_MAX = 0
      MSEC_MAX = 1
      SEC_MAX = 2
      MIN_MAX = 3
      HOUR_MAX = 4
      DAY_MAX = 5

      SEGMENT_CLASS_BY_ORDINAL = [
        Format::NanoSecondSegment, Format::MilliSecondSegment, Format::SecondSegment, Format::MinuteSegment, Format::HourSegment, Format::DaySegment
      ]

      def bad_format_specifier(format, start, position)
        _("Bad format specifier '%{expression}' in '%{format}', at position %{position}") % { expression: format[start,position-start], format: format, position: position }
      end

      def append_literal(bld, codepoint)
        if bld.empty? || !bld.last.is_a?(Format::LiteralSegment)
          bld << Format::LiteralSegment.new(''.concat(codepoint))
        else
          bld.last.concat(codepoint)
        end
      end

      # States used by the #internal_parser function
      STATE_LITERAL = 0 # expects literal or '%'
      STATE_PAD  = 1 # expects pad, width, or format character
      STATE_WIDTH = 2 # expects width, or format character

      def internal_parse(str)
        bld = []
        raise ArgumentError, _('Format must be a String') unless str.is_a?(String)
        highest = -1
        state = STATE_LITERAL
        padchar = '0'
        width = nil
        position = -1
        fstart = 0

        str.codepoints do |codepoint|
          position += 1
          if state == STATE_LITERAL
            if codepoint == 0x25 # '%'
              state = STATE_PAD
              fstart = position
              padchar = '0'
              width = nil
            else
              append_literal(bld, codepoint)
            end
            next
          end

          case codepoint
          when 0x25 # '%'
            append_literal(bld, codepoint)
            state = STATE_LITERAL
          when 0x2D # '-'
            raise ArgumentError, bad_format_specifier(str, fstart, position) unless state == STATE_PAD
            padchar = nil
            state = STATE_WIDTH
          when 0x5F # '_'
            raise ArgumentError, bad_format_specifier(str, fstart, position) unless state == STATE_PAD
            padchar = ' '
            state = STATE_WIDTH
          when 0x44 # 'D'
            highest = DAY_MAX
            bld << Format::DaySegment.new(padchar, width)
            state = STATE_LITERAL
          when 0x48 # 'H'
            highest = HOUR_MAX unless highest > HOUR_MAX
            bld << Format::HourSegment.new(padchar, width)
            state = STATE_LITERAL
          when 0x4D # 'M'
            highest = MIN_MAX unless highest > MIN_MAX
            bld << Format::MinuteSegment.new(padchar, width)
            state = STATE_LITERAL
          when 0x53 # 'S'
            highest = SEC_MAX unless highest > SEC_MAX
            bld << Format::SecondSegment.new(padchar, width)
            state = STATE_LITERAL
          when 0x4C # 'L'
            highest = MSEC_MAX unless highest > MSEC_MAX
            bld << Format::MilliSecondSegment.new(padchar, width)
            state = STATE_LITERAL
          when 0x4E # 'N'
            highest = NSEC_MAX unless highest > NSEC_MAX
            bld << Format::NanoSecondSegment.new(padchar, width)
            state = STATE_LITERAL
          else # only digits allowed at this point
            raise ArgumentError, bad_format_specifier(str, fstart, position) unless codepoint >= 0x30 && codepoint <= 0x39
            if state == STATE_PAD && codepoint == 0x30
              padchar = '0'
            else
              n = codepoint - 0x30
              if width.nil?
                width = n
              else
                width = width * 10 + n
              end
            end
            state = STATE_WIDTH
          end
        end

        raise ArgumentError, bad_format_specifier(str, fstart, position)  unless state == STATE_LITERAL
        unless highest == -1
          hc = SEGMENT_CLASS_BY_ORDINAL[highest]
          bld.find { |s| s.instance_of?(hc) }.set_use_total
        end
        Format.new(str, bld)
      end

      @singleton = FormatParser.new
    end

    class Format
      DEFAULTS = ['%D-%H:%M:%S.%-N', '%H:%M:%S.%-N', '%M:%S.%-N', '%S.%-N', '%D-%H:%M:%S', '%H:%M:%S', '%D-%H:%M', '%S'].map { |str| FormatParser.singleton.parse_format(str) }
    end
  end
end
end
