module Neo4j
  module Rails
    # This module handles the getting, setting and updating of attributes or properties
    # in a Railsy way.  This typically means not writing anything to the DB until the
    # object is saved (after validation).
    #
    # Externally, when we talk about properties (e.g. {#property?}, {#property_names}),
    # we mean all of the stored properties for this object include the 'hidden' props
    # with underscores at the beginning such as _neo_id and _classname.  When we talk
    # about attributes, we mean all the properties apart from those hidden ones.
    #
    # This mixin defines a number of class methods, see #{ClassMethods}.
    #
    module Attributes
      extend ActiveSupport::Concern
      extend TxMethods

      included do
        include ActiveModel::Dirty # track changes to attributes
        include ActiveModel::MassAssignmentSecurity # handle attribute hash assignment

        class << self
          attr_accessor :attribute_defaults
        end

        self.attribute_defaults ||= {}

        # save the original [] and []= to use as read/write to Neo4j
        alias_method :read_attribute, :[]
        alias_method :write_attribute, :[]=

        # wrap the read/write in type conversion
        alias_method_chain :read_local_property, :type_conversion
        alias_method_chain :write_local_property, :type_conversion

        # whenever we refer to [] or []=. use our local properties store
        alias_method :[], :read_local_property
        alias_method :[]=, :write_local_property

        def self.inherited(sub_klass)
          super
          return if sub_klass.to_s[0..0] == '#' # this is really for anonymous test classes
          setup_neo4j_subclass(sub_klass)
          sub_klass.send(:define_method, :attribute_defaults) do
            self.class.attribute_defaults
          end
          sub_klass.attribute_defaults = self.attribute_defaults.clone
          # Hmm, could not do this from the Finders Mixin Module - should be moved
          sub_klass.rule(:_all, :functions => Neo4j::Wrapper::Rule::Functions::Size.new) if sub_klass.respond_to?(:rule)
        end
      end

      # Is called when a node neo4j entity is created and we need to save attributes
      # @private
      def init_on_create(*)
        self._classname = self.class.to_s
        write_default_attributes
        write_changed_attributes
        clear_changes
      end

      # Setup this mixins instance variables
      # @private
      def initialize_attributes(attributes)
        @_properties = {}
        @_properties_before_type_cast={}
        self.attributes = attributes if attributes
      end

      # Mass-assign attributes.  Stops any protected attributes from being assigned.
      def attributes=(attributes, guard_protected_attributes = true)
        attributes = sanitize_for_mass_assignment(attributes) if guard_protected_attributes

        multi_parameter_attributes = []
        attributes.each do |k, v|
          if k.to_s.include?("(")
            multi_parameter_attributes << [k, v]
          else
            respond_to?("#{k}=") ? send("#{k}=", v) : self[k] = v
          end
        end

        assign_multiparameter_attributes(multi_parameter_attributes)
      end

      def attribute_defaults
        self.class.attribute_defaults
      end

      # Updates this resource with all the attributes from the passed-in Hash and requests that the record be saved.
      # If saving fails because the resource is invalid then false will be returned.
      def update_attributes(attributes)
        self.attributes = attributes
        save
      end
      tx_methods :update_attributes

      # Same as {#update_attributes}, but raises an exception if saving fails.
      def update_attributes!(attributes)
        self.attributes = attributes
        save!
      end
      tx_methods :update_attributes!

      # @private
      def reset_attributes
        @_properties = {}
      end



      # Updates a single attribute and saves the record.
      # This is especially useful for boolean flags on existing records. Also note that
      #
      # * Validation is skipped.
      # * Callbacks are invoked.
      # * Updates all the attributes that are dirty in this object.
      #
      def update_attribute(name, value)
        respond_to?("#{name}=") ? send("#{name}=", value) : self[name] = value
        save(:validate => false)
      end

      def hash
        persisted? ? _java_entity.neo_id.hash : super
      end

      def to_param
        persisted? ? neo_id.to_s : nil
      end

      def to_model
        self
      end

      # Returns an Enumerable of all (primary) key attributes
      # or nil if model.persisted? is false
      def to_key
        persisted? ? [id] : nil
      end

      # Return the properties from the Neo4j Node, merged with those that haven't
      # yet been saved
      def props
        ret = {}
        property_names.each do |property_name|
          ret[property_name] = respond_to?(property_name) ? send(property_name) : send(:[], property_name)
        end
        ret
      end

      # Return all the attributes for this model as a hash attr => value.  Doesn't
      # include properties that start with <tt>_</tt>.
      def attributes
        ret = {}
        attribute_names.each do |attribute_name|
          ret[attribute_name] = self.class._decl_props[attribute_name.to_sym] ? send(attribute_name) : send(:[], attribute_name)
        end
        ret
      end

      # Known properties are either in the @_properties, the declared
      # attributes or the property keys for the persisted node.
      def property_names
        # initialize @_properties if needed since
        # we can ask property names before the object is initialized (active_support initialize callbacks, respond_to?)
        @_properties ||= {}
        keys = @_properties.keys + self.class._decl_props.keys.map(&:to_s)
        keys += _java_entity.property_keys.to_a if persisted?
        keys.flatten.uniq
      end

      # Known attributes are either in the @_properties, the declared
      # attributes or the property keys for the persisted node.  Any attributes
      # that start with <tt>_</tt> are rejected
      def attribute_names
        property_names.reject { |property_name| _invalid_attribute_name?(property_name) }
      end

      # Known properties are either in the @_properties, the declared
      # properties or the property keys for the persisted node
      def property?(name)
        return false unless @_properties
        @_properties.has_key?(name) ||
            self.class._decl_props.has_key?(name) ||
            persisted? && super
      end

      def property_changed?
        return !@_properties.empty? unless persisted?
        !!@_properties.keys.find { |k| self._java_node[k] != @_properties[k] }
      end

      # Return true if method_name is the name of an appropriate attribute
      # method
      def attribute?(name)
        name[0] != ?_ && property?(name)
      end


      # Wrap the getter in a conversion from Java to Ruby
      def read_local_property_with_type_conversion(property)
        self.class._converter(property).to_ruby(read_local_property_without_type_conversion(property))
      end

      # Wrap the setter in a conversion from Ruby to Java
      def write_local_property_with_type_conversion(property, value)
        @_properties_before_type_cast[property.to_sym]=value if self.class._decl_props.has_key? property.to_sym
        conv_value = self.class._converter(property.to_sym).to_java(value)
        write_local_property_without_type_conversion(property, conv_value)
      end


      # The behaviour of []= changes with a Rails Model, where nothing gets written
      # to Neo4j until the object is saved, during which time all the validations
      # and callbacks are run to ensure correctness
      def write_local_property(key, value)
        key_s = key.to_s
        if !@_properties.has_key?(key_s) || @_properties[key_s] != value
          attribute_will_change!(key_s)
          @_properties[key_s] = value.nil? ? attribute_defaults[key_s] : value
        end
        value
      end

      # Returns the locally stored value for the key or retrieves the value from
      # the DB if we don't have one
      def read_local_property(key)
        key = key.to_s
        if @_properties.has_key?(key)
          @_properties[key]
        else
          @_properties[key] = (persisted? && _java_entity.has_property?(key)) ? read_attribute(key) : attribute_defaults[key]
        end
      end


      module ClassMethods
        # Returns all defined properties
        def columns
          self._decl_props.keys
        end


        # Declares a property.
        # It support the following hash options:
        # <tt>:default</tt>,<tt>:null</tt>,<tt>:limit</tt>,<tt>:type</tt>,<tt>:index</tt>,<tt>:converter</tt>
        #
        # @example Set the property type,
        #   class Person < Neo4j::RailsModel
        #     property :age, :type => Time
        #   end
        #
        # @example Set the property type,
        #   class Person < Neo4j::RailsModel
        #     property :age, :default => 0
        #   end
        # @example
        #   class Person < Neo4j::RailsModel
        #     property :age, :null => false
        #   end
        # Property must be there
        #
        # @example Property has a length limit
        #   class Person < Neo4j::RailsModel
        #     property :name, :limit => 128
        #   end
        #
        # @example Index with lucene.
        #   class Person < Neo4j::RailsModel
        #     property :name, :index => :exact
        #     property :year, :index => :exact, :type => Fixnum  # index as fixnum too
        #     property :description, :index => :fulltext
        #   end
        #
        # @example Using a custom converter
        #   module MyConverter
        #     def to_java(v)
        #       "Java:#{v}"
        #     end
        #
        #     def to_ruby(v)
        #       "Ruby:#{v}"
        #     end
        #
        #     def index_as
        #       String
        #     end
        #
        #     extend self
        #   end
        #
        #   class Person < Neo4j::RailsModel
        #     property :name, :converter => MyConverter
        #   end
        #
        def property(*args)
          options = args.extract_options!
          args.each do |property_sym|
            property_setup(property_sym, options)
          end
        end


        protected
        def property_setup(property, options)
          _decl_props[property] = options
          handle_property_options_for(property, options)
          define_property_methods_for(property, options)
          define_property_before_type_cast_methods_for(property, options)
        end

        def handle_property_options_for(property, options)
          attribute_defaults[property.to_s] = options[:default] if options.has_key?(:default)

          converter = options[:converter] || Neo4j::TypeConverters.converter(_decl_props[property][:type])
          _decl_props[property][:converter] = converter

          if options.include?(:index)
            _decl_props[property][:index] = options[:index]
            raise "Indexing boolean property is not allowed" if options[:type] && options[:type] == :boolean
            index(property, :type => options[:index], :field_type => converter.index_as)
          end

          if options.has_key?(:null) && options[:null] === false
            validates(property, :non_nil => true, :on => :create)
            validates(property, :non_nil => true, :on => :update)
          end
          validates(property, :length => {:maximum => options[:limit]}) if options[:limit]
        end

        def define_property_methods_for(property, options)
          unless method_defined?(property)
            class_eval <<-RUBY, __FILE__, __LINE__
              def #{property}
                send(:[], "#{property}")
              end
            RUBY
          end

          unless method_defined?("#{property}=".to_sym)
            class_eval <<-RUBY, __FILE__, __LINE__
              def #{property}=(value)
                send(:[]=, "#{property}", value)
              end
            RUBY
          end
        end

        def define_property_before_type_cast_methods_for(property, options)
          property_before_type_cast = "#{property}_before_type_cast"
          class_eval <<-RUBY, __FILE__, __LINE__
            def #{property_before_type_cast}=(value)
              @_properties_before_type_cast[:#{property}]=value
            end

            def #{property_before_type_cast}
              @_properties_before_type_cast.has_key?(:#{property}) ? @_properties_before_type_cast[:#{property}] : self.#{property}
            end
          RUBY
        end
      end


      protected


      # Ensure any defaults are stored in the DB
      def write_default_attributes
        self.class.attribute_defaults.each do |attribute, value|
          write_attribute(attribute, Neo4j::TypeConverters.convert(value, attribute, self.class, false)) unless changed_attributes.has_key?(attribute) || _java_node.has_property?(attribute)
        end
      end

      # Write attributes to the Neo4j DB only if they're altered
      def write_changed_attributes
        @_properties.each do |attribute, value|
          write_attribute(attribute, value) if changed_attributes.has_key?(attribute)
        end
      end



      def attribute_missing(method_id, *args, &block)
        method_name = method_id.method_name
        if property?(method_name)
          self[method_name]
        else
          super
        end
      end

      # TODO THIS IS ONLY NEEDED IN ACTIVEMODEL < 3.2, ?
      # To get ActiveModel::Dirty to work, we need to be able to call undeclared
      # properties as though they have get methods
      def method_missing(method_id, *args, &block)
        method_name = method_id.to_s
        if property?(method_name)
          self[method_name]
        else
          super
        end
      end

      def _invalid_attribute_name?(attr_name)
        attr_name.to_s[0] == ?_ && !self.class._decl_props.include?(attr_name.to_sym)
      end




      # Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done
      # by calling new on the column type or aggregation type (through composed_of) object with these parameters.
      # So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate
      # written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the
      # parentheses to have the parameters typecasted before they're used in the constructor. Use i for Fixnum,
      # f for Float, s for String, and a for Array. If all the values for a given attribute are empty, the
      # attribute will be set to nil.
      def assign_multiparameter_attributes(pairs)
        execute_callstack_for_multiparameter_attributes(
            extract_callstack_for_multiparameter_attributes(pairs)
        )
      end

      def execute_callstack_for_multiparameter_attributes(callstack)
        errors = []
        callstack.each do |name, values_with_empty_parameters|
          begin
            # (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass
            decl_type = self.class._decl_props[name.to_sym][:type]
            raise "Not a multiparameter attribute, missing :type on property #{name} for #{self.class}" unless decl_type

            # in order to allow a date to be set without a year, we must keep the empty values.
            values = values_with_empty_parameters.reject { |v| v.nil? }

            if values.empty?
              send(name + "=", nil)
            else

              #TODO: Consider extracting hardcoded assignments into "Binders"
              value = if Neo4j::TypeConverters::TimeConverter.convert?(decl_type)
                        instantiate_time_object(name, values)
                      elsif Neo4j::TypeConverters::DateConverter.convert?(decl_type)
                        begin
                          values = values_with_empty_parameters.collect do |v|
                            v.nil? ? 1 : v
                          end
                          Date.new(*values)
                        rescue ArgumentError => ex # if Date.new raises an exception on an invalid date
                          instantiate_time_object(name, values).to_date # we instantiate Time object and convert it back to a date thus using Time's logic in handling invalid dates
                        end
                      elsif Neo4j::TypeConverters::DateTimeConverter.convert?(decl_type)
                        DateTime.new(*values)
                      else
                        raise "Unknown type #{decl_type}"
                      end

              send(name + "=", value)
            end
          rescue Exception => ex
            raise "error on assignment #{values.inspect} to #{name}, ex: #{ex}"
          end
        end
        unless errors.empty?
          raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
        end
      end

      def instantiate_time_object(name, values)
#        if self.class.send(:create_time_zone_conversion_attribute?, name, column_for_attribute(name))
#          Time.zone.local(*values)
#        else
        Time.time_with_datetime_fallback(self.class.default_timezone, *values)
#        end
      end

      def extract_callstack_for_multiparameter_attributes(pairs)
        attributes = {}

        for pair in pairs
          multiparameter_name, value = pair
          attribute_name = multiparameter_name.split("(").first
          attributes[attribute_name] = [] unless attributes.include?(attribute_name)

          parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
          attributes[attribute_name] << [find_parameter_position(multiparameter_name), parameter_value]
        end

        attributes.each { |name, values| attributes[name] = values.sort_by { |v| v.first }.collect { |v| v.last } }
      end


      def type_cast_attribute_value(multiparameter_name, value)
        multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value
      end

      def find_parameter_position(multiparameter_name)
        multiparameter_name.scan(/\(([0-9]*).*\)/).first.first
      end

      # Tracks the current changes and clears the changed attributes hash.  Called
      # after saving the object.
      def clear_changes
        @previously_changed = changes
        @changed_attributes.clear
      end

      def _classname
        self.class.to_s
      end

      def _classname=(value)
        write_local_property_without_type_conversion("_classname", value)
      end

    end
  end
end