module DataMapper
  class Collection < LazyArray
    include Assertions

    attr_reader :query

    ##
    # @return [Repository] the repository the collection is
    #   associated with
    #
    # @api public
    def repository
      query.repository
    end

    ##
    # loads the entries for the collection. Used by the
    # adapters to load the instances of the declared
    # model for this collection's query.
    #
    # @api private
    def load(values)
      add(model.load(values, query))
    end

    ##
    # reloads the entries associated with this collection
    #
    # @param [DataMapper::Query] query (optional) additional query
    #   to scope by.  Use this if you want to query a collections result
    #   set
    #
    # @see DataMapper::Collection#all
    #
    # @api public
    def reload(query = {})
      @query = scoped_query(query)
      @query.update(:fields => @query.fields | @key_properties)
      replace(all(:reload => true))
    end

    ##
    # retrieves an entry out of the collection's entry by key
    #
    # @param [DataMapper::Types::*, ...] key keys which uniquely
    #   identify a resource in the collection
    #
    # @return [DataMapper::Resource, NilClass] the resource which
    #   has the supplied keys
    #
    # @api public
    def get(*key)
      key = model.typecast_key(key)
      if loaded?
        # loop over the collection to find the matching resource
        detect { |resource| resource.key == key }
      elsif query.limit || query.offset > 0
        # current query is exclusive, find resource within the set

        # TODO: use a subquery to retrieve the collection and then match
        #   it up against the key.  This will require some changes to
        #   how subqueries are generated, since the key may be a
        #   composite key.  In the case of DO adapters, it means subselects
        #   like the form "(a, b) IN(SELECT a,b FROM ...)", which will
        #   require making it so the Query condition key can be a
        #   Property or an Array of Property objects

        # use the brute force approach until subquery lookups work
        lazy_load
        get(*key)
      else
        # current query is all inclusive, lookup using normal approach
        first(model.to_query(repository, key))
      end
    end

    ##
    # retrieves an entry out of the collection's entry by key,
    # raising an exception if the object cannot be found
    #
    # @param [DataMapper::Types::*, ...] key keys which uniquely
    #   identify a resource in the collection
    #
    # @calls DataMapper::Collection#get
    #
    # @raise [ObjectNotFoundError] "Could not find #{model.name} with key #{key.inspect} in collection"
    #
    # @api public
    def get!(*key)
      get(*key) || raise(ObjectNotFoundError, "Could not find #{model.name} with key #{key.inspect} in collection")
    end

    ##
    # Further refines a collection's conditions.  #all provides an
    # interface which simulates a database view.
    #
    # @param [Hash[Symbol, Object], DataMapper::Query] query parameters for
    #   an query within the results of the original query.
    #
    # @return [DataMapper::Collection] a collection whose query is the result
    #   of a merge
    #
    # @api public
    def all(query = {})
      # TODO: this shouldn't be a kicker if scoped_query() is called
      return self if query.kind_of?(Hash) ? query.empty? : query == self.query
      query = scoped_query(query)
      query.repository.read_many(query)
    end

    ##
    # Simulates Array#first by returning the first entry (when
    # there are no arguments), or transforms the collection's query
    # by applying :limit => n when you supply an Integer. If you
    # provide a conditions hash, or a Query object, the internal
    # query is scoped and a new collection is returned
    #
    # @param [Integer, Hash[Symbol, Object], Query] args
    #
    # @return [DataMapper::Resource, DataMapper::Collection] The
    #   first resource in the entries of this collection, or
    #   a new collection whose query has been merged
    #
    # @api public
    def first(*args)
      # TODO: this shouldn't be a kicker if scoped_query() is called
      if loaded?
        if args.empty?
          return super
        elsif args.size == 1 && args.first.kind_of?(Integer)
          limit = args.shift
          return self.class.new(scoped_query(:limit => limit)) { |c| c.replace(super(limit)) }
        end
      end

      query = args.last.respond_to?(:merge) ? args.pop : {}
      query = scoped_query(query.merge(:limit => args.first || 1))

      if args.any?
        query.repository.read_many(query)
      else
        query.repository.read_one(query)
      end
    end

    ##
    # Simulates Array#last by returning the last entry (when
    # there are no arguments), or transforming the collection's
    # query by reversing the declared order, and applying
    # :limit => n when you supply an Integer.  If you
    # supply a conditions hash, or a Query object, the
    # internal query is scoped and a new collection is returned
    #
    # @calls Collection#first
    #
    # @api public
    def last(*args)
      return super if loaded? && args.empty?

      reversed = reverse

      # tell the collection to reverse the order of the
      # results coming out of the adapter
      reversed.query.add_reversed = !query.add_reversed?

      reversed.first(*args)
    end

    ##
    # Simulates Array#at and returns the entrie at that index.
    # Also accepts negative indexes and appropriate reverses
    # the order of the query
    #
    # @calls Collection#first
    # @calls Collection#last
    #
    # @api public
    def at(offset)
      return super if loaded?
      offset >= 0 ? first(:offset => offset) : last(:offset => offset.abs - 1)
    end

    ##
    # Simulates Array#slice and returns a new Collection
    # whose query has a new offset or limit according to the
    # arguments provided.
    #
    # If you provide a range, the min is used as the offset
    # and the max minues the offset is used as the limit.
    #
    # @param [Integer, Array(Integer), Range] args the offset,
    # offset and limit, or range indicating offsets and limits
    #
    # @return [DataMapper::Resource, DataMapper::Collection]
    #   The entry which resides at that offset and limit,
    #   or a new Collection object with the set limits and offset
    #
    # @raise [ArgumentError] "arguments may be 1 or 2 Integers,
    #   or 1 Range object, was: #{args.inspect}"
    #
    # @alias []
    #
    # @api public
    def slice(*args)
      return at(args.first) if args.size == 1 && args.first.kind_of?(Integer)

      if args.size == 2 && args.first.kind_of?(Integer) && args.last.kind_of?(Integer)
        offset, limit = args
      elsif args.size == 1 && args.first.kind_of?(Range)
        range  = args.first
        offset = range.first
        limit  = range.last - offset
        limit += 1 unless range.exclude_end?
      else
        raise ArgumentError, "arguments may be 1 or 2 Integers, or 1 Range object, was: #{args.inspect}", caller
      end

      all(:offset => offset, :limit => limit)
    end

    alias [] slice

    ##
    #
    # @return [DataMapper::Collection] a new collection whose
    #   query is sorted in the reverse
    #
    # @see Array#reverse, DataMapper#all, DataMapper::Query#reverse
    #
    # @api public
    def reverse
      all(self.query.reverse)
    end

    ##
    # @see Array#<<
    #
    # @api public
    def <<(resource)
      super
      relate_resource(resource)
      self
    end

    ##
    # @see Array#push
    #
    # @api public
    def push(*resources)
      super
      resources.each { |resource| relate_resource(resource) }
      self
    end

    ##
    # @see Array#unshift
    #
    # @api public
    def unshift(*resources)
      super
      resources.each { |resource| relate_resource(resource) }
      self
    end

    ##
    # @see Array#replace
    #
    # @api public
    def replace(other)
      if loaded?
        each { |resource| orphan_resource(resource) }
      end
      super
      other.each { |resource| relate_resource(resource) }
      self
    end

    ##
    # @see Array#pop
    #
    # @api public
    def pop
      orphan_resource(super)
    end

    ##
    # @see Array#shift
    #
    # @api public
    def shift
      orphan_resource(super)
    end

    ##
    # @see Array#delete
    #
    # @api public
    def delete(resource, &block)
      orphan_resource(super)
    end

    ##
    # @see Array#delete_at
    #
    # @api public
    def delete_at(index)
      orphan_resource(super)
    end

    ##
    # @see Array#clear
    #
    # @api public
    def clear
      if loaded?
        each { |resource| orphan_resource(resource) }
      end
      super
      self
    end

    # builds a new resource and appends it to the collection
    #
    # @param Hash[Symbol => Object] attributes attributes which
    #   the new resource should have.
    #
    # @api public
    def build(attributes = {})
      repository.scope do
        resource = model.new(default_attributes.merge(attributes))
        self << resource
        resource
      end
    end

    ##
    # creates a new resource, saves it, and appends it to the collection
    #
    # @param Hash[Symbol => Object] attributes attributes which
    #   the new resource should have.
    #
    # @api public
    def create(attributes = {})
      repository.scope do
        resource = model.create(default_attributes.merge(attributes))
        self << resource unless resource.new_record?
        resource
      end
    end

    def update(attributes = {}, preload = false)
      raise NotImplementedError, 'update *with* validations has not be written yet, try update!'
    end

    ##
    # batch updates the entries belongs to this collection, and skip
    # validations for all resources.
    #
    # @example Reached the Age of Alchohol Consumption
    #   Person.all(:age.gte => 21).update!(:allow_beer => true)
    #
    # @param attributes Hash[Symbol => Object] attributes to update
    # @param reload [FalseClass, TrueClass] if set to true, collection
    #   will have loaded resources reflect updates.
    #
    # @return [TrueClass, FalseClass]
    #   TrueClass indicates that all entries were affected
    #   FalseClass indicates that some entries were affected
    #
    # @api public
    def update!(attributes = {}, reload = false)
      # TODO: delegate to Model.update
      return true if attributes.empty?

      dirty_attributes = {}

      model.properties(repository.name).slice(*attributes.keys).each do |property|
        dirty_attributes[property] = attributes[property.name] if property
      end

      # this should never be done on update! even if collection is loaded. or?
      # each { |resource| resource.attributes = attributes } if loaded?

      changes = repository.update(dirty_attributes, scoped_query)

      # need to decide if this should be done in update!
      query.update(attributes)

      if identity_map.any? && reload
        reload_query = @key_properties.zip(identity_map.keys.transpose).to_hash
        model.all(reload_query.merge(attributes)).reload(:fields => attributes.keys)
      end

      # this should return true if there are any changes at all. as it skips validations
      # the only way it could be fewer changes is if some resources already was updated.
      # that should not return false? true = 'now all objects have these new values'
      return loaded? ? changes == size : changes > 0
    end

    def destroy
      raise NotImplementedError, 'destroy *with* validations has not be written yet, try destroy!'
    end

    ##
    # batch destroy the entries belongs to this collection, and skip
    # validations for all resources.
    #
    # @example The War On Terror (if only it were this easy)
    #   Person.all(:terrorist => true).destroy() #
    #
    # @return [TrueClass, FalseClass]
    #   TrueClass indicates that all entries were affected
    #   FalseClass indicates that some entries were affected
    #
    # @api public
    def destroy!
      # TODO: delegate to Model.destroy
      if loaded?
        return false unless repository.delete(scoped_query) == size

        each do |resource|
          resource.instance_variable_set(:@new_record, true)
          identity_map.delete(resource.key)
          resource.dirty_attributes.clear

          model.properties(repository.name).each do |property|
            next unless resource.attribute_loaded?(property.name)
            resource.dirty_attributes[property] = property.get(resource)
          end
        end
      else
        return false unless repository.delete(scoped_query) > 0
      end

      clear

      true
    end

    ##
    # @return [DataMapper::PropertySet] The set of properties this
    #   query will be retrieving
    #
    # @api public
    def properties
      PropertySet.new(query.fields)
    end

    ##
    # @return [DataMapper::Relationship] The model's relationships
    #
    # @api public
    def relationships
      model.relationships(repository.name)
    end

    ##
    # default values to use when creating a Resource within the Collection
    #
    # @return [Hash] The default attributes for DataMapper::Collection#create
    #
    # @see DataMapper::Collection#create
    #
    # @api public
    def default_attributes
      default_attributes = {}
      query.conditions.each do |tuple|
        operator, property, bind_value = *tuple

        next unless operator == :eql &&
          property.kind_of?(DataMapper::Property) &&
          ![ Array, Range ].any? { |k| bind_value.kind_of?(k) }
          !@key_properties.include?(property)

        default_attributes[property.name] = bind_value
      end
      default_attributes
    end

    ##
    # check to see if collection can respond to the method
    #
    # @param method [Symbol] method to check in the object
    # @param include_private [FalseClass, TrueClass] if set to true,
    #   collection will check private methods
    #
    # @return [TrueClass, FalseClass]
    #   TrueClass indicates the method can be responded to by the collection
    #   FalseClass indicates the method can not be responded to by the collection
    #
    # @api public
    def respond_to?(method, include_private = false)
      super || model.public_methods(false).include?(method.to_s) || relationships.has_key?(method)
    end

    protected

    ##
    # @api private
    def model
      query.model
    end

    private

    ##
    # @api public
    def initialize(query, &block)
      assert_kind_of 'query', query, Query

      unless block_given?
        # It can be helpful (relationship.rb: 112-13, used for SEL) to have a non-lazy Collection.
        block = lambda {}
      end

      @query          = query
      @key_properties = model.key(repository.name)

      super()

      load_with(&block)
    end

    ##
    # @api private
    def add(resource)
      query.add_reversed? ? unshift(resource) : push(resource)
      resource
    end

    ##
    # @api private
    def relate_resource(resource)
      return unless resource
      resource.collection = self
      resource
    end

    ##
    # @api private
    def orphan_resource(resource)
      return unless resource
      resource.collection = nil if resource.collection == self
      resource
    end

    ##
    # @api private
    def scoped_query(query = self.query)
      assert_kind_of 'query', query, Query, Hash

      query.update(keys) if loaded?

      return self.query if query == self.query

      query = if query.kind_of?(Hash)
        Query.new(query.has_key?(:repository) ? query.delete(:repository) : self.repository, model, query)
      else
        query
      end

      if query.limit || query.offset > 0
        set_relative_position(query)
      end

      self.query.merge(query)
    end

    ##
    # @api private
    def keys
      keys = map {|r| r.key }
      keys.any? ? @key_properties.zip(keys.transpose).to_hash : {}
    end

    ##
    # @api private
    def identity_map
      repository.identity_map(model)
    end

    ##
    # @api private
    def set_relative_position(query)
      return if query == self.query

      if query.offset == 0
        return if !query.limit.nil? && !self.query.limit.nil? && query.limit <= self.query.limit
        return if  query.limit.nil? &&  self.query.limit.nil?
      end

      first_pos = self.query.offset + query.offset
      last_pos  = self.query.offset + self.query.limit if self.query.limit

      if limit = query.limit
        if last_pos.nil? || first_pos + limit < last_pos
          last_pos = first_pos + limit
        end
      end

      if last_pos && first_pos >= last_pos
        raise 'outside range'  # TODO: raise a proper exception object
      end

      query.update(:offset => first_pos)
      query.update(:limit => last_pos - first_pos) if last_pos
    end

    ##
    # @api private
    def method_missing(method, *args, &block)
      if model.public_methods(false).include?(method.to_s)
        model.send(:with_scope, query) do
          model.send(method, *args, &block)
        end
      elsif relationship = relationships[method]
        klass = model == relationship.child_model ? relationship.parent_model : relationship.child_model

        # TODO: when self.query includes an offset/limit use it as a
        # subquery to scope the results rather than a join

        query = Query.new(repository, klass)
        query.conditions.push(*self.query.conditions)
        query.update(relationship.query)
        query.update(args.pop) if args.last.kind_of?(Hash)

        query.update(
          :fields => klass.properties(repository.name).defaults,
          :links  => self.query.links + [ relationship ]
        )

        klass.all(query, &block)
      else
        super
      end
    end
  end # class Collection
end # module DataMapper
