require "data_active/version"

module DataActive
  def self.included(base)
    base.extend ClassMethods
  end

  def ensure_unique(name)
    begin
      self[name] = yield
    end while self.class.exists?(name => self[name])
  end

  VALID_FROM_XML_OPTIONS = [:sync, :create, :update, :destroy, :fail_on_invalid]

  module ClassMethods
    def many_from_xml(source_xml, options = [])
      @data_active_options = options
      many_from root_node_in source_xml
    end

    def one_from_xml(source_xml, options = [])
      @data_active_options = options

      current_node = root_node_in source_xml

      if current_node.name.underscore.eql?(self.name.underscore)
        # Load or create a new record
        pk_node = current_node.xpath self.primary_key.to_s

        active_record = find_record_based_on(pk_node)

        unless active_record.nil?
          # Process the attributes
          if options.include? :update or options.include? :sync or options.include? :create
            assign_attributes_from current_node, :to => active_record
            if options.include? :fail_on_invalid and !active_record.valid?
              messages = active_record.errors.messages.map {|attribute, messages| "#{attribute} #{messages.map{|message| message }.join(', ')}"}.join(', ')
              raise "Found an invalid #{active_record.class.name} with the following errors: #{messages}. Source: #{current_node.to_s}"
            end
          end

          # Save the record
          if options.include? :sync
            # Doing complete synchronisation with XML
            active_record.save
          elsif options.include?(:create) and active_record.new_record?
            active_record.save
          elsif options.include?(:update) and not active_record.new_record?
            active_record.save
          end

          # Check through associations and apply sync appropriately
          self.reflect_on_all_associations.each do |association|
            foreign_key = foreign_key_from(association)
            klass = association.klass

            case
              when association.macro == :has_many, association.macro == :has_and_belongs_to_many
                instances = instances_for association, :from => current_node, :for => active_record

                child_ids = []
                instances.each do |instance|
                  new_record = klass.one_from_xml(instance, options)
                  if new_record != nil
                    child_ids << new_record[klass.primary_key]
                    active_record.__send__(klass.name.underscore.pluralize.to_sym) << new_record
                  end
                end

                unless active_record.new_record?
                  if options.include?(:sync) or options.include?(:destroy)
                    if child_ids.length > 0
                      klass.destroy_all [klass.primary_key.to_s + " not in (?) and #{foreign_key} = ?", child_ids.collect, active_record.attributes[self.primary_key.to_s]]
                    else
                      klass.destroy_all
                    end
                  end
                end

              when association.macro == :has_one
                pk_value = active_record.new_record? ? 0 : active_record.attributes[self.primary_key.to_s]
                single_objects = current_node.xpath("//#{self.name.underscore}[#{self.primary_key}=#{pk_value}]/#{association.name}")
                klass = association.klass
                record = klass.where(foreign_key => active_record.attributes[self.primary_key.to_s]).all
                if single_objects.count == 1
                  # Check to see if the already record exists
                  if record.count == 1
                    db_pk_value = record[0][klass.primary_key]
                    xml_pk_value = Integer(single_objects[0].element_children.xpath("//#{self.name.underscore}/#{klass.primary_key}").text)

                    if db_pk_value != xml_pk_value
                      # Different record in xml
                      if options.include?(:sync) or options.include?(:destroy)
                        # Delete the one in the database
                        klass.destroy(record[0][klass.primary_key])
                      end
                    end
                  elsif record.count > 1
                    raise "Too many records for one to one association in the database. Found #{record.count} records of '#{association.name}' for association with '#{self.name}'"
                  end

                  if options.include?(:create) or options.include?(:update) or options.include?(:sync)
                    new_record = klass.one_from_xml(single_objects[0], options)
                    if new_record != nil
                      new_record[foreign_key.to_sym] = active_record[self.primary_key.to_s]
                      new_record.save!
                    end
                  end
                elsif single_objects.count > 1
                  # There are more than one associations
                  raise "Too many records for one to one association in the provided XML. Found #{single_objects.count} records of '#{association.name}' for association with '#{self.name}'"
                else
                  # There are no records in the XML
                  if record.count > 0 and options.include?(:sync) or options.include?(:destroy)
                    # Found some in the database: destroy then
                    klass.destroy_all("#{foreign_key} = #{active_record.attributes[self.primary_key.to_s]}")
                  end
                end

              when association.macro == :belongs_to

              else
                raise "unsupported association #{association.macro} for #{association.name  } on #{self.name}"
            end
          end
        end

        active_record
      else
        raise "The supplied XML (#{current_node.name}) cannot be mapped to this class (#{self.name})"
      end
    end

    private
    def xml_node_matches_class(xml_node)
      if xml_node.attributes['type'].blank?
        xml_node.name.underscore == self.name.underscore
      else
        xml_node.attributes['type'].value.underscore == self.name.underscore
      end
    end

    def find_record_based_on(pk_node)
      ar = nil
      if pk_node
        begin
          ar = find pk_node.text
        rescue
          # No record exists, create a new one
          if @data_active_options.include?(:sync) or @data_active_options.include?(:create)
            ar = self.new
          end
        end
      else
        # No primary key value, must be a new record
        if @data_active_options.include?(:sync) or @data_active_options.include?(:create)
          ar = self.new
        end
      end
      ar
    end

    def assign_attributes_from(current_node, options)
      record = options[:to]

      record.attributes.each do |name, value|
        attribute_nodes = current_node.xpath name.to_s
        if attribute_nodes.count == 1
          if attribute_nodes[0].attributes['nil'].try(:value)
            record[name] = nil
          else
            record[name] = attribute_nodes[0].text
          end
        elsif attribute_nodes.count > 1
          raise "Found duplicate elements in xml for active record attribute '#{name}'"
        end
      end
    end

    def instances_for(association, options)
      current_node = options[:from]
      active_record = options[:for]

      # Attempt to find instances which are in the following format
      # <books>
      #   <book>
      #     ...
      #   </book>
      #   <book>
      #     ...
      #   </book>
      # </books>
      if active_record.new_record?
        results = current_node.xpath("//#{self.name.underscore}[#{self.primary_key}=#{current_node.xpath(self.primary_key.to_s).text}]/#{association.name}")
      else
        results = current_node.xpath("//#{self.name.underscore}[#{self.primary_key}=#{active_record.attributes[self.primary_key.to_s]}]/#{association.name}")
      end


      if results.count.eql? 0
        # Attempt to find instances which are in the following format
        # <book>
        #   ...
        # </book>
        if active_record.new_record?
          results = current_node.xpath("//#{self.name.underscore}[#{self.primary_key}=#{current_node.xpath(self.primary_key.to_s).text}]/#{association.name.to_s.singularize}")
        else
          results = current_node.xpath("//#{self.name.underscore}[#{self.primary_key}=#{active_record.attributes[self.primary_key.to_s]}]/#{association.name.to_s.singularize}")
        end
      else
        results = results.first.element_children
      end

      results
    end

    def foreign_key_from(association)
      if ActiveRecord::Reflection::AssociationReflection.method_defined? :foreign_key
        # Support for Rails 3.1 and later
        foreign_key = association.foreign_key
      elsif ActiveRecord::Reflection::AssociationReflection.method_defined? :primary_key_name
        # Support for Rails earlier than 3.1
        foreign_key = association.primary_key_name
      else
        raise "Unsupported version of ActiveRecord. Unable to identify the foreign key."
      end
      foreign_key
    end

    def root_node_in(source_xml)
      if source_xml.is_a?(String)
        doc = Nokogiri::XML(source_xml)
        doc.children.first
      else
        source_xml
      end
    end

    def remove_records_not_in(recorded_ids)
      if @data_active_options.include?(:sync)
        if recorded_ids.length > 0
          self.destroy_all [self.primary_key.to_s + " not in (?)", recorded_ids.collect]
        end
      elsif @data_active_options.include?(:destroy)
        if recorded_ids.length > 0
          self.destroy_all [self.primary_key.to_s + " not in (?)", recorded_ids.collect]
        else
          self.destroy_all
        end
      end
    end

    def many_from_rails_xml(current_node)
      records = []
      recorded_ids = []

      current_node.element_children.each do |node|
        record = self.one_from_xml(node, @data_active_options)
        if record
          recorded_ids << record[primary_key.to_sym]
          records << record
        end
      end

      remove_records_not_in recorded_ids

      records
    end

    def many_from_ms_xml(current_node)
      records = []
      recorded_ids = []

      current_node.element_children.each do |node|
        if self.name.underscore.eql?(node.name.underscore)
          record = self.one_from_xml(node, @data_active_options)
          if record
            recorded_ids << record[primary_key.to_sym]
            records << record
          end
        end
      end

      remove_records_not_in recorded_ids

      records
    end

    def many_from(current_node)
      case
        when self.name.pluralize.underscore.eql?(current_node.name.underscore)
          many_from_rails_xml current_node

        when (current_node.name.eql?('dataroot') \
          and current_node.namespace_definitions.map { |ns| ns.href }.include?('urn:schemas-microsoft-com:officedata'))
          # Identified as data generated from Microsoft Access
          many_from_ms_xml current_node

        when self.name.underscore.eql?(current_node.name.underscore)
          raise "The supplied XML (#{current_node.name}) is a single instance of '#{self.name}'. Please use one_from_xml"

        else
          raise "The supplied XML (#{current_node.name}) cannot be mapped to this class (#{self.name})"

      end
    end
  end
end

class ActiveRecord::Base
  include DataActive
end