# frozen_string_literal: true
module GraphQL
  module Define
    # @api deprecated
    module InstanceDefinable
      def self.included(base)
        base.extend(ClassMethods)
        base.ensure_defined(:metadata)
      end

      # @api deprecated
      def metadata
        @metadata ||= {}
      end

      # @api deprecated
      def define(**kwargs, &block)
        # make sure the previous definition_proc was executed:
        ensure_defined
        stash_dependent_methods
        @pending_definition = Definition.new(kwargs, block)
        nil
      end

      # @api deprecated
      def redefine(**kwargs, &block)
        ensure_defined
        new_inst = self.dup
        new_inst.define(**kwargs, &block)
        new_inst
      end

      def initialize_copy(other)
        super
        @metadata = other.metadata.dup
      end

      private

      # Run the definition block if it hasn't been run yet.
      # This can only be run once: the block is deleted after it's used.
      # You have to call this before using any value which could
      # come from the definition block.
      # @return [void]
      def ensure_defined
        if @pending_definition
          defn = @pending_definition
          @pending_definition = nil

          revive_dependent_methods

          begin
            defn_proxy = DefinedObjectProxy.new(self)
            # Apply definition from `define(...)` kwargs
            defn.define_keywords.each do |keyword, value|
              # Don't splat string hashes, which blows up on Rubies before 2.7
              if value.is_a?(Hash) && value.each_key.all? { |k| k.is_a?(Symbol) }
                defn_proxy.public_send(keyword, **value)
              else
                defn_proxy.public_send(keyword, value)
              end
            end
            # and/or apply definition from `define { ... }` block
            if defn.define_proc
              defn_proxy.instance_eval(&defn.define_proc)
            end
          rescue StandardError
            # The definition block failed to run, so make this object pending again:
            stash_dependent_methods
            @pending_definition = defn
            raise
          end
        end
        nil
      end

      # Take the pending methods and put them back on this object's singleton class.
      # This reverts the process done by {#stash_dependent_methods}
      # @return [void]
      def revive_dependent_methods
        pending_methods = @pending_methods
        self.singleton_class.class_eval {
          pending_methods.each do |method|
            undef_method(method.name) if method_defined?(method.name)
            define_method(method.name, method)
          end
        }
        @pending_methods = nil
      end

      # Find the method names which were declared as definition-dependent,
      # then grab the method definitions off of this object's class
      # and store them for later.
      #
      # Then make a dummy method for each of those method names which:
      #
      # - Triggers the pending definition, if there is one
      # - Calls the same method again.
      #
      # It's assumed that {#ensure_defined} will put the original method definitions
      # back in place with {#revive_dependent_methods}.
      # @return [void]
      def stash_dependent_methods
        method_names = self.class.ensure_defined_method_names
        @pending_methods = method_names.map { |n| self.class.instance_method(n) }
        self.singleton_class.class_eval do
          method_names.each do |method_name|
            undef_method(method_name) if method_defined?(method_name)
            define_method(method_name) { |*args, &block|
              ensure_defined
              self.send(method_name, *args, &block)
            }
          end
        end
      end

      class Definition
        attr_reader :define_keywords, :define_proc
        def initialize(define_keywords, define_proc)
          @define_keywords = define_keywords
          @define_proc = define_proc
        end
      end

      module ClassMethods
        # Create a new instance
        # and prepare a definition using its {.definitions}.
        # @param kwargs [Hash] Key-value pairs corresponding to defininitions from `accepts_definitions`
        # @param block [Proc] Block which calls helper methods from `accepts_definitions`
        def define(**kwargs, &block)
          instance = self.new
          instance.define(**kwargs, &block)
          instance
        end

        # Attach definitions to this class.
        # Each symbol in `accepts` will be assigned with `{key}=`.
        # The last entry in accepts may be a hash of name-proc pairs for custom definitions.
        def accepts_definitions(*accepts)
          new_assignments = if accepts.last.is_a?(Hash)
            accepts.pop.dup
          else
            {}
          end

          accepts.each do |key|
            new_assignments[key] = AssignAttribute.new(key)
          end

          @own_dictionary = own_dictionary.merge(new_assignments)
        end

        def ensure_defined(*method_names)
          @ensure_defined_method_names ||= []
          @ensure_defined_method_names.concat(method_names)
          nil
        end

        def ensure_defined_method_names
          own_method_names = @ensure_defined_method_names || []
          if superclass.respond_to?(:ensure_defined_method_names)
            superclass.ensure_defined_method_names + own_method_names
          else
            own_method_names
          end
        end

        # @return [Hash] combined definitions for self and ancestors
        def dictionary
          if superclass.respond_to?(:dictionary)
            own_dictionary.merge(superclass.dictionary)
          else
            own_dictionary
          end
        end

        # @return [Hash] definitions for this class only
        def own_dictionary
          @own_dictionary ||= {}
        end
      end

      class AssignMetadataKey
        def initialize(key)
          @key = key
        end

        def call(defn, value = true)
          defn.metadata[@key] = value
        end
      end

      class AssignAttribute
        extend GraphQL::Ruby2Keywords

        def initialize(attr_name)
          @attr_assign_method = :"#{attr_name}="
        end

        # Even though we're just using the first value here,
        # We have to add a splat here to use `ruby2_keywords`,
        # so that it will accept a `[{}]` input from the caller.
        def call(defn, *value)
          defn.public_send(@attr_assign_method, value.first)
        end
        ruby2_keywords :call
      end
    end
  end
end
