# encoding: UTF-8
# frozen_string_literal: true

module TZInfo
  # A time represented as an `Integer` number of seconds since 1970-01-01
  # 00:00:00 UTC (ignoring leap seconds), the fraction through the second
  # (sub_second as a `Rational`) and an optional UTC offset. Like Ruby's `Time`
  # class, {Timestamp} can distinguish between a local time with a zero offset
  # and a time specified explicitly as UTC.
  class Timestamp
    include Comparable

    # The Unix epoch (1970-01-01 00:00:00 UTC) as a chronological Julian day
    # number.
    JD_EPOCH = 2440588
    private_constant :JD_EPOCH

    class << self
      # Returns a new {Timestamp} representing the (Gregorian calendar) date and
      # time specified by the supplied parameters.
      #
      # If `utc_offset` is `nil`, `:utc` or 0, the date and time parameters will
      # be interpreted as representing a UTC date and time. Otherwise the date
      # and time parameters will be interpreted as a local date and time with
      # the given offset.
      #
      # @param year [Integer] the year.
      # @param month [Integer] the month (1-12).
      # @param day [Integer] the day of the month (1-31).
      # @param hour [Integer] the hour (0-23).
      # @param minute [Integer] the minute (0-59).
      # @param second [Integer] the second (0-59).
      # @param sub_second [Numeric] the fractional part of the second as either
      #   a `Rational` that is greater than or equal to 0 and less than 1, or
      #   the `Integer` 0.
      # @param utc_offset [Object] either `nil` for a {Timestamp} without a
      #   specified offset, an offset from UTC specified as an `Integer` number
      #   of seconds or the `Symbol` `:utc`).
      # @return [Timestamp] a new {Timestamp} representing the specified
      #   (Gregorian calendar) date and time.
      # @raise [ArgumentError] if either of `year`, `month`, `day`, `hour`,
      #   `minute`, or `second` is not an `Integer`.
      # @raise [ArgumentError] if `sub_second` is not a `Rational`, or the
      #   `Integer` 0.
      # @raise [ArgumentError] if `utc_offset` is not `nil`, not an `Integer`
      #   and not the `Symbol` `:utc`.
      # @raise [RangeError] if `month` is not between 1 and 12.
      # @raise [RangeError] if `day` is not between 1 and 31.
      # @raise [RangeError] if `hour` is not between 0 and 23.
      # @raise [RangeError] if `minute` is not between 0 and 59.
      # @raise [RangeError] if `second` is not between 0 and 59.
      # @raise [RangeError] if `sub_second` is a `Rational` but that is less
      #   than 0 or greater than or equal to 1.
      def create(year, month = 1, day = 1, hour = 0, minute = 0, second = 0, sub_second = 0, utc_offset = nil)
        raise ArgumentError, 'year must be an Integer' unless year.kind_of?(Integer)
        raise ArgumentError, 'month must be an Integer' unless month.kind_of?(Integer)
        raise ArgumentError, 'day must be an Integer' unless day.kind_of?(Integer)
        raise ArgumentError, 'hour must be an Integer' unless hour.kind_of?(Integer)
        raise ArgumentError, 'minute must be an Integer' unless minute.kind_of?(Integer)
        raise ArgumentError, 'second must be an Integer' unless second.kind_of?(Integer)
        raise RangeError, 'month must be between 1 and 12' if month < 1 || month > 12
        raise RangeError, 'day must be between 1 and 31' if day < 1 || day > 31
        raise RangeError, 'hour must be between 0 and 23' if hour < 0 || hour > 23
        raise RangeError, 'minute must be between 0 and 59' if minute < 0 || minute > 59
        raise RangeError, 'second must be between 0 and 59' if second < 0 || second > 59

        # Based on days_from_civil from https://howardhinnant.github.io/date_algorithms.html#days_from_civil
        after_february = month > 2
        year -= 1 unless after_february
        era = year / 400
        year_of_era = year - era * 400
        day_of_year = (153 * (month + (after_february ? -3 : 9)) + 2) / 5 + day - 1
        day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year
        days_since_epoch = era * 146097 + day_of_era - 719468
        value = ((days_since_epoch * 24 + hour) * 60 + minute) * 60 + second
        value -= utc_offset if utc_offset.kind_of?(Integer)

        new(value, sub_second, utc_offset)
      end

      # When used without a block, returns a {Timestamp} representation of a
      # given `Time`, `DateTime` or {Timestamp}.
      #
      # When called with a block, the {Timestamp} representation of `value` is
      # passed to the block. The block must then return a {Timestamp}, which
      # will be converted back to the type of the initial value. If the initial
      # value was a {Timestamp}, the block result will just be returned.
      #
      # The UTC offset of `value` can either be preserved (the {Timestamp}
      # representation will have the same UTC offset as `value`), ignored (the
      # {Timestamp} representation will have no defined UTC offset), or treated
      # as though it were UTC (the {Timestamp} representation will have a
      # {utc_offset} of 0 and {utc?} will return `true`).
      #
      # @param value [Object] a `Time`, `DateTime` or {Timestamp}.
      # @param offset [Symbol] either `:preserve` to preserve the offset of
      #   `value`, `:ignore` to ignore the offset of `value` and create a
      #   {Timestamp} with an unspecified offset, or `:treat_as_utc` to treat
      #   the offset of `value` as though it were UTC and create a UTC
      #   {Timestamp}.
      # @yield [timestamp] if a block is provided, the {Timestamp}
      #   representation is passed to the block.
      # @yieldparam timestamp [Timestamp] the {Timestamp} representation of
      #   `value`.
      # @yieldreturn [Timestamp] a {Timestamp} to be converted back to the type
      #   of `value`.
      # @return [Object] if called without a block, the {Timestamp}
      #   representation of `value`, otherwise the result of the block,
      #   converted back to the type of `value`.
      def for(value, offset = :preserve)
        raise ArgumentError, 'value must be specified' unless value

        case offset
          when :ignore
            ignore_offset = true
            target_utc_offset = nil
          when :treat_as_utc
            ignore_offset = true
            target_utc_offset = :utc
          when :preserve
            ignore_offset = false
            target_utc_offset = nil
          else
            raise ArgumentError, 'offset must be :preserve, :ignore or :treat_as_utc'
        end

        time_like = false
        timestamp = case value
          when Time
            for_time(value, ignore_offset, target_utc_offset)
          when DateTime
            for_datetime(value, ignore_offset, target_utc_offset)
          when Timestamp
            for_timestamp(value, ignore_offset, target_utc_offset)
          else
            raise ArgumentError, "#{value.class} values are not supported" unless is_time_like?(value)
            time_like = true
            for_time_like(value, ignore_offset, target_utc_offset)
        end

        if block_given?
          result = yield timestamp
          raise ArgumentError, 'block must return a Timestamp' unless result.kind_of?(Timestamp)

          case value
            when Time
              result.to_time
            when DateTime
              result.to_datetime
            else # A Time-like value or a Timestamp
              time_like ? result.to_time : result
          end
        else
          timestamp
        end
      end

      # Creates a new UTC {Timestamp}.
      #
      # @param value [Integer] the number of seconds since 1970-01-01 00:00:00
      #   UTC ignoring leap seconds.
      # @param sub_second [Numeric] the fractional part of the second as either
      #   a `Rational` that is greater than or equal to 0 and less than 1, or
      #   the `Integer` 0.
      # @raise [ArgumentError] if `value` is not an `Integer`.
      # @raise [ArgumentError] if `sub_second` is not a `Rational`, or the
      #   `Integer` 0.
      # @raise [RangeError] if `sub_second` is a `Rational` but that is less
      #   than 0 or greater than or equal to 1.
      def utc(value, sub_second = 0)
        new(value, sub_second, :utc)
      end

      private

      # Constructs a new instance of `self` (i.e. {Timestamp} or a subclass of
      # {Timestamp}) without validating the parameters. This method is used
      # internally within {Timestamp} to avoid the overhead of checking
      # parameters.
      #
      # @param value [Integer] the number of seconds since 1970-01-01 00:00:00
      #   UTC ignoring leap seconds.
      # @param sub_second [Numeric] the fractional part of the second as either
      #   a `Rational` that is greater than or equal to 0 and less than 1, or
      #   the `Integer` 0.
      # @param utc_offset [Object] either `nil` for a {Timestamp} without a
      #   specified offset, an offset from UTC specified as an `Integer` number
      #   of seconds or the `Symbol` `:utc`).
      # @return [Timestamp] a new instance of `self`.
      def new!(value, sub_second = 0, utc_offset = nil)
        result = allocate
        result.send(:initialize!, value, sub_second, utc_offset)
        result
      end

      # Creates a {Timestamp} that represents a given `Time`, optionally
      # ignoring the offset.
      #
      # @param time [Time] a `Time`.
      # @param ignore_offset [Boolean] whether to ignore the offset of `time`.
      # @param target_utc_offset [Object] if `ignore_offset` is `true`, the UTC
      #   offset of the result (`:utc`, `nil` or an `Integer`).
      # @return [Timestamp] the {Timestamp} representation of `time`.
      def for_time(time, ignore_offset, target_utc_offset)
        value = time.to_i
        sub_second = time.subsec

        if ignore_offset
          utc_offset = target_utc_offset
          value += time.utc_offset
        elsif time.utc?
          utc_offset = :utc
        else
          utc_offset = time.utc_offset
        end

        new!(value, sub_second, utc_offset)
      end

      # Creates a {Timestamp} that represents a given `DateTime`, optionally
      # ignoring the offset.
      #
      # @param datetime [DateTime] a `DateTime`.
      # @param ignore_offset [Boolean] whether to ignore the offset of
      #   `datetime`.
      # @param target_utc_offset [Object] if `ignore_offset` is `true`, the UTC
      #   offset of the result (`:utc`, `nil` or an `Integer`).
      # @return [Timestamp] the {Timestamp} representation of `datetime`.
      def for_datetime(datetime, ignore_offset, target_utc_offset)
        value = (datetime.jd - JD_EPOCH) * 86400 + datetime.sec + datetime.min * 60 + datetime.hour * 3600
        sub_second = datetime.sec_fraction

        if ignore_offset
          utc_offset = target_utc_offset
        else
          utc_offset = (datetime.offset * 86400).to_i
          value -= utc_offset
        end

        new!(value, sub_second, utc_offset)
      end

      # Returns a {Timestamp} that represents another {Timestamp}, optionally
      # ignoring the offset. If the result would be identical to `value`, the
      # same instance is returned. If the passed in value is an instance of a
      # subclass of {Timestamp}, then a new {Timestamp} will always be returned.
      #
      # @param timestamp [Timestamp] a {Timestamp}.
      # @param ignore_offset [Boolean] whether to ignore the offset of
      #   `timestamp`.
      # @param target_utc_offset [Object] if `ignore_offset` is `true`, the UTC
      #   offset of the result (`:utc`, `nil` or an `Integer`).
      # @return [Timestamp] a [Timestamp] representation of `timestamp`.
      def for_timestamp(timestamp, ignore_offset, target_utc_offset)
        if ignore_offset
          if target_utc_offset
            unless target_utc_offset == :utc && timestamp.utc? || timestamp.utc_offset == target_utc_offset
              return new!(timestamp.value + (timestamp.utc_offset || 0), timestamp.sub_second, target_utc_offset)
            end
          elsif timestamp.utc_offset
            return new!(timestamp.value + timestamp.utc_offset, timestamp.sub_second)
          end
        end

        unless timestamp.instance_of?(Timestamp)
          # timestamp is identical in value, sub_second and utc_offset but is a
          # subclass (i.e. TimestampWithOffset). Return a new Timestamp
          # instance.
          return new!(timestamp.value, timestamp.sub_second, timestamp.utc? ? :utc : timestamp.utc_offset)
        end

        timestamp
      end

      # Determines if an object is like a `Time` (for the purposes of converting
      # to a {Timestamp} with {for}), responding to `to_i` and `subsec`.
      #
      # @param value [Object] an object to test.
      # @return [Boolean] `true` if the object is `Time`-like, otherwise
      #   `false`.
      def is_time_like?(value)
        value.respond_to?(:to_i) && value.respond_to?(:subsec)
      end

      # Creates a {Timestamp} that represents a given `Time`-like object,
      # optionally ignoring the offset (if the `time_like` responds to
      # `utc_offset`).
      #
      # @param time_like [Object] a `Time`-like object.
      # @param ignore_offset [Boolean] whether to ignore the offset of `time`.
      # @param target_utc_offset [Object] if `ignore_offset` is `true`, the UTC
      #   offset of the result (`:utc`, `nil` or an `Integer`).
      # @return [Timestamp] the {Timestamp} representation of `time_like`.
      def for_time_like(time_like, ignore_offset, target_utc_offset)
        value = time_like.to_i
        sub_second = time_like.subsec.to_r

        if ignore_offset
          utc_offset = target_utc_offset
          value += time_like.utc_offset.to_i if time_like.respond_to?(:utc_offset)
        elsif time_like.respond_to?(:utc_offset)
          utc_offset = time_like.utc_offset.to_i
        else
          utc_offset = 0
        end

        new(value, sub_second, utc_offset)
      end
    end

    # @return [Integer] the number of seconds since 1970-01-01 00:00:00 UTC
    #   ignoring leap seconds (i.e. each day is treated as if it were 86,400
    #   seconds long).
    attr_reader :value

    # @return [Numeric] the fraction of a second elapsed since timestamp as
    #   either a `Rational` or the `Integer` 0. Always greater than or equal to
    #   0 and less than 1.
    attr_reader :sub_second

    # @return [Integer] the offset from UTC in seconds or `nil` if the
    #   {Timestamp} doesn't have a specified offset.
    attr_reader :utc_offset

    # Initializes a new {Timestamp}.
    #
    # @param value [Integer] the number of seconds since 1970-01-01 00:00:00 UTC
    #   ignoring leap seconds.
    # @param sub_second [Numeric] the fractional part of the second as either a
    #   `Rational` that is greater than or equal to 0 and less than 1, or
    #   the `Integer` 0.
    # @param utc_offset [Object] either `nil` for a {Timestamp} without a
    #   specified offset, an offset from UTC specified as an `Integer` number of
    #   seconds or the `Symbol` `:utc`).
    # @raise [ArgumentError] if `value` is not an `Integer`.
    # @raise [ArgumentError] if `sub_second` is not a `Rational`, or the
    #   `Integer` 0.
    # @raise [RangeError] if `sub_second` is a `Rational` but that is less
    #   than 0 or greater than or equal to 1.
    # @raise [ArgumentError] if `utc_offset` is not `nil`, not an `Integer` and
    #   not the `Symbol` `:utc`.
    def initialize(value, sub_second = 0, utc_offset = nil)
      raise ArgumentError, 'value must be an Integer' unless value.kind_of?(Integer)
      raise ArgumentError, 'sub_second must be a Rational or the Integer 0' unless (sub_second.kind_of?(Integer) && sub_second == 0) || sub_second.kind_of?(Rational)
      raise RangeError, 'sub_second must be >= 0 and < 1' if sub_second < 0 || sub_second >= 1
      raise ArgumentError, 'utc_offset must be an Integer, :utc or nil' if utc_offset && utc_offset != :utc && !utc_offset.kind_of?(Integer)
      initialize!(value, sub_second, utc_offset)
    end

    # @return [Boolean] `true` if this {Timestamp} represents UTC, `false` if
    #   the {Timestamp} wasn't specified as UTC or `nil` if the {Timestamp} has
    #   no specified offset.
    def utc?
      @utc
    end

    # Adds a number of seconds to the {Timestamp} value, setting the UTC offset
    # of the result.
    #
    # @param seconds [Integer] the number of seconds to be added.
    # @param utc_offset [Object] either `nil` for a {Timestamp} without a
    #   specified offset, an offset from UTC specified as an `Integer` number of
    #   seconds or the `Symbol` `:utc`).
    # @return [Timestamp] the result of adding `seconds` to the
    #   {Timestamp} value as a new {Timestamp} instance with the chosen
    #   `utc_offset`.
    # @raise [ArgumentError] if `seconds` is not an `Integer`.
    # @raise [ArgumentError] if `utc_offset` is not `nil`, not an `Integer` and
    #   not the `Symbol` `:utc`.
    def add_and_set_utc_offset(seconds, utc_offset)
      raise ArgumentError, 'seconds must be an Integer' unless seconds.kind_of?(Integer)
      raise ArgumentError, 'utc_offset must be an Integer, :utc or nil' if utc_offset && utc_offset != :utc && !utc_offset.kind_of?(Integer)
      return self if seconds == 0 && utc_offset == (@utc ? :utc : @utc_offset)
      Timestamp.send(:new!, @value + seconds, @sub_second, utc_offset)
    end

    # @return [Timestamp] a UTC {Timestamp} equivalent to this instance. Returns
    #   `self` if {#utc? self.utc?} is `true`.
    def utc
      return self if @utc
      Timestamp.send(:new!, @value, @sub_second, :utc)
    end

    # Converts this {Timestamp} to a `Time`.
    #
    # @return [Time] a `Time` representation of this {Timestamp}. If the UTC
    #   offset of this {Timestamp} is not specified, a UTC `Time` will be
    #   returned.
    def to_time
      time = new_time

      if @utc_offset && !@utc
        time.localtime(@utc_offset)
      else
        time.utc
      end
    end

    # Converts this {Timestamp} to a `DateTime`.
    #
    # @return [DateTime] a DateTime representation of this {Timestamp}. If the
    #   UTC offset of this {Timestamp} is not specified, a UTC `DateTime` will
    #   be returned.
    def to_datetime
      new_datetime
    end

    # Converts this {Timestamp} to an `Integer` number of seconds since
    # 1970-01-01 00:00:00 UTC (ignoring leap seconds).
    #
    # @return [Integer] an Integer representation of this {Timestamp} (the
    #   number of seconds since 1970-01-01 00:00:00 UTC ignoring leap seconds).
    def to_i
      value
    end

    # Formats this {Timestamp} according to the directives in the given format
    # string.
    #
    # @param format [String] the format string. Please refer to `Time#strftime`
    #   for a list of supported format directives.
    # @return [String] the formatted {Timestamp}.
    # @raise [ArgumentError] if `format` is not specified.
    def strftime(format)
      raise ArgumentError, 'format must be specified' unless format
      to_time.strftime(format)
    end

    # @return [String] a `String` representation of this {Timestamp}.
    def to_s
      return value_and_sub_second_to_s unless @utc_offset
      return "#{value_and_sub_second_to_s} UTC" if @utc

      sign = @utc_offset >= 0 ? '+' : '-'
      min, sec = @utc_offset.abs.divmod(60)
      hour, min = min.divmod(60)

      "#{value_and_sub_second_to_s(@utc_offset)} #{sign}#{'%02d' % hour}:#{'%02d' % min}#{sec > 0 ? ':%02d' % sec : nil}#{@utc_offset != 0 ? " (#{value_and_sub_second_to_s} UTC)" : nil}"
    end

    # Compares this {Timestamp} with another.
    #
    # {Timestamp} instances without a defined UTC offset are not comparable with
    # {Timestamp} instances that have a defined UTC offset.
    #
    # @param t [Timestamp] the {Timestamp} to compare this instance with.
    # @return [Integer] -1, 0 or 1 depending if this instance is earlier, equal
    #   or later than `t` respectively. Returns `nil` when comparing a
    #   {Timestamp} that does not have a defined UTC offset with a {Timestamp}
    #   that does have a defined UTC offset. Returns `nil` if `t` is not a
    #   {Timestamp}.
    def <=>(t)
      return nil unless t.kind_of?(Timestamp)
      return nil if utc_offset && !t.utc_offset
      return nil if !utc_offset && t.utc_offset

      result = value <=> t.value
      result = sub_second <=> t.sub_second if result == 0
      result
    end

    alias eql? ==

    # @return [Integer] a hash based on the value, sub-second and whether there
    #   is a defined UTC offset.
    def hash
      [@value, @sub_second, !!@utc_offset].hash
    end

    # @return [String] the internal object state as a programmer-readable
    #   `String`.
    def inspect
      "#<#{self.class}: @value=#{@value}, @sub_second=#{@sub_second}, @utc_offset=#{@utc_offset.inspect}, @utc=#{@utc.inspect}>"
    end

    protected

    # Creates a new instance of a `Time` or `Time`-like class matching the
    # {value} and {sub_second} of this {Timestamp}, but not setting the offset.
    #
    # @param klass [Class] the class to instantiate.
    #
    # @private
    def new_time(klass = Time)
      klass.at(@value, @sub_second * 1_000_000)
    end

    # Constructs a new instance of a `DateTime` or `DateTime`-like class with
    # the same {value}, {sub_second} and {utc_offset} as this {Timestamp}.
    #
    # @param klass [Class] the class to instantiate.
    #
    # @private
    def new_datetime(klass = DateTime)
      datetime = klass.jd(JD_EPOCH + ((@value.to_r + @sub_second) / 86400))
      @utc_offset && @utc_offset != 0 ? datetime.new_offset(Rational(@utc_offset, 86400)) : datetime
    end

    private

    # Converts the value and sub-seconds to a `String`, adding on the given
    # offset.
    #
    # @param offset [Integer] the offset to add to the value.
    # @return [String] the value and sub-seconds.
    def value_and_sub_second_to_s(offset = 0)
      "#{@value + offset}#{sub_second_to_s}"
    end

    # Converts the {sub_second} value to a `String` suitable for appending to
    # the `String` representation of a {Timestamp}.
    #
    # @return [String] a `String` representation of {sub_second}.
    def sub_second_to_s
      if @sub_second == 0
        ''
      else
        " #{@sub_second.numerator}/#{@sub_second.denominator}"
      end
    end

    # Initializes a new {Timestamp} without validating the parameters. This
    # method is used internally within {Timestamp} to avoid the overhead of
    # checking parameters.
    #
    # @param value [Integer] the number of seconds since 1970-01-01 00:00:00 UTC
    #   ignoring leap seconds.
    # @param sub_second [Numeric] the fractional part of the second as either a
    #   `Rational` that is greater than or equal to 0 and less than 1, or the
    #   `Integer` 0.
    # @param utc_offset [Object] either `nil` for a {Timestamp} without a
    #   specified offset, an offset from UTC specified as an `Integer` number of
    #   seconds or the `Symbol` `:utc`).
    def initialize!(value, sub_second = 0, utc_offset = nil)
      @value = value

      # Convert Rational(0,1) to 0.
      @sub_second = sub_second == 0 ? 0 : sub_second

      if utc_offset
        @utc = utc_offset == :utc
        @utc_offset = @utc ? 0 : utc_offset
      else
        @utc = @utc_offset = nil
      end
    end
  end
end
