module Consul
  module Power
    include Consul::Power::DynamicAccess::InstanceMethods

    def self.included(base)
      base.extend ClassMethods
      base.send :include, Memoized
    end

    private

    def default_include_power?(power_name, *context)
      result = send(power_name, *context)
      # Everything that is not nil is considered as included.
      # We are short-circuiting for #scoped first since sometimes
      # has_many associations (which behave scopish) trigger their query
      # when you try to negate them, compare them or even retrieve their
      # class. Unfortunately we can only reproduce this in live Rails
      # apps, not in Consul tests. Might be some standard gem that is not
      # loaded in Consul tests.
      result.respond_to?(:load_target, true) || !!result
    end

    def default_include_object?(power_name, *args)
      check_number_of_arguments_in_include_object(power_name, args.length)
      object = args.pop
      context = args
      power_value = send(power_name, *context)
      if power_value.nil?
        false
      elsif Util.scope?(power_value)
        if Util.scope_selects_all_records?(power_value)
          true
        else
          power_ids_name = self.class.power_ids_name(power_name)
          send(power_ids_name, *context).include?(object.id)
        end
      elsif Util.collection?(power_value)
        power_value.include?(object)
      else
        raise Consul::NoCollection, "can only call #include_object? on a collection, but power was of type #{power_value.class.name}"
      end
    end

    def default_power_ids(power_name, *args)
      scope = send(power_name, *args)
      database_touched
      scope.collect_ids
    end

    def powerless!(*args)
      raise Consul::Powerless.new("No power to #{[*args].inspect}")
    end

    def boolean_or_nil?(value)
      [TrueClass, FalseClass, NilClass].include?(value.class)
    end

    def database_touched
      # spy for tests
    end

    def singularize_power_name(name)
      self.class.singularize_power_name(name)
    end

    def check_number_of_arguments_in_include_object(power_name, given_arguments)
      # check unmemoized methods as Memoizer wraps methods and masks the arity.
      unmemoized_power_name = respond_to?("_unmemoized_#{power_name}") ? "_unmemoized_#{power_name}" : power_name
      power_arity = method(unmemoized_power_name).arity
      expected_arity = power_arity + 1 # one additional argument for the context
      if power_arity >= 0 && expected_arity != given_arguments
        raise ArgumentError.new("wrong number of arguments (given #{given_arguments}, expected #{expected_arity})")
      end
    end

    module ClassMethods
      include Consul::Power::DynamicAccess::ClassMethods

      def power(*names, &block)
        names.each do |name|
          define_power(name, &block)
        end
      end

      def power_ids_name(name)
        "#{name.to_s.singularize}_ids"
      end

      def self.thread_key(klass)
        "consul|#{klass.to_s}.current"
      end

      def current
        Thread.current[ClassMethods.thread_key(self)]
      end

      def current=(power)
        Thread.current[ClassMethods.thread_key(self)] = power
      end

      def with_power(*args, **kwargs , &block)
        inner_power = if args.first.is_a?(self)
          args.first
        elsif args.length == 1 && args.first.nil?
          nil
        elsif kwargs.empty?
          new(*args)
        else
          new(*args, **kwargs)
        end

        old_power = current
        self.current = inner_power
        block.call
      ensure
        self.current = old_power
      end

      def without_power(&block)
        with_power(nil, &block)
      end

      def define_query_and_bang_methods(name, options, &query)
        is_plural = options.fetch(:is_plural)
        query_method = "#{name}?"
        bang_method = "#{name}!"
        define_method(query_method, &query)
        memoize query_method
        define_method(bang_method) do |*args|
          if is_plural
            if send(query_method, *args)
              send(name, *args)
            else
              powerless!(name, *args)
            end
          else
            send(query_method, *args) or powerless!(name, *args)
          end
        end
        # We don't memoize the bang method since memoizer can't memoize a thrown exception
      end

      def define_ids_method(name)
        ids_method = power_ids_name(name)
        define_method(ids_method) { |*args| default_power_ids(name, *args) }
        # Memoize `ids_method` in addition to the collection method itself, since
        # #default_include_object? directly accesses `ids_method`.
        memoize ids_method
      end

      def define_main_method(name, &block)
        define_method(name, &block)
        memoize name
      end

      def define_power(name, &block)
        name = name.to_s
        if name.ends_with?('?')
          # The developer is trying to register an optimized query method
          # for singular object queries.
          name_without_suffix = name.chop
          define_query_and_bang_methods(name_without_suffix, :is_plural => false, &block)
        else
          define_main_method(name, &block)
          define_ids_method(name)
          define_query_and_bang_methods(name, :is_plural => true) { |*args| default_include_power?(name, *args) }
          begin
            singular = singularize_power_name(name)
            define_query_and_bang_methods(singular, :is_plural => false) { |*args| default_include_object?(name, *args) }
          rescue Consul::PowerNotSingularizable
            # We do not define singularized power methods if it would
            # override the collection method
          end
        end
        name
      end

      def singularize_power_name(name)
        name = name.to_s
        singularized = name.singularize
        if singularized == name
          raise Consul::PowerNotSingularizable, "Power name can not have a singular form: #{name}"
        else
          singularized
        end
      end

    end
  end
end
