require 'semantic_puppet'

module SemanticPuppet
  class VersionRange < Range
    class << self
      # Parses a version range string into a comparable {VersionRange} instance.
      #
      # Currently parsed version range string may take any of the following:
      # forms:
      #
      # * Regular Semantic Version strings
      #   * ex. `"1.0.0"`, `"1.2.3-pre"`
      # * Partial Semantic Version strings
      #   * ex. `"1.0.x"`, `"1"`, `"2.X"`
      # * Inequalities
      #   * ex. `"> 1.0.0"`, `"<3.2.0"`, `">=4.0.0"`
      # * Approximate Versions
      #   * ex. `"~1.0.0"`, `"~ 3.2.0"`, `"~4.0.0"`
      # * Inclusive Ranges
      #   * ex. `"1.0.0 - 1.3.9"`
      # * Range Intersections
      #   * ex. `">1.0.0 <=2.3.0"`
      #
      # @param range_str [String] the version range string to parse
      # @return [VersionRange] a new {VersionRange} instance
      def parse(range_str)
        partial = '\d+(?:[.]\d+)?(?:[.][x]|[.]\d+(?:[-][0-9a-z.-]*)?)?'
        exact   = '\d+[.]\d+[.]\d+(?:[-][0-9a-z.-]*)?'

        range = range_str.gsub(/([(><=~])[ ]+/, '\1')
        range = range.gsub(/ - /, '#').strip

        return case range
        when /\A(#{partial})\Z/i
          parse_loose_version_expression($1)
        when /\A([><][=]?)(#{exact})\Z/i
          parse_inequality_expression($1, $2)
        when /\A~(#{partial})\Z/i
          parse_reasonably_close_expression($1)
        when /\A(#{exact})#(#{exact})\Z/i
          parse_inclusive_range_expression($1, $2)
        when /[ ]+/
          parse_intersection_expression(range)
        else
          raise ArgumentError
        end

      rescue ArgumentError
        raise ArgumentError, "Unparsable version range: #{range_str.inspect}"
      end

      private

      # Creates a new {VersionRange} from a range intersection expression.
      #
      # @param expr [String] a range intersection expression
      # @return [VersionRange] a version range representing `expr`
      def parse_intersection_expression(expr)
        expr.split(/[ ]+/).map { |x| parse(x) }.inject { |a,b| a & b }
      end

      # Creates a new {VersionRange} from a "loose" description of a Semantic
      # Version number.
      #
      # @see .process_loose_expr
      #
      # @param expr [String] a "loose" version expression
      # @return [VersionRange] a version range representing `expr`
      def parse_loose_version_expression(expr)
        start, finish = process_loose_expr(expr)

        if start.stable?
          start = start.send(:first_prerelease)
        end

        if finish.stable?
          exclude = true
          finish = finish.send(:first_prerelease)
        end

        self.new(start, finish, exclude)
      end

      # Creates an open-ended version range from an inequality expression.
      #
      # @overload parse_inequality_expression('<', expr)
      #   {include:.parse_lt_expression}
      #
      # @overload parse_inequality_expression('<=', expr)
      #   {include:.parse_lte_expression}
      #
      # @overload parse_inequality_expression('>', expr)
      #   {include:.parse_gt_expression}
      #
      # @overload parse_inequality_expression('>=', expr)
      #   {include:.parse_gte_expression}
      #
      # @param comp ['<', '<=', '>', '>='] an inequality operator
      # @param expr [String] a "loose" version expression
      # @return [VersionRange] a range covering all versions in the inequality
      def parse_inequality_expression(comp, expr)
        case comp
        when '>'
          parse_gt_expression(expr)
        when '>='
          parse_gte_expression(expr)
        when '<'
          parse_lt_expression(expr)
        when '<='
          parse_lte_expression(expr)
        end
      end

      # Returns a range covering all versions greater than the given `expr`.
      #
      # @param expr [String] the version to be greater than
      # @return [VersionRange] a range covering all versions greater than the
      #         given `expr`
      def parse_gt_expression(expr)
        if expr =~ /^[^+]*-/
          start = Version.parse("#{expr}.0")
        else
          start = process_loose_expr(expr).last.send(:first_prerelease)
        end

        self.new(start, SemanticPuppet::Version::MAX)
      end

      # Returns a range covering all versions greater than or equal to the given
      # `expr`.
      #
      # @param expr [String] the version to be greater than or equal to
      # @return [VersionRange] a range covering all versions greater than or
      #         equal to the given `expr`
      def parse_gte_expression(expr)
        if expr =~ /^[^+]*-/
          start = Version.parse(expr)
        else
          start = process_loose_expr(expr).first.send(:first_prerelease)
        end

        self.new(start, SemanticPuppet::Version::MAX)
      end

      # Returns a range covering all versions less than the given `expr`.
      #
      # @param expr [String] the version to be less than
      # @return [VersionRange] a range covering all versions less than the
      #         given `expr`
      def parse_lt_expression(expr)
        if expr =~ /^[^+]*-/
          finish = Version.parse(expr)
        else
          finish = process_loose_expr(expr).first.send(:first_prerelease)
        end

        self.new(SemanticPuppet::Version::MIN, finish, true)
      end

      # Returns a range covering all versions less than or equal to the given
      # `expr`.
      #
      # @param expr [String] the version to be less than or equal to
      # @return [VersionRange] a range covering all versions less than or equal
      #         to the given `expr`
      def parse_lte_expression(expr)
        if expr =~ /^[^+]*-/
          finish = Version.parse(expr)
          self.new(SemanticPuppet::Version::MIN, finish)
        else
          finish = process_loose_expr(expr).last.send(:first_prerelease)
          self.new(SemanticPuppet::Version::MIN, finish, true)
        end
      end

      # The "reasonably close" expression is used to designate ranges that have
      # a reasonable proximity to the given "loose" version number. These take
      # the form:
      #
      #     ~[Version]
      #
      # The general semantics of these expressions are that the given version
      # forms a lower bound for the range, and the upper bound is either the
      # next version number increment (at whatever precision the expression
      # provides) or the next stable version (in the case of a prerelease
      # version).
      #
      # @example "Reasonably close" major version
      #   "~1" # => (>=1.0.0 <2.0.0)
      # @example "Reasonably close" minor version
      #   "~1.2" # => (>=1.2.0 <1.3.0)
      # @example "Reasonably close" patch version
      #   "~1.2.3" # => (>=1.2.3 <1.3.0)
      # @example "Reasonably close" prerelease version
      #   "~1.2.3-alpha" # => (>=1.2.3-alpha <1.2.4)
      #
      # @param expr [String] a "loose" expression to build the range around
      # @return [VersionRange] a "reasonably close" version range
      def parse_reasonably_close_expression(expr)
        parsed, succ = process_loose_expr(expr)

        if parsed.stable?
          parsed = parsed.send(:first_prerelease)

          # Handle the special case of "~1.2.3" expressions.
          succ = succ.next(:minor) if ((parsed.major == succ.major) && (parsed.minor == succ.minor))

          succ = succ.send(:first_prerelease)
          self.new(parsed, succ, true)
        else
          self.new(parsed, succ.next(:patch).send(:first_prerelease), true)
        end
      end

      # An "inclusive range" expression takes two version numbers (or partial
      # version numbers) and creates a range that covers all versions between
      # them. These take the form:
      #
      #     [Version] - [Version]
      #
      # @param start [String] a "loose" expresssion for the start of the range
      # @param finish [String] a "loose" expression for the end of the range
      # @return [VersionRange] a {VersionRange} covering `start` to `finish`
      def parse_inclusive_range_expression(start, finish)
        start, _ = process_loose_expr(start)
        _, finish = process_loose_expr(finish)

        start = start.send(:first_prerelease) if start.stable?
        if finish.stable?
          exclude = true
          finish = finish.send(:first_prerelease)
        end

        self.new(start, finish, exclude)
      end

      # A "loose expression" is one that takes the form of all or part of a
      # valid Semantic Version number. Particularly:
      #
      # * [Major].[Minor].[Patch]-[Prerelease]
      # * [Major].[Minor].[Patch]
      # * [Major].[Minor]
      # * [Major]
      #
      # Various placeholders are also permitted in "loose expressions"
      # (typically an 'x' or an asterisk).
      #
      # This method parses these expressions into a minimal and maximal version
      # number pair.
      #
      # @todo Stabilize whether the second value is inclusive or exclusive
      #
      # @param expr [String] a string containing a "loose" version expression
      # @return [(VersionNumber, VersionNumber)] a minimal and maximal
      #         version pair for the given expression
      def process_loose_expr(expr)
        case expr
        when /^(\d+)(?:[.][xX*])?$/
          expr = "#{$1}.0.0"
          arity = :major
        when /^(\d+[.]\d+)(?:[.][xX*])?$/
          expr = "#{$1}.0"
          arity = :minor
        when /^\d+[.]\d+[.]\d+$/
          arity = :patch
        end

        version = next_version = Version.parse(expr)

        if arity
          next_version = version.next(arity)
        end

        [ version, next_version ]
      end
    end

    # Computes the intersection of a pair of ranges. If the ranges have no
    # useful intersection, an empty range is returned.
    #
    # @param other [VersionRange] the range to intersect with
    # @return [VersionRange] the common subset
    def intersection(other)
      raise NOT_A_VERSION_RANGE unless other.kind_of?(VersionRange)

      if self.begin < other.begin
        return other.intersection(self)
      end

      unless include?(other.begin) || other.include?(self.begin)
        return EMPTY_RANGE
      end

      endpoint = ends_before?(other) ? self : other
      VersionRange.new(self.begin, endpoint.end, endpoint.exclude_end?)
    end
    alias :& :intersection

    # Returns a string representation of this range, prefering simple common
    # expressions for comprehension.
    #
    # @return [String] a range expression representing this VersionRange
    def to_s
      start, finish  = self.begin, self.end
      inclusive = exclude_end? ? '' : '='

      case
      when EMPTY_RANGE == self
        "<0.0.0"
      when exact_version?, patch_version?
        "#{ start }"
      when minor_version?
        "#{ start }".sub(/.0$/, '.x')
      when major_version?
        "#{ start }".sub(/.0.0$/, '.x')
      when open_end? && start.to_s =~ /-.*[.]0$/
        ">#{ start }".sub(/.0$/, '')
      when open_end?
        ">=#{ start }"
      when open_begin?
        "<#{ inclusive }#{ finish }"
      else
        ">=#{ start } <#{ inclusive }#{ finish }"
      end
    end
    alias :inspect :to_s

    private

    # Determines whether this {VersionRange} has an earlier endpoint than the
    # give `other` range.
    #
    # @param other [VersionRange] the range to compare against
    # @return [Boolean] true if the endpoint for this range is less than or
    #         equal to the endpoint of the `other` range.
    def ends_before?(other)
      self.end < other.end || (self.end == other.end && self.exclude_end?)
    end

    # Describes whether this range has an upper limit.
    # @return [Boolean] true if this range has no upper limit
    def open_end?
      self.end == SemanticPuppet::Version::MAX
    end

    # Describes whether this range has a lower limit.
    # @return [Boolean] true if this range has no lower limit
    def open_begin?
      self.begin == SemanticPuppet::Version::MIN
    end

    # Describes whether this range follows the patterns for matching all
    # releases with the same exact version.
    # @return [Boolean] true if this range matches only a single exact version
    def exact_version?
      self.begin == self.end
    end

    # Describes whether this range follows the patterns for matching all
    # releases with the same major version.
    # @return [Boolean] true if this range matches only a single major version
    def major_version?
      start, finish = self.begin, self.end

      exclude_end? &&
      start.major.next == finish.major &&
      same_minor? && start.minor == 0 &&
      same_patch? && start.patch == 0 &&
      [start.prerelease, finish.prerelease] == ['', '']
    end

    # Describes whether this range follows the patterns for matching all
    # releases with the same minor version.
    # @return [Boolean] true if this range matches only a single minor version
    def minor_version?
      start, finish = self.begin, self.end

      exclude_end? &&
      same_major? &&
      start.minor.next == finish.minor &&
      same_patch? && start.patch == 0 &&
      [start.prerelease, finish.prerelease] == ['', '']
    end

    # Describes whether this range follows the patterns for matching all
    # releases with the same patch version.
    # @return [Boolean] true if this range matches only a single patch version
    def patch_version?
      start, finish = self.begin, self.end

      exclude_end? &&
      same_major? &&
      same_minor? &&
      start.patch.next == finish.patch &&
      [start.prerelease, finish.prerelease] == ['', '']
    end

    # @return [Boolean] true if `begin` and `end` share the same major verion
    def same_major?
      self.begin.major == self.end.major
    end

    # @return [Boolean] true if `begin` and `end` share the same minor verion
    def same_minor?
      self.begin.minor == self.end.minor
    end

    # @return [Boolean] true if `begin` and `end` share the same patch verion
    def same_patch?
      self.begin.patch == self.end.patch
    end

    undef :to_a

    NOT_A_VERSION_RANGE = ArgumentError.new("value must be a #{VersionRange}")

    public

    # A range that matches no versions
    EMPTY_RANGE = VersionRange.parse('< 0.0.0').freeze
  end
end
