# frozen-string-literal: true

module Sequel
  # Empty namespace that plugins should use to store themselves,
  # so they can be loaded via Model.plugin.
  #
  # Plugins should be modules with one of the following conditions:
  # * A singleton method named apply, which takes a model, 
  #   additional arguments, and an optional block.  This is called
  #   the first time the plugin is loaded for this model (unless it was
  #   already loaded by an ancestor class), before including/extending
  #   any modules, with the arguments
  #   and block provided to the call to Model.plugin.
  # * A module inside the plugin module named ClassMethods,
  #   which will extend the model class.
  # * A module inside the plugin module named InstanceMethods,
  #   which will be included in the model class.
  # * A module inside the plugin module named DatasetMethods,
  #   which will extend the model's dataset.
  # * A singleton method named configure, which takes a model, 
  #   additional arguments, and an optional block.  This is called
  #   every time the Model.plugin method is called, after including/extending
  #   any modules.
  module Plugins
    # In the given module +mod+, define methods that are call the same method
    # on the dataset.  This is designed for plugins to define dataset methods
    # inside ClassMethods that call the implementations in DatasetMethods.
    #
    # This should not be called with untrusted input or method names that
    # can't be used literally, since it uses class_eval.
    def self.def_dataset_methods(mod, meths)
      Array(meths).each do |meth|
        mod.class_eval("def #{meth}(*args, &block); dataset.#{meth}(*args, &block) end", __FILE__, __LINE__)
        # :nocov:
        mod.send(:ruby2_keywords, meth) if respond_to?(:ruby2_keywords, true)
        # :nocov:
      end
    end

    # Add method to +mod+ that overrides inherited_instance_variables to include the
    # values in this hash. These affects how class instance variables will be treated
    # during subclassing.
    def self.inherited_instance_variables(mod, hash)
      mod.send(:define_method, :inherited_instance_variables) do ||
        super().merge!(hash)
      end
      mod.send(:private, :inherited_instance_variables)
    end

    # Sets the model instance variables used by the plugin. These instance variables
    # will be initialized to nil for created model objects.
    def self.model_instance_variables(mod, *ivs)
      mod.send(:define_method, :each_model_instance_variable) do |&block|
        super(&block)
        ivs.each(&block)
      end
      mod.send(:private, :each_model_instance_variable)
    end

    # Add method to +mod+ that overrides set_dataset to call the method afterward.
    def self.after_set_dataset(mod, meth)
      mod.send(:define_method, :set_dataset) do |*a|
        r = super(*a)
        # Allow calling private class methods as methods this specifies are usually private
        send(meth)
        r
      end
    end

    method_num = 0
    method_num_mutex = Mutex.new
    # Return a unique method name symbol for the given suffix.
    SEQUEL_METHOD_NAME = lambda do |suffix|
      :"_sequel_#{suffix}_#{method_num_mutex.synchronize{method_num += 1}}"
    end

    # Define a private instance method using the block with the provided name and
    # expected arity.  If the name is given as a Symbol, it is used directly.
    # If the name is given as a String, a unique name will be generated using
    # that string.  The expected_arity should be either 0 (no arguments) or
    # 1 (single argument).
    #
    # If a block with an arity that does not match the expected arity is used,
    # a deprecation warning will be issued. The method defined should still
    # work, though it will be slower than a method with the expected arity.
    #
    # Sequel only checks arity for regular blocks, not lambdas.  Lambdas were
    # already strict in regards to arity, so there is no need to try to fix
    # arity to keep backwards compatibility for lambdas.
    #
    # Blocks with required keyword arguments are not supported by this method.
    def self.def_sequel_method(model, meth, expected_arity, &block)
      if meth.is_a?(String)
        meth = SEQUEL_METHOD_NAME.call(meth)
      end
      call_meth = meth

      unless block.lambda?
        required_args, optional_args, rest, keyword = _define_sequel_method_arg_numbers(block)

        if keyword == :required
          raise Error, "cannot use block with required keyword arguments when calling define_sequel_method with expected arity #{expected_arity}"
        end

        case expected_arity
        when 0
          unless required_args == 0
            # SEQUEL6: remove
            Sequel::Deprecation.deprecate("Arity mismatch in block passed to define_sequel_method. Expected Arity 0, but arguments required for #{block.inspect}. Support for this will be removed in Sequel 6.")
            b = block
            block = lambda{instance_exec(&b)} # Fallback
          end
        when 1
          if required_args == 0 && optional_args == 0 && !rest
            # SEQUEL6: remove
            Sequel::Deprecation.deprecate("Arity mismatch in block passed to define_sequel_method. Expected Arity 1, but no arguments accepted for #{block.inspect}.  Support for this will be removed in Sequel 6.")
            temp_method = SEQUEL_METHOD_NAME.call("temp")
            model.class_eval("def #{temp_method}(_) #{meth =~ /\A\w+\z/ ? "#{meth}_arity" : "send(:\"#{meth}_arity\")"} end", __FILE__, __LINE__)
            model.send(:alias_method, meth, temp_method)
            model.send(:undef_method, temp_method)
            model.send(:private, meth)
            meth = :"#{meth}_arity"
          elsif required_args > 1
            # SEQUEL6: remove
            Sequel::Deprecation.deprecate("Arity mismatch in block passed to define_sequel_method. Expected Arity 1, but more arguments required for #{block.inspect}.  Support for this will be removed in Sequel 6.")
            b = block
            block = lambda{|r| instance_exec(r, &b)} # Fallback
          end
        else
          raise Error, "unexpected arity passed to define_sequel_method: #{expected_arity.inspect}"
        end
      end

      model.send(:define_method, meth, &block)
      model.send(:private, meth)
      model.send(:alias_method, meth, meth)
      call_meth
    end

    # Return the number of required argument, optional arguments,
    # whether the callable accepts any additional arguments,
    # and whether the callable accepts keyword arguments (true, false
    # or :required).
    def self._define_sequel_method_arg_numbers(callable)
      optional_args = 0
      rest = false
      keyword = false
      callable.parameters.map(&:first).each do |arg_type, _|
        case arg_type
        when :opt
          optional_args += 1
        when :rest
          rest = true
        when :keyreq
          keyword = :required
        when :key, :keyrest
          keyword ||= true
        else
          raise Error, "invalid arg_type passed to _define_sequel_method_arg_numbers: #{arg_type}"
        end
      end
      arity = callable.arity
      if arity < 0
        arity = arity.abs - 1
      end
      required_args = arity
      arity -= 1 if keyword == :required

      # callable currently is always a non-lambda Proc
      optional_args -= arity

      [required_args, optional_args, rest, keyword]
    end
    private_class_method :_define_sequel_method_arg_numbers
  end
end
