# frozen-string-literal: true
#
# The pg_range extension adds support for the PostgreSQL 9.2+ range
# types to Sequel.  PostgreSQL range types are similar to ruby's
# Range class, representating an array of values.  However, they
# are more flexible than ruby's ranges, allowing exclusive beginnings
# and endings (ruby's range only allows exclusive endings).
#
# When PostgreSQL range values are retreived, they are parsed and returned
# as instances of Sequel::Postgres::PGRange.  PGRange mostly acts
# like a Range, but it's not a Range as not all PostgreSQL range
# type values would be valid ruby ranges.  If the range type value
# you are using is a valid ruby range, you can call PGRange#to_range
# to get a Range.  However, if you call PGRange#to_range on a range
# type value uses features that ruby's Range does not support, an
# exception will be raised.
#
# In addition to the parser, this extension comes with literalizers
# for PGRange and Range, so they can be used in queries and as bound variables.
#
# To turn an existing Range into a PGRange, use Sequel.pg_range:
#
#   Sequel.pg_range(range)
#
# If you have loaded the {core_extensions extension}[rdoc-ref:doc/core_extensions.rdoc],
# or you have loaded the core_refinements extension
# and have activated refinements for the file, you can also use Range#pg_range:
#
#   range.pg_range 
#
# You may want to specify a specific range type:
#
#   Sequel.pg_range(range, :daterange)
#   range.pg_range(:daterange)
#
# If you specify the range database type, Sequel will automatically cast
# the value to that type when literalizing.
#
# To use this extension, load it into the Database instance:
#
#   DB.extension :pg_range
#
# See the {schema modification guide}[rdoc-ref:doc/schema_modification.rdoc]
# for details on using range type columns in CREATE/ALTER TABLE statements.
#
# This extension makes it easy to add support for other range types.  In
# general, you just need to make sure that the subtype is handled and has the
# appropriate converter installed.  For user defined
# types, you can do this via:
#
#   DB.add_conversion_proc(subtype_oid){|string| }
#
# Then you can call
# Sequel::Postgres::PGRange::DatabaseMethods#register_range_type
# to automatically set up a handler for the range type.  So if you
# want to support the timerange type (assuming the time type is already
# supported):
#
#   DB.register_range_type('timerange')
#
# This extension integrates with the pg_array extension.  If you plan
# to use arrays of range types, load the pg_array extension before the
# pg_range extension:
#
#   DB.extension :pg_array, :pg_range
#
# Related module: Sequel::Postgres::PGRange

