# frozen-string-literal: true

module Sequel
  module Plugins
    # The auto_validations plugin automatically sets up the following types of validations
    # for your model columns:
    #
    # 1. type validations for all columns
    # 2. not_null validations on NOT NULL columns (optionally, presence validations)
    # 3. unique validations on columns or sets of columns with unique indexes
    # 4. max length validations on string columns
    # 5. no null byte validations on string columns
    # 6. minimum and maximum values on columns
    #
    # To determine the columns to use for the type/not_null/max_length/no_null_byte/max_value/min_value validations,
    # the plugin looks at the database schema for the model's table.  To determine
    # the unique validations, Sequel looks at the indexes on the table.  In order
    # for this plugin to be fully functional, the underlying database adapter needs
    # to support both schema and index parsing.  Additionally, unique validations are
    # only added for models that select from a simple table, they are not added for models
    # that select from a subquery.
    #
    # This plugin uses the validation_helpers plugin underneath to implement the
    # validations.  It does not allow for any per-column validation message
    # customization, but you can alter the messages for the given type of validation
    # on a per-model basis (see the validation_helpers documentation).
    #
    # You can skip certain types of validations from being automatically added via:
    #
    #   Model.skip_auto_validations(:not_null)
    #
    # If you want to skip all auto validations (only useful if loading the plugin
    # in a superclass):
    #
    #   Model.skip_auto_validations(:all)
    #
    # It is possible to skip auto validations on a per-model-instance basis via:
    #
    #   instance.skip_auto_validations(:unique, :not_null) do
    #     puts instance.valid?
    #   end
    #
    # By default, the plugin uses a not_null validation for NOT NULL columns, but that
    # can be changed to a presence validation using an option:
    #
    #   Model.plugin :auto_validations, not_null: :presence
    #
    # This is useful if you want to enforce that NOT NULL string columns do not
    # allow empty values.
    #
    # You can also supply hashes to pass options through to the underlying validators:
    #
    #   Model.plugin :auto_validations, unique_opts: {only_if_modified: true}
    #
    # This works for unique_opts, max_length_opts, schema_types_opts, max_value_opts, min_value_opts, no_null_byte_opts,
    # explicit_not_null_opts, and not_null_opts.
    #
    # If you only want auto_validations to add validations to columns that do not already
    # have an error associated with them, you can use the skip_invalid option:
    #
    #   Model.plugin :auto_validations, skip_invalid: true
    #
    # Usage:
    #
    #   # Make all model subclass use auto validations (called before loading subclasses)
    #   Sequel::Model.plugin :auto_validations
    #
    #   # Make the Album class use auto validations
    #   Album.plugin :auto_validations
    module AutoValidations
      NOT_NULL_OPTIONS = {:from=>:values}.freeze
      EXPLICIT_NOT_NULL_OPTIONS = {:from=>:values, :allow_missing=>true}.freeze
      MAX_LENGTH_OPTIONS = {:from=>:values, :allow_nil=>true}.freeze
      SCHEMA_TYPES_OPTIONS = NOT_NULL_OPTIONS
      UNIQUE_OPTIONS = NOT_NULL_OPTIONS
      NO_NULL_BYTE_OPTIONS = MAX_LENGTH_OPTIONS
      MAX_VALUE_OPTIONS = {:from=>:values, :allow_nil=>true, :skip_invalid=>true}.freeze
      MIN_VALUE_OPTIONS = MAX_VALUE_OPTIONS
      AUTO_VALIDATE_OPTIONS = {
        :no_null_byte=>NO_NULL_BYTE_OPTIONS,
        :not_null=>NOT_NULL_OPTIONS,
        :explicit_not_null=>EXPLICIT_NOT_NULL_OPTIONS,
        :max_length=>MAX_LENGTH_OPTIONS,
        :max_value=>MAX_VALUE_OPTIONS,
        :min_value=>MIN_VALUE_OPTIONS,
        :schema_types=>SCHEMA_TYPES_OPTIONS,
        :unique=>UNIQUE_OPTIONS
      }.freeze

      EMPTY_ARRAY = [].freeze

      def self.apply(model, opts=OPTS)
        model.instance_exec do
          plugin :validation_helpers
          @auto_validate_presence = false
          @auto_validate_no_null_byte_columns = []
          @auto_validate_not_null_columns = []
          @auto_validate_explicit_not_null_columns = []
          @auto_validate_max_length_columns = []
          @auto_validate_max_value_columns = []
          @auto_validate_min_value_columns = []
          @auto_validate_unique_columns = []
          @auto_validate_types = true
          @auto_validate_options = AUTO_VALIDATE_OPTIONS
        end
      end

      # Setup auto validations for the model if it has a dataset.
      def self.configure(model, opts=OPTS)
        model.instance_exec do
          setup_auto_validations if @dataset
          if opts[:not_null] == :presence
            @auto_validate_presence = true
          end

          h = @auto_validate_options.dup
          [:not_null, :explicit_not_null, :max_length, :max_value, :min_value, :no_null_byte, :schema_types, :unique].each do |type|
            if type_opts = opts[:"#{type}_opts"]
              h[type] = h[type].merge(type_opts).freeze
            end
          end

          if opts[:skip_invalid]
            [:not_null, :explicit_not_null, :no_null_byte, :max_length, :schema_types].each do |type|
              h[type] = h[type].merge(:skip_invalid=>true).freeze
            end
          end

          @auto_validate_options = h.freeze
        end
      end

      module ClassMethods
        # The columns with automatic no_null_byte validations
        attr_reader :auto_validate_no_null_byte_columns

        # The columns with automatic not_null validations
        attr_reader :auto_validate_not_null_columns

        # The columns with automatic not_null validations for columns present in the values.
        attr_reader :auto_validate_explicit_not_null_columns

        # The columns or sets of columns with automatic max_length validations, as an array of
        # pairs, with the first entry being the column name and second entry being the maximum length.
        attr_reader :auto_validate_max_length_columns

        # The columns with automatch max value validations, as an array of
        # pairs, with the first entry being the column name and second entry being the maximum value.
        attr_reader :auto_validate_max_value_columns

        # The columns with automatch min value validations, as an array of
        # pairs, with the first entry being the column name and second entry being the minimum value.
        attr_reader :auto_validate_min_value_columns

        # The columns or sets of columns with automatic unique validations
        attr_reader :auto_validate_unique_columns

        # Inherited options
        attr_reader :auto_validate_options

        Plugins.inherited_instance_variables(self,
          :@auto_validate_presence=>nil,
          :@auto_validate_types=>nil,
          :@auto_validate_no_null_byte_columns=>:dup,
          :@auto_validate_not_null_columns=>:dup,
          :@auto_validate_explicit_not_null_columns=>:dup,
          :@auto_validate_max_length_columns=>:dup,
          :@auto_validate_max_value_columns=>:dup,
          :@auto_validate_min_value_columns=>:dup,
          :@auto_validate_unique_columns=>:dup,
          :@auto_validate_options => :dup)
        Plugins.after_set_dataset(self, :setup_auto_validations)

        # Whether to use a presence validation for not null columns
        def auto_validate_presence?
          @auto_validate_presence
        end

        # Whether to automatically validate schema types for all columns
        def auto_validate_types?
          @auto_validate_types
        end

        # Freeze auto_validation settings when freezing model class.
        def freeze
          @auto_validate_no_null_byte_columns.freeze
          @auto_validate_not_null_columns.freeze
          @auto_validate_explicit_not_null_columns.freeze
          @auto_validate_max_length_columns.freeze
          @auto_validate_max_value_columns.freeze
          @auto_validate_min_value_columns.freeze
          @auto_validate_unique_columns.freeze

          super
        end

        # Skip automatic validations for the given validation type
        # (:not_null, :no_null_byte, :types, :unique, :max_length, :max_value, :min_value).
        # If :all is given as the type, skip all auto validations.
        #
        # Skipping types validation automatically skips max_value and min_value validations,
        # since those validations require valid types.
        def skip_auto_validations(type)
          case type
          when :all
            [:not_null, :no_null_byte, :types, :unique, :max_length, :max_value, :min_value].each{|v| skip_auto_validations(v)}
          when :not_null
            auto_validate_not_null_columns.clear
            auto_validate_explicit_not_null_columns.clear
          when :types
            @auto_validate_types = false
          else
            public_send("auto_validate_#{type}_columns").clear
          end
        end

        private

        # Parse the database schema and indexes and record the columns to automatically validate.
        def setup_auto_validations
          not_null_cols, explicit_not_null_cols = db_schema.select{|col, sch| sch[:allow_null] == false}.partition{|col, sch| sch[:default].nil?}.map{|cs| cs.map{|col, sch| col}}
          @auto_validate_not_null_columns = not_null_cols - Array(primary_key)
          explicit_not_null_cols += Array(primary_key)
          @auto_validate_explicit_not_null_columns = explicit_not_null_cols.uniq
          @auto_validate_max_length_columns = db_schema.select{|col, sch| sch[:type] == :string && sch[:max_length].is_a?(Integer)}.map{|col, sch| [col, sch[:max_length]]}
          @auto_validate_max_value_columns = db_schema.select{|col, sch| sch[:max_value]}.map{|col, sch| [col, sch[:max_value]]}
          @auto_validate_min_value_columns = db_schema.select{|col, sch| sch[:min_value]}.map{|col, sch| [col, sch[:min_value]]}
          @auto_validate_no_null_byte_columns = db_schema.select{|_, sch| sch[:type] == :string}.map{|col, _| col}
          table = dataset.first_source_table
          @auto_validate_unique_columns = if db.supports_index_parsing? && [Symbol, SQL::QualifiedIdentifier, SQL::Identifier, String].any?{|c| table.is_a?(c)}
            db.indexes(table).select{|name, idx| idx[:unique] == true}.map{|name, idx| idx[:columns].length == 1 ? idx[:columns].first : idx[:columns]}
          else
            []
          end
        end
      end

      module InstanceMethods
        # Skip the given types of auto validations on this instance inside the block.
        def skip_auto_validations(*types)
          types << :all if types.empty?
          @_skip_auto_validations = types
          yield
        ensure
          @_skip_auto_validations = nil
        end

        # Validate the model's auto validations columns
        def validate
          super
          skip = @_skip_auto_validations || EMPTY_ARRAY
          return if skip.include?(:all)
          opts = model.auto_validate_options

          unless skip.include?(:no_null_byte) || (no_null_byte_columns = model.auto_validate_no_null_byte_columns).empty?
            validates_no_null_byte(no_null_byte_columns, opts[:no_null_byte])
          end

          unless skip.include?(:not_null)
            not_null_method = model.auto_validate_presence? ? :validates_presence : :validates_not_null
            unless (not_null_columns = model.auto_validate_not_null_columns).empty?
              public_send(not_null_method, not_null_columns, opts[:not_null])
            end
            unless (not_null_columns = model.auto_validate_explicit_not_null_columns).empty?
              public_send(not_null_method, not_null_columns, opts[:explicit_not_null])
            end
          end

          unless skip.include?(:max_length) || (max_length_columns = model.auto_validate_max_length_columns).empty?
            max_length_columns.each do |col, len|
              validates_max_length(len, col, opts[:max_length])
            end
          end

          unless skip.include?(:types) || !model.auto_validate_types?
            validates_schema_types(keys, opts[:schema_types])

            unless skip.include?(:max_value) || ((max_value_columns = model.auto_validate_max_value_columns).empty?)
              max_value_columns.each do |col, max|
                validates_max_value(max, col, opts[:max_value])
              end
            end

            unless skip.include?(:min_value) || ((min_value_columns = model.auto_validate_min_value_columns).empty?)
              min_value_columns.each do |col, min|
                validates_min_value(min, col, opts[:min_value])
              end
            end
          end

          unless skip.include?(:unique)
            unique_opts = Hash[opts[:unique]]
            if model.respond_to?(:sti_dataset)
              unique_opts[:dataset] = model.sti_dataset
            end
            model.auto_validate_unique_columns.each{|cols| validates_unique(cols, unique_opts)}
          end
        end
      end
    end
  end
end
