require 'forwardable'
require 'lotus/utils/kernel'

# Lotus namespace
#
# @since 0.1.0
module Lotus
  # Lotus::Model namespace
  #
  # @since 0.1.0
  module Model
    # Lotus::Adapters namespace
    #
    # @since 0.1.0
    module Adapters
      # Lotus::Adapters::Dynamodb namespace
      #
      # @since 0.1.0
      module Dynamodb
        # Query DynamoDB table with a powerful API.
        #
        # All the methods are chainable, it allows advanced composition of
        # options.
        #
        # This works as a lazy filtering mechanism: the records are fetched from
        # the DynamoDB only when needed.
        #
        # @example
        #
        #   query.where(language: 'ruby')
        #        .and(framework: 'lotus')
        #        .all
        #
        #   # the records are fetched only when we invoke #all
        #
        # It implements Ruby's `Enumerable` and borrows some methods from `Array`.
        # Expect a query to act like them.
        #
        # @since 0.1.0
        class Query
          include Enumerable
          extend  Forwardable

          def_delegators :all, :to_s, :empty?

          # @attr_reader operation [Symbol] operation to perform
          #
          # @since 0.1.0
          # @api private
          attr_reader :operation

          # @attr_reader options [Hash] an accumulator for the query options
          #
          # @since 0.1.0
          # @api private
          attr_reader :options

          # Initialize a query
          #
          # @param dataset [Lotus::Model::Adapters::Dynamodb::Collection]
          # @param collection [Lotus::Model::Mapping::Collection]
          # @param blk [Proc] an optional block that gets yielded in the
          #   context of the current query
          #
          # @since 0.1.0
          # @api private
          def initialize(dataset, collection, &blk)
            @dataset    = dataset
            @collection = collection

            @operation  = :scan
            @options    = {}

            instance_eval(&blk) if block_given?
          end

          # Resolves the query by fetching records from the database and
          # translating them into entities.
          #
          # @return [Array] a collection of entities
          #
          # @since 0.1.0
          def all
            response = run

            while continue?(response)
              @options[:exclusive_start_key] = response.last_evaluated_key
              response = run(response)
            end

            @collection.deserialize(response.entities)
          end

          # Iterates over fetched records.
          #
          # @return [Integer] total count of records
          #
          # @since 0.1.1
          def each
            response = run

            entities = @collection.deserialize(response.entities)
            entities.each { |x| yield(x) }

            while continue?(response)
              response.entities = []

              @options[:exclusive_start_key] = response.last_evaluated_key
              response = run(response)

              entities = @collection.deserialize(response.entities)
              entities.each { |x| yield(x) }
            end

            response.count
          end

          # Set operation to be query instead of scan.
          #
          # @return self
          #
          # @since 0.1.0
          def query
            @operation = :query
            self
          end

          # Adds a condition that behaves like SQL `WHERE`.
          #
          # It accepts a `Hash` with only one pair.
          # The key must be the name of the column expressed as a `Symbol`.
          # The value is the one used by the internal filtering logic.
          #
          # @param condition [Hash]
          #
          # @return self
          #
          # @since 0.1.0
          #
          # @example Fixed value
          #
          #   query.where(language: 'ruby')
          #
          # @example Array
          #
          #   query.where(id: [1, 3])
          #
          # @example Range
          #
          #   query.where(year: 1900..1982)
          #
          # @example Multiple conditions
          #
          #   query.where(language: 'ruby')
          #        .where(framework: 'lotus')
          def where(condition)
            key = key_for_condition(condition)
            serialized = serialize_condition(condition)

            if serialized
              @options[key] ||= {}
              @options[key].merge!(serialized)
            end

            self
          end

          alias_method :eq, :where
          alias_method :in, :where
          alias_method :between, :where

          # Sets DynamoDB ConditionalOperator to `OR`. This works query-wide
          # so this method has no arguments.
          #
          # @return self
          #
          # @since 0.1.0
          def or
            @options[:conditional_operator] = "OR"
            self
          end

          # Logical negation of a #where condition.
          #
          # It accepts a `Hash` with only one pair.
          # The key must be the name of the column expressed as a `Symbol`.
          # The value is the one used by the internal filtering logic.
          #
          # @param condition [Hash]
          #
          # @since 0.1.0
          #
          # @return self
          #
          # @example Fixed value
          #
          #   query.exclude(language: 'java')
          #
          # @example Multiple conditions
          #
          #   query.exclude(language: 'java')
          #        .exclude(company: 'enterprise')
          def exclude(condition)
            key = key_for_condition(condition)
            serialized = serialize_condition(condition, negate: true)

            if serialized
              @options[key] ||= {}
              @options[key].merge!(serialized)
            end

            self
          end

          alias_method :not, :exclude
          alias_method :ne, :exclude

          # Perform LE comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def le(condition); comparison(condition, 'LE'); end

          # Perform LT comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def lt(condition); comparison(condition, 'LT'); end

          # Perform GE comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def ge(condition); comparison(condition, 'GE'); end

          # Perform GT comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def gt(condition); comparison(condition, 'GT'); end

          # Perform CONTAINS comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def contains(condition); comparison(condition, 'CONTAINS'); end

          # Perform NOT_CONTAINS comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def not_contains(condition); comparison(condition, 'NOT_CONTAINS'); end

          # Perform BEGINS_WITH comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def begins_with(condition); comparison(condition, 'BEGINS_WITH'); end

          # Perform NULL comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def null(column); comparison({ column => '' }, 'NULL'); end

          # Perform NOT_NULL comparison.
          #
          # @return self
          #
          # @since 0.1.0
          def not_null(column); comparison({ column => '' }, 'NOT_NULL'); end

          # Perform comparison operation.
          #
          # @return self
          #
          # @api private
          # @since 0.1.0
          def comparison(condition, operator)
            key = key_for_condition(condition)
            serialized = serialize_condition(condition, operator: operator)

            if serialized
              @options[key] ||= {}
              @options[key].merge!(serialized)
            end

            self
          end

          # Select only the specified columns.
          #
          # By default a query selects all the mapped columns.
          #
          # @param columns [Array<Symbol>]
          #
          # @return self
          #
          # @since 0.1.0
          #
          # @example Single column
          #
          #   query.select(:name)
          #
          # @example Multiple columns
          #
          #   query.select(:name, :year)
          def select(*columns)
            @options[:select] = "SPECIFIC_ATTRIBUTES"
            @options[:attributes_to_get] = columns.map(&:to_s)
            self
          end

          # Specify the ascending order of the records, sorted by the range key.
          #
          # @return self
          #
          # @since 0.1.0
          #
          # @see Lotus::Model::Adapters::Dynamodb::Query#desc
          def order(*columns)
            warn "DynamoDB only supports order by range_key" if columns.any?

            query
            @options[:scan_index_forward] = true
            self
          end

          alias_method :asc, :order

          # Specify the descending order of the records, sorted by the range key.
          #
          # @return self
          #
          # @since 0.1.0
          #
          # @see Lotus::Model::Adapters::Dynamodb::Query#asc
          def desc(*columns)
            warn "DynamoDB only supports order by range_key" if columns.any?

            query
            @options[:scan_index_forward] = false
            self
          end

          # Limit the number of records to return.
          #
          # @param number [Fixnum]
          #
          # @return self
          #
          # @since 0.1.0
          #
          # @example
          #
          #   query.limit(1)
          def limit(number)
            @options[:limit] = number
            self
          end

          # This method is not implemented. DynamoDB does not provide offset.
          #
          # @param number [Fixnum]
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def offset(number)
            raise NotImplementedError
          end

          # This method is not implemented. DynamoDB does not provide sum.
          #
          # @param column [Symbol] the column name
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def sum(column)
            raise NotImplementedError
          end

          # This method is not implemented. DynamoDB does not provide average.
          #
          # @param column [Symbol] the column name
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def average(column)
            raise NotImplementedError
          end

          alias_method :avg, :average

          # This method is not implemented. DynamoDB does not provide max.
          #
          # @param column [Symbol] the column name
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def max(column)
            raise NotImplementedError
          end

          # This method is not implemented. DynamoDB does not provide min.
          #
          # @param column [Symbol] the column name
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def min(column)
            raise NotImplementedError
          end

          # This method is not implemented. DynamoDB does not provide interval.
          #
          # @param column [Symbol] the column name
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def interval(column)
            raise NotImplementedError
          end

          # This method is not implemented. DynamoDB does not provide range.
          #
          # @param column [Symbol] the column name
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def range(column)
            raise NotImplementedError
          end

          # Checks if at least one record exists for the current conditions.
          #
          # @return [TrueClass,FalseClass]
          #
          # @since 0.1.0
          #
          # @example
          #
          #    query.where(author_id: 23).exists? # => true
          def exists?
            !count.zero?
          end

          alias_method :exist?, :exists?

          # Returns a count of the records for the current conditions.
          #
          # @return [Fixnum]
          #
          # @since 0.1.0
          #
          # @example
          #
          #    query.where(author_id: 23).count # => 5
          def count
            @options[:select] = "COUNT"
            @options.delete(:attributes_to_get)

            response = run

            while continue?(response)
              @options[:exclusive_start_key] = response.last_evaluated_key
              response = run(response)
            end

            response.count
          end

          # This method is not implemented.
          #
          # @raise [NotImplementedError]
          #
          # @since 0.1.0
          def negate!
            raise NotImplementedError
          end

          # Tells DynamoDB to use consistent reads.
          #
          # @return self
          #
          # @since 0.1.0
          def consistent
            query
            @options[:consistent_read] = true
            self
          end

          # Tells DynamoDB which index to use.
          #
          # @return self
          #
          # @since 0.1.0
          def index(name)
            query
            @options[:index_name] = name.to_s
            self
          end

          private
          # Return proper options key for a given condition.
          #
          # @param condition [Hash] the condition
          #
          # @return [Symbol] the key
          #
          # @api private
          # @since 0.1.0
          def key_for_condition(condition)
            if @dataset.key?(condition.keys.first, @options[:index_name])
              query
              :key_conditions
            elsif operation == :scan
              :scan_filter
            else
              :query_filter
            end
          end

          # Serialize given condition for DynamoDB API.
          #
          # @param condition [Hash] the condition
          # @param negate [Boolean] true when negative condition
          #
          # @return [Hash] the serialized condition
          #
          # @api private
          # @since 0.1.0
          def serialize_condition(condition, operator: nil, negate: false)
            column, value = condition.keys.first, condition.values.first

            operator ||= case
            when value.is_a?(Array)
              negate ? nil : "IN"
            when value.is_a?(Range)
              negate ? nil : "BETWEEN"
            else
              negate ? "NE" : "EQ"
            end

            # Operator is not supported
            raise NotImplementedError unless operator

            values ||= case
            when value.is_a?(Range)
              [value.first, value.last]
            else
              [value].flatten
            end

            serialized = {
              column.to_s => {
                comparison_operator: operator,
              },
            }

            if !["NULL", "NOT_NULL"].include?(operator)
              serialized[column.to_s][:attribute_value_list] = values.map do |v|
                @dataset.format_attribute(column, v)
              end
            end

            serialized
          end

          # Apply all the options and return a filtered collection.
          #
          # @param previous_response [Response] deserialized response from a previous operation
          #
          # @return [Array]
          #
          # @api private
          # @since 0.1.0
          def run(previous_response = nil)
            @dataset.public_send(operation, @options, previous_response)
          end

          # Check if request needs to be continued.
          #
          # @param previous_response [Response] deserialized response from a previous operation
          #
          # @return [Boolean]
          #
          # @api private
          # @since 0.1.2
          def continue?(previous_response)
            return false unless previous_response.last_evaluated_key

            if @options[:limit]
              if @options[:limit] > previous_response.count
                @options[:limit] = @options[:limit] - previous_response.count

                true
              else
                false
              end
            else
              true
            end
          end
        end
      end
    end
  end
end