module Sequel
  module Postgres
    class PGRange
      include Sequel::SQL::AliasMethods

      # Creates callable objects that convert strings into PGRange instances.
      class Parser
        # The database range type for this parser (e.g. 'int4range'),
        # automatically setting the db_type for the returned PGRange instances.
        attr_reader :db_type

        # A callable object to convert the beginning and ending of the range into
        # the appropriate ruby type.
        attr_reader :converter

        # Set the db_type and converter on initialization.
        def initialize(db_type, converter=nil)
          @db_type = db_type.to_s.dup.freeze if db_type
          @converter = converter
        end

        # Parse the range type input string into a PGRange value.
        def call(string)
          if string == 'empty'
            return PGRange.empty(db_type)
          end

          raise(InvalidValue, "invalid or unhandled range format: #{string.inspect}") unless matches = /\A(\[|\()("((?:\\"|[^"])*)"|[^"]*),("((?:\\"|[^"])*)"|[^"]*)(\]|\))\z/.match(string)

          exclude_begin = matches[1] == '('
          exclude_end = matches[6] == ')'

          # If the input is quoted, it needs to be unescaped.  Also, quoted input isn't
          # checked for emptiness, since the empty quoted string is considered an 
          # element that happens to be the empty string, while an unquoted empty string
          # is considered unbounded.
          #
          # While PostgreSQL allows pure escaping for input (without quoting), it appears
          # to always use the quoted output form when characters need to be escaped, so
          # there isn't a need to unescape unquoted output.
          if beg = matches[3]
            beg.gsub!(/\\(.)/, '\1')
          else
            beg = matches[2] unless matches[2].empty?
          end
          if en = matches[5]
            en.gsub!(/\\(.)/, '\1')
          else
            en = matches[4] unless matches[4].empty?
          end

          if c = converter
            beg = c.call(beg) if beg
            en = c.call(en) if en
          end

          PGRange.new(beg, en, :exclude_begin=>exclude_begin, :exclude_end=>exclude_end, :db_type=>db_type)
        end
      end

      module DatabaseMethods
        # Add the conversion procs to the database
        # and extend the datasets to correctly literalize ruby Range values.
        def self.extended(db)
          db.instance_exec do
            @pg_range_schema_types ||= {}
            extend_datasets(DatasetMethods)
            register_range_type('int4range', :oid=>3904, :subtype_oid=>23)
            register_range_type('numrange', :oid=>3906, :subtype_oid=>1700)
            register_range_type('tsrange', :oid=>3908, :subtype_oid=>1114)
            register_range_type('tstzrange', :oid=>3910, :subtype_oid=>1184)
            register_range_type('daterange', :oid=>3912, :subtype_oid=>1082)
            register_range_type('int8range', :oid=>3926, :subtype_oid=>20)
            if respond_to?(:register_array_type)
              register_array_type('int4range', :oid=>3905, :scalar_oid=>3904, :scalar_typecast=>:int4range)
              register_array_type('numrange', :oid=>3907, :scalar_oid=>3906, :scalar_typecast=>:numrange)
              register_array_type('tsrange', :oid=>3909, :scalar_oid=>3908, :scalar_typecast=>:tsrange)
              register_array_type('tstzrange', :oid=>3911, :scalar_oid=>3910, :scalar_typecast=>:tstzrange)
              register_array_type('daterange', :oid=>3913, :scalar_oid=>3912, :scalar_typecast=>:daterange)
              register_array_type('int8range', :oid=>3927, :scalar_oid=>3926, :scalar_typecast=>:int8range)
            end
            [:int4range, :numrange, :tsrange, :tstzrange, :daterange, :int8range].each do |v|
              @schema_type_classes[v] = PGRange
            end

            procs = conversion_procs
            add_conversion_proc(3908, Parser.new("tsrange", procs[1114]))
            add_conversion_proc(3910, Parser.new("tstzrange", procs[1184]))
            if respond_to?(:register_array_type) && defined?(PGArray::Creator)
              add_conversion_proc(3909, PGArray::Creator.new("tsrange", procs[3908]))
              add_conversion_proc(3911, PGArray::Creator.new("tstzrange", procs[3910]))
            end
          end
        end

        # Handle Range and PGRange values in bound variables
        def bound_variable_arg(arg, conn)
          case arg
          when PGRange 
            arg.unquoted_literal(schema_utility_dataset)
          when Range
            PGRange.from_range(arg).unquoted_literal(schema_utility_dataset)
          else
            super
          end
        end

        # Freeze the pg range schema types to prevent adding new ones.
        def freeze
          @pg_range_schema_types.freeze
          super
        end

        # Register a database specific range type.  This can be used to support
        # different range types per Database.  Options:
        #
        # :converter :: A callable object (e.g. Proc), that is called with the start or end of the range
        #               (usually a string), and should return the appropriate typecasted object.
        # :oid :: The PostgreSQL OID for the range type.  This is used by the Sequel postgres adapter
        #         to set up automatic type conversion on retrieval from the database.
        # :subtype_oid :: Should be the PostgreSQL OID for the range's subtype. If given,
        #                 automatically sets the :converter option by looking for scalar conversion
        #                 proc.
        #
        # If a block is given, it is treated as the :converter option.
        def register_range_type(db_type, opts=OPTS, &block)
          oid = opts[:oid]
          soid = opts[:subtype_oid]

          if has_converter = opts.has_key?(:converter)
            raise Error, "can't provide both a block and :converter option to register_range_type" if block
            converter = opts[:converter]
          else
            has_converter = true if block
            converter = block
          end

          unless (soid || has_converter) && oid
            range_oid, subtype_oid = from(:pg_range).join(:pg_type, :oid=>:rngtypid).where(:typname=>db_type.to_s).get([:rngtypid, :rngsubtype])
            soid ||= subtype_oid unless has_converter
            oid ||= range_oid
          end

          db_type = db_type.to_s.dup.freeze

          if soid
            raise Error, "can't provide both a converter and :subtype_oid option to register" if has_converter 
            raise Error, "no conversion proc for :subtype_oid=>#{soid.inspect} in conversion_procs" unless converter = conversion_procs[soid]
          end

          parser = Parser.new(db_type, converter)
          add_conversion_proc(oid, parser)

          @pg_range_schema_types[db_type] = db_type.to_sym

          singleton_class.class_eval do
            meth = :"typecast_value_#{db_type}"
            define_method(meth){|v| typecast_value_pg_range(v, parser)}
            private meth
          end

          @schema_type_classes[:"#{opts[:type_symbol] || db_type}"] = PGRange
          nil
        end

        private

        # Recognize the registered database range types.
        def schema_range_type(db_type)
          @pg_range_schema_types[db_type] || super
        end

        # Set the :ruby_default value if the default value is recognized as a range.
        def schema_post_process(_)
          super.each do |a|
            h = a[1]
            db_type = h[:db_type]
            if @pg_range_schema_types[db_type] && h[:default] =~ /\A'([^']+)'::#{db_type}\z/
              default = $1
              if convertor = conversion_procs[h[:oid]]
                h[:ruby_default] = convertor.call(default)
              end
            end
          end
        end

        # Typecast value correctly to a PGRange.  If already an
        # PGRange instance with the same db_type, return as is.
        # If a PGRange with a different subtype, return a new
        # PGRange with the same values and the expected subtype.
        # If a Range object, create a PGRange with the given
        # db_type.  If a string, assume it is in PostgreSQL
        # output format and parse it using the parser.
        def typecast_value_pg_range(value, parser)
          case value
          when PGRange
            if value.db_type.to_s == parser.db_type
              value
            elsif value.empty?
              PGRange.empty(parser.db_type)
            else
              PGRange.new(value.begin, value.end, :exclude_begin=>value.exclude_begin?, :exclude_end=>value.exclude_end?, :db_type=>parser.db_type)
            end
          when Range
            PGRange.from_range(value, parser.db_type)
          when String
            parser.call(typecast_check_string_length(value, 100))
          else
            raise Sequel::InvalidValue, "invalid value for range type: #{value.inspect}"
          end
        end
      end

      module DatasetMethods
        private

        # Handle literalization of ruby Range objects, treating them as
        # PostgreSQL ranges.
        def literal_other_append(sql, v)
          case v
          when Range
            super(sql, Sequel::Postgres::PGRange.from_range(v))
          else
            super
          end
        end
      end

      include Enumerable

      # The beginning of the range.  If nil, the range has an unbounded beginning.
      attr_reader :begin

      # The end of the range.  If nil, the range has an unbounded ending.
      attr_reader :end

      # The PostgreSQL database type for the range (e.g. 'int4range').
      attr_reader :db_type

      # Create a new PGRange instance using the beginning and ending of the ruby Range,
      # with the given db_type.
      def self.from_range(range, db_type=nil)
        new(range.begin, range.end, :exclude_end=>range.exclude_end?, :db_type=>db_type)
      end

      # Create an empty PGRange with the given database type.
      def self.empty(db_type=nil)
        new(nil, nil, :empty=>true, :db_type=>db_type)
      end

      # Initialize a new PGRange instance.  Accepts the following options:
      #
      # :db_type :: The PostgreSQL database type for the range.
      # :empty :: Whether the range is empty (has no points)
      # :exclude_begin :: Whether the beginning element is excluded from the range.
      # :exclude_end :: Whether the ending element is excluded from the range.
      def initialize(beg, en, opts=OPTS)
        @begin = beg
        @end = en
        @empty = !!opts[:empty]
        @exclude_begin = !!opts[:exclude_begin]
        @exclude_end = !!opts[:exclude_end]
        @db_type = opts[:db_type]
        if @empty
          raise(Error, 'cannot have an empty range with either a beginning or ending') unless @begin.nil? && @end.nil? && opts[:exclude_begin].nil? && opts[:exclude_end].nil?
        end
      end

      # Delegate to the ruby range object so that the object mostly acts like a range.
      range_methods = %w'each last first step'
      range_methods.each do |m|
        class_eval("def #{m}(*a, &block) to_range.#{m}(*a, &block) end", __FILE__, __LINE__)
      end

      # Return whether the value is inside the range.
      def cover?(value)
        return false if empty?
        b = self.begin
        return false if b && b.public_send(exclude_begin? ? :>= : :>, value)
        e = self.end
        return false if e && e.public_send(exclude_end? ? :<= : :<, value)
        true
      end

      # Consider the receiver equal to other PGRange instances with the
      # same beginning, ending, exclusions, and database type.  Also consider
      # it equal to Range instances if this PGRange can be converted to a
      # a Range and those ranges are equal.
      def eql?(other)
        case other
        when PGRange
          if db_type == other.db_type
            if empty?
              other.empty?
            elsif other.empty?
              false
            else
              [:@begin, :@end, :@exclude_begin, :@exclude_end].all?{|v| instance_variable_get(v) == other.instance_variable_get(v)}
            end
          else
            false
          end
        when Range
          if valid_ruby_range?
            to_range.eql?(other)
          else
            false
          end
        else
          false
        end
      end
      alias == eql?

      # Make sure equal ranges have the same hash.
      def hash
        if @empty
          @db_type.hash
        else
          [@begin, @end, @exclude_begin, @exclude_end, @db_type].hash
        end
      end

      # Allow PGRange values in case statements, where they return true if they
      # are equal to each other using eql?, or if this PGRange can be converted
      # to a Range, delegating to that range.
      def ===(other)
        if eql?(other)
          true
        else
          if valid_ruby_range?
            to_range === other 
          else
            false
          end
        end
      end

      # Whether this range is empty (has no points).  Note that for manually created ranges
      # (ones not retrieved from the database), this will only be true if the range
      # was created using the :empty option.
      def empty?
        @empty
      end

      # Whether the beginning element is excluded from the range.
      def exclude_begin?
        @exclude_begin
      end

      # Whether the ending element is excluded from the range.
      def exclude_end?
        @exclude_end
      end

      # Append a literalize version of the receiver to the sql.
      def sql_literal_append(ds, sql)
        if (s = @db_type) && !empty?
          sql << s.to_s << "("
          ds.literal_append(sql, self.begin)
          sql << ','
          ds.literal_append(sql, self.end)
          sql << ','
          ds.literal_append(sql, "#{exclude_begin? ? "(" : "["}#{exclude_end? ? ")" : "]"}")
          sql << ")"
        else
          ds.literal_append(sql, unquoted_literal(ds))
          if s
            sql << '::' << s.to_s
          end
        end
      end

      ENDLESS_RANGE_NOT_SUPPORTED = RUBY_VERSION < '2.6'
      STARTLESS_RANGE_NOT_SUPPORTED = RUBY_VERSION < '2.7'

      # Return a ruby Range object for this instance, if one can be created.
      def to_range
        return @range if @range
        raise(Error, "cannot create ruby range for an empty PostgreSQL range") if empty?
        raise(Error, "cannot create ruby range when PostgreSQL range excludes beginning element") if exclude_begin?
        # :nocov:
        raise(Error, "cannot create ruby range when PostgreSQL range has unbounded beginning") if STARTLESS_RANGE_NOT_SUPPORTED && !self.begin
        raise(Error, "cannot create ruby range when PostgreSQL range has unbounded ending") if ENDLESS_RANGE_NOT_SUPPORTED && !self.end
        # :nocov:
        @range = Range.new(self.begin, self.end, exclude_end?)
      end

      # Whether or not this PGRange is a valid ruby range.  In order to be a valid ruby range,
      # it must have a beginning and an ending (no unbounded ranges), and it cannot exclude
      # the beginning element.
      def valid_ruby_range?
        !(empty? || exclude_begin? || (STARTLESS_RANGE_NOT_SUPPORTED && !self.begin) || (ENDLESS_RANGE_NOT_SUPPORTED && !self.end))
      end

      # Whether the beginning of the range is unbounded.
      def unbounded_begin?
        self.begin.nil? && !empty?
      end

      # Whether the end of the range is unbounded.
      def unbounded_end?
        self.end.nil? && !empty?
      end

      # Return a string containing the unescaped version of the range.
      # Separated out for use by the bound argument code.
      def unquoted_literal(ds)
        if empty?
          'empty'
        else
          "#{exclude_begin? ? "(" : "["}#{escape_value(self.begin, ds)},#{escape_value(self.end, ds)}#{exclude_end? ? ")" : "]"}"
        end
      end

      # Allow automatic parameterization for ranges with types.
      def sequel_auto_param_type(ds)
        "::#{db_type}" if db_type
      end

      private

      # Escape common range types.  Instead of quoting, just backslash escape all
      # special characters.
      def escape_value(k, ds)
        case k
        when nil
          ''
        when Date, Time
          ds.literal(k)[1...-1]
        when Integer, Float
          k.to_s
        when BigDecimal
          k.to_s('F')
        when LiteralString
          k
        when String
          if k.empty?
            '""'
          else
            k.gsub(/("|,|\\|\[|\]|\(|\))/, '\\\\\1')
          end
        else
          ds.literal(k).gsub(/("|,|\\|\[|\]|\(|\))/, '\\\\\1')
        end
      end
    end
  end

  module SQL::Builders
    # Convert the object to a Postgres::PGRange.
    def pg_range(v, db_type=nil)
      case v
      when Postgres::PGRange
        if db_type.nil? || v.db_type == db_type
          v
        else
          Postgres::PGRange.new(v.begin, v.end, :exclude_begin=>v.exclude_begin?, :exclude_end=>v.exclude_end?, :db_type=>db_type)
        end
      when Range
        Postgres::PGRange.from_range(v, db_type)
      else
        # May not be defined unless the pg_range_ops extension is used
        pg_range_op(v)
      end
    end
  end

  Database.register_extension(:pg_range, Postgres::PGRange::DatabaseMethods)
end

# :nocov:
if Sequel.core_extensions?
  class Range 
    # Create a new PGRange using the receiver as the input range,
    # with the given database type.
    def pg_range(db_type=nil)
      Sequel::Postgres::PGRange.from_range(self, db_type)
    end
  end
end

if defined?(Sequel::CoreRefinements)
  module Sequel::CoreRefinements
    refine Range do
      def pg_range(db_type=nil)
        Sequel::Postgres::PGRange.from_range(self, db_type)
      end
    end
  end
end
# :nocov:
