#--
# LdapShadows, a Medium-level LDAP Access Library and Tool.
# Copyright (c) 2009-2010 Marc Dequènes (Duck) <Duck@DuckCorp.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#++


require 'ldap_shadows/manipulation_helper'


module LdapShadows
module Elements
  class LdapObject < ActiveLdap::Base
    include LdapElement

    FULL_HANDLE_PATTERN = /^(?:root|([a-zA-Z_]+)\/(.+))$/

    attr_reader :parent_changed

    @relations_info = {}
    @virtual_attributes = {}

    class << self
      attr_reader :relations_info
    end

    # default
    ldap_mapping :prefix => '', :classes => ['top'], :scope => :sub

    # always called for instanciated objects
    #
    # Note: ActiveLdap uses allocate to instanciate objects, then
    #       call initialize only for DB-new objects, and
    #       call initialize_by_ldap_data for DB-loaded objects
    def init_instance_variables
      super

      @parent_changed = false
      @latest_parent_full_handle = nil
      @aspects_inst_cache = {}
    end

    def virtual_attributes
      list = self.class.virtual_attributes
      aspects.values.each do |aspect|
        list += aspect.virtual_attributes
      end
    end

    # return nil if not found
    def attribute_info(attr)
      return self.class.real_attribute_info(attr) if self.has_attribute? attr

      info = self.class.virtual_attribute_info(attr)
      return info unless info.nil?

      aspects.values.each do |aspect|
        info = aspect.virtual_attribute_info(attr)
        return info unless info.nil?
      end
    end

    def handle
      name = self[dn_attribute] || self.attributes[dn_attribute] || self.dn.to_s
      name = name.first if name.is_a? Array
      name.strip
    end

    def full_handle
      "#{self.class.handle}/#{self.handle}"
    end

    def self.cast
      super

      ldap_mapping self.parameters[:mapping].reject {|key, val| not ActiveLdap::Base::VALID_LDAP_MAPPING_OPTIONS.include?(key)}
    end

    def self.cast_relations
      super

      object_rel = {}
      object_rel.merge!(self.parameters[:relations]) if self.parameters.include?(:relations)
      self.possible_aspects.each do |aspect_name|
        aspect = self.shadow.get_aspect(aspect_name)
        if aspect.nil?
          raise PreProcessingError, _("Aspect '%s' is missing for object '%s'") % [aspect_name, self.handle]
        end
        object_rel.merge!(aspect.parameters[:relations])
      end

      object_relations_info = {}
      object_rel.each_pair do |field_name, rel|
        foreign_klass = self.shadow.get_object(rel[:object])
        if foreign_klass.nil?
          raise PreProcessingError, _("Relation '%s' for object '%s' is impossible: foreign object '%s' is missing") % [field_name, self.handle, rel[:object]]
        end
        rel[:class_name] = foreign_klass.to_s

	rel_attr_val = rel[:foreign_key]
        case rel[:type]
        when 'belongs_to'
          belongs_to field_name, rel.reject {|key, val| not ActiveLdap::Associations::ClassMethods::VALID_BELONGS_TO_OPTIONS.include?(key) }
	  rel_attr_val = rel[:primary_key] if rel[:many] and rel[:primary_key]
        when 'has_many'
          has_many field_name, rel.reject {|key, val| not ActiveLdap::Associations::ClassMethods::VALID_HAS_MANY_OPTIONS.include?(key) }
        else
          raise "bug in '#{self.handle}' object relations (wrong type)"
        end

        object_relations_info[field_name] = {
          :foreign_klass => foreign_klass,
          :single_value => ActiveLdap::Base.schema.attribute(rel_attr_val).single_value?,
          :read_only => rel[:read_only] || false
        }
      end
      instance_variable_set(:@relations_info, object_relations_info)
    end

    def has_field?(field)
      return false if field.downcase == "objectclass"
      return true if has_attribute?(field)
      return true if virtual_attributes.include?(field)
      aspects.values.each do |aspect|
        return true if aspect.virtual_attributes.include?(field)
      end
      return false
    end

    def human_name
      attr_list = ['displayName', 'cn']
      name_attribute = self.class.parameters[:presentation][:name_attribute]
      attr_list.unshift(name_attribute) unless name_attribute.nil?
      attr_list.each do |attr|
        if attr == 'dn'
          return self.dn.to_s
        elsif self.attribute_present?(attr)
          val = self.send(attr, true)
          return val[0].to_s.strip
        end
      end
      return ""
    end

    def human_description
      attr_list = ['description']
      desc_attribute = self.class.parameters[:presentation][:desc_attribute]
      attr_list.unshift(desc_attribute) unless desc_attribute.nil?
      attr_list.each do |attr|
        if self.attribute_present?(attr)
          return self[attr].is_a?(Array) ? self[attr][0] : self[attr]
        end
      end
      return ""
    end

    def possible_relations
      self.associations.collect {|assoc| assoc.to_s } - ['children']
    end

    def relations
      rel_list = self.class.parameters[:mapping][:associated_relations]

      aspects.values.each do |aspect|
        rel_list += aspect.parameters[:mapping][:associated_relations]
      end

      rel_list & possible_relations
    end

    def self.possible_aspects
      self.parameters[:mapping][:possible_aspects].sort
    end

    def aspects
      present_aspects = {}
      self.class.possible_aspects.each do |aspect_name|
        aspect = self.class.shadow.get_aspect(aspect_name)
        aspect_mapping = aspect.parameters[:mapping]
        present_aspects[aspect.handle] = aspect if self.classes & aspect_mapping[:classes] == aspect_mapping[:classes]
      end

      present_aspects
    end

    def organized_data
      # merge ignored and expert attributes for object and all present aspects
      ignored_attrs = self.class.shadow.get_config[:presentation][:hidden_attributes]
      ignored_attrs += self.class.parameters[:presentation][:hidden_attributes]
      expert_attributes = self.class.parameters[:presentation][:expert_attributes]
      self.aspects.values.each do |aspect|
        aspect_data = aspect.parameters
        ignored_attrs += aspect_data[:presentation][:hidden_attributes]
        expert_attributes += aspect_data[:presentation][:expert_attributes]
      end

      attr_list = self.nonempty_attributes - ignored_attrs

      admin_attributes = attr_list.select do |attr|
        ActiveLdap::Base.schema.attribute(attr).directory_operation?
      end

      rel_list = self.possible_relations

      # first pass to take aspects forced relations into account
      obj_aspects = {}
      self.aspects.values.each do |aspect|
        aspect_data = aspect.parameters

        unless aspect_data[:mapping][:associated_attributes].empty?
          taken_attr_list = aspect_data[:mapping][:associated_attributes] & (attr_list + ignored_attrs)
          unless taken_attr_list.empty?
            obj_aspects[aspect.handle] ||= {}
            obj_aspects[aspect.handle].merge!(fetch_attributes_data(taken_attr_list, expert_attributes, admin_attributes))
            attr_list -= taken_attr_list
          end
        end

        unless aspect_data[:mapping][:associated_relations].empty?
          taken_rel_list = aspect_data[:mapping][:associated_relations] & rel_list
          unless taken_rel_list.empty?
            obj_aspects[aspect.handle] ||= {}
            obj_aspects[aspect.handle].merge!(fetch_relations_data(taken_rel_list, expert_attributes))
            rel_list -= taken_rel_list
          end
        end
      end

      # manage general attributes
      obj_info = {}
      if self.class.parameters[:mapping][:associate_unclaimed_attributes]
        taken_attr_list = attr_list
      else
        taken_attr_list = admin_attributes
        taken_attr_list += self.class.parameters[:mapping][:associated_attributes]
        taken_attr_list += self.class.possible_attributes
      end
      taken_attr_list = taken_attr_list.uniq & attr_list
      taken_attr_list += self.class.virtual_attributes
      obj_info = fetch_attributes_data(taken_attr_list, expert_attributes, admin_attributes)
      attr_list -= taken_attr_list

      # manage general relations
      if self.class.parameters[:mapping][:associated_relations]
        taken_rel_list = self.class.parameters[:mapping][:associated_relations] & rel_list
        unless taken_rel_list.empty?
          obj_info.merge!(fetch_relations_data(taken_rel_list, expert_attributes))
          rel_list -= taken_rel_list
        end
      end

      # second pass to dispath the remaining attributes
      unless attr_list.empty?
        self.aspects.values.each do |aspect|
          taken_attr_list = (aspect.possible_attributes & attr_list)
          taken_attr_list += aspect.virtual_attributes
          obj_aspects[aspect.handle] ||= {}
          obj_aspects[aspect.handle].merge!(fetch_attributes_data(taken_attr_list, expert_attributes, admin_attributes))
          attr_list -= taken_attr_list

          break if attr_list.empty?
        end
      end

      [obj_info, obj_aspects]
    end

    def modify(key, op, val)
      if key.index(":")
        type, field = key.split(":")
      else
        type = nil
        field = key
      end

      case type
      when nil
        modify_field(key, op, val)
      when 'rel'
        modify_relation(field, op, val)
      when ''
        case field
        when 'aspects'
          modify_aspects(op, val)
        when 'parent'
          modify_parent(op, val)
        else
          raise PreProcessingError, _("Unknown core field '%s'") % field
        end
      else
        raise PreProcessingError, _("Unknown type '%s' for field '%s'") % [type, field]
      end
    end

    def modify_field(field, op, val)
      unless self.has_field?(field)
        raise PreProcessingError, _("No such field '%s' in object '%s'") % [field, self.class.handle]
      end

      attr_info = self.attribute_info(field)
      if attr_info[:read_only]
        raise PreProcessingError, _("The field '%s' cannot be modified (read only)") % field
      end

      if attr_info[:binary]
        unless File.exists?(val)
          raise PreProcessingError, _("The field '%s' contains binary data, you must provide a filename instead of a direct value") % field
        end

        begin
          val = File.read(val)
        rescue
          raise PreProcessingError, _("The file for the binary field '%s' cannot be read: ") % [field, $!]
        end
      elsif attr_info[:syntax] == "1.3.6.1.4.1.1466.115.121.1.12"
        if val =~ FULL_HANDLE_PATTERN
          obj_hdl = $1.downcase
          item_hdl = $2

          obj_klass = self.class.shadow.get_object(obj_hdl)
          raise PreProcessingError, _("No such object '%s'") % obj_hdl if obj_klass.nil?

          begin
            item = obj_klass.find(item_hdl, :attributes => [''])
          rescue ActiveLdap::EntryNotFound
            raise PreProcessingError, _("No such item '%s/%s'") % [obj_klass.handle, item_hdl]
          end

          val = item.dn
        end
      end

      old_val = self.send(field, true)

      # if val is nil or the latest value is removed, then the attribute is removed from the object,
      case op
      when '='
        val = [val] if old_val.is_a? Enumerable
        return false if val == old_val

        self.send(field + "=", val)

      when '+='
        if attr_info.single_value?
          raise PreProcessingError, _("The field '%s' cannot hold more than one value") % field
        end

        return false if old_val.include?(val)
        new_val = old_val << val
        self.send(field + "=", new_val)

      when '-='
        return false unless old_val.include?(val)

        new_val = old_val - [val]
        self.send(field + "=", new_val)

      else
        raise SyntaxError, _("Unknown operator '%s'") % op
      end

      true
    end

    def modify_relation(rel, op, val)
      unless self.relations.include?(rel)
        raise PreProcessingError, _("No such relation '%s' for object '%s'") % [rel, self.class.handle]
      end

      rel_info = self.class.relations_info[rel.to_sym]
      if rel_info[:read_only]
        raise PreProcessingError, _("The relation '%s' cannot be modified (read only)") % rel
      end

      if val.blank?
        raise PreProcessingError, _("No item handle specified for relation '%s'") % rel
      end

      # fetch remote object in relation, which will be the real 'val'
      foreign_item_list = rel_info[:foreign_klass].find(:all, val)
      if foreign_item_list.empty?
        raise PreProcessingError, _("Foreign item '%s' for relation '%s' not found") % [val, rel]
      end
      if foreign_item_list.size > 1
        raise WeirdError, _("Ambiguous item '%s' for relation '%s' (%s possible items)") %
          [val, rel, foreign_item_list.size]
      end

      old_val = self.send(rel)
      val = foreign_item_list.first

      # if val is nil or the latest value is removed, then the association's attribute is removed from one side
      case op
      when '='
        val = [val] if old_val.is_a? Enumerable
        self.send(rel + "=", val)

      when '+='
        if rel_info[:single_value]
          raise PreProcessingError, _("The relation '%s' cannot hold more than one foreign item") % rel
        end

        return false if old_val.include?(val)

        self.send(rel) << val

      when '-='
        return false unless old_val.include?(val)

        self.send(rel).delete(val)

      else
        raise SyntaxError, _("Unknown operator '%s'") % op
      end

      true
    end

    def modify_aspects(op, aspect_name)
      unless self.class.possible_aspects.include?(aspect_name)
        raise PreProcessingError, _("No such aspect '%s' for object '%s'") % [aspect_name, self.class.handle]
      end

      case op
      when '='
        raise PreProcessingError, _("The equality operator is not possible for aspects")

      when '+='
        return false if self.aspects.keys.include?(aspect_name)

        self.add_aspect(aspect_name)

      when '-='
        return false unless self.aspects.keys.include?(aspect_name)

        self.remove_aspect(aspect_name)

      else
        raise SyntaxError, _("Unknown operator '%s'") % op
      end

      true
    end

    def modify_parent(op, parent_full_handle)
      case op
      when '='
      when '+=', '-='
        raise PreProcessingError, _("This operator is not possible for parent")
      else
        raise SyntaxError, _("Unknown operator '%s'") % op
      end

      unless Manipulation.looks_like_full_handle?(parent_full_handle)
        raise PreProcessingError, _("Parent for the item is not a full handle")
      end
      parent_item = Manipulation.find_item_by_full_handle(self.class.shadow, parent_full_handle)

      if self.new_entry?
        self.base = parent_item.dn - parent_item.class.base
      else
        raise PreProcessingError, _("Moving items is not yet implemented")
      end

      @parent_changed = true
      @latest_parent_full_handle = parent_full_handle
    end

    def add_aspect(aspect_name)
      return unless self.class.possible_aspects.include?(aspect_name)

      aspect_mapping = self.class.shadow.get_aspect(aspect_name).parameters[:mapping]
      add_class(*aspect_mapping[:classes])

      # recursive dependency enforcement
      aspect_mapping[:depend_aspects].each do |dep_aspect|
        add_aspect(dep_aspect)
      end
    end

    def remove_aspect(aspect_name)
      return unless self.class.possible_aspects.include?(aspect_name)

      # remove reverse-depends
      self.class.possible_aspects.each do |rdep_aspect_name|
        if self.class.shadow.get_aspect(rdep_aspect_name).parameters[:mapping][:depend_aspects].include?(aspect_name)
          remove_aspect(rdep_aspect_name)
        end
      end

      aspect_mapping = self.class.shadow.get_aspect(aspect_name).parameters[:mapping]
      remove_class(*aspect_mapping[:classes])
    end

    def delete(options = {})
      before_delete_jobs
      super(options)
      after_save_jobs
    end

    def delete_recursive
      # TODO: recursive instanciation and reverse recursive hook calls
      before_delete_jobs
      self.class.delete_all(nil, :scope => :sub, :base => self.dn)
      after_delete_jobs
    end

    # cannot override create_or_update() because of alias chaining
    def save
      before_save_jobs
      r = super
      after_save_jobs
      r
    end

    def save!
      before_save_jobs
      r = super
      after_save_jobs
      r
    end

    def method_missing(method, *args)
      aspects.each_pair do |aspect_name, aspect|
        if aspect.instance_methods.include? method.to_s
          aspect_inst = @aspects_inst_cache[aspect_name]
          if aspect_inst.nil?
            aspect_inst = aspect.new(self)
            @aspects_inst_cache[aspect_name] = aspect_inst
          end
          return aspect_inst.send(method, *args)
        end
      end

      super
    end

    protected

    def before_save_jobs
      check_hooks_before(:save)
      check_missing_attributes
      check_password
      check_parent
    end

    def before_delete_jobs
      check_hooks_before(:delete)
    end

    def check_hooks_before(action)
      begin
        case action
        when :save
          if self.new_entry?
            self.class.hook_before_create(self)
          else
            self.class.hook_before_modify(self)
          end
        when :delete
          self.class.hook_before_delete(self)
        end
      rescue
        raise PreProcessingError, _("Hook before action on object failed: %s") % $!
      end

      # TODO: move this in the LdapAspect class
      self.aspects.each do |aspect_name, aklass|
        begin
          case action
          when :save
            if self.new_entry?
              aklass.hook_before_create(self)
            else
              aklass.hook_before_modify(self)
            end
          when :delete
            aklass.hook_before_delete(self)
          end
        rescue
          raise PreProcessingError, _("Hook before action on aspect '%s' failed: %s") % [aspect_name, $!]
        end
      end
    end

    def check_missing_attributes
      missing_fields = self.missing_attributes
      unless missing_fields.empty?
        miss_str = []
        missing_fields.each do |field|
          str = Translator.translate_field_name(field)
          str += " [#{field}]" if $program_options[:handles]
          miss_str << str
        end
        raise PreProcessingError, _("Cannot save the item; the following fields are missing: %s") %
          miss_str.join(", ")
      end
    end

    def check_password
      return unless self.modified_attributes([:replace], true).include? 'userPassword'

      hash_func = self.class.config.global_config[:password_hash]
      return if hash_func.nil?

      self.userPassword = ActiveLdap::UserPassword.send(hash_func, self.userPassword)
    end

    def check_parent
      parent_full_handle = nil
      parent_item_dn = nil

      if self.new_entry? and not self.parent_changed
        parent_full_handle = self.class.parameters[:mapping][:default_parent]
        if parent_full_handle
          unless Manipulation.looks_like_full_handle?(parent_full_handle)
            raise PreProcessingError, _("Default parent for the item is not a full handle")
          end

          begin
            parent_item = Manipulation.find_item_by_full_handle(self.class.shadow, parent_full_handle)
            self.base = parent_item.dn - parent_item.class.base
          rescue
            raise PreProcessingError, _("Cannot create the item: bad default parent for this kind of object: %s") % $!
          end

          parent_item_dn = parent_item.dn.to_s
        else
          raise PreProcessingError, _("Cannot create the item: parent not specified and no default for such object")
        end
      end

      parent_full_handle = @latest_parent_full_handle if parent_full_handle.nil?
      # return if this item is not new and was not relocated
      return if parent_full_handle.nil?

      parent_item_dn = self.base.to_s if parent_item_dn.nil?

      p_hdl_restr = self.class.parameters[:mapping][:parent_handle_restrictions]
      unless p_hdl_restr.nil? or parent_full_handle =~ Regexp.new(p_hdl_restr)
        raise PreProcessingError, _("This parent can't raise such a child (handle restrictions)")
      end
      p_dn_restr = self.class.parameters[:mapping][:parent_dn_restrictions]
      unless p_dn_restr.nil? or parent_item_dn =~ Regexp.new(p_dn_restr)
        raise PreProcessingError, _("This parent can't raise such a child (DN restrictions)")
      end
    end

    def after_save_jobs
      check_hooks_after(:save)
      @parent_changed = false
    end

    def after_delete_jobs
      check_hooks_after(:delete)
    end

    def check_hooks_after(action)
      # TODO: move this in the LdapAspect class
      self.aspects.each do |aspect_name, aklass|
        begin
          case action
          when :save
            if self.new_entry?
              aklass.hook_after_create(self)
            else
              aklass.hook_after_modify(self)
            end
          when :delete
            aklass.hook_after_delete(self)
          end
        rescue
          raise PreProcessingError, _("Hook after action on aspect '%s' failed: %s") % [aspect_name, $!]
        end
      end

      begin
        case action
        when :save
          if self.new_entry?
            self.class.hook_after_create(self)
          else
            self.class.hook_after_modify(self)
          end
        when :delete
          self.class.hook_after_delete(self)
        end
      rescue
        raise PreProcessingError, _("Hook after action on object failed: %s") % $!
      end
    end

    def fetch_attributes_data(attr_list, expert_attributes, admin_attributes)
      attr_data = attr_list.collect do |attr|
        base_info = self.attribute_info(attr)

        if base_info.nil?
          nil
        else
          val = self.send(attr)
          [attr, base_info.merge({
             :value => val,
             :multiple => val.is_a?(Array),
             :expert => expert_attributes.include?(attr),
             :admin => admin_attributes.include?(attr)
           })]
        end
      end
      Hash[attr_data.compact]
    end

    def fetch_relations_data(rel_list, expert_attributes)
      rel_data = rel_list.collect do |rel|
        data = self.send(rel)
        if data.is_a? Enumerable
          if data.empty?
            value = nil
          else
            value = data.collect{|g| g.handle }
            multiple = true
          end
        else
          # the exists? method also ensure the object is loaded
          if data.exists?
            value = data.handle
          else
            value = nil
          end
          multiple = false
        end

        if value.nil?
          nil
        else
          rel_key = "rel:" + rel
          [rel_key, {
             :syntax => nil,
             :value => value,
             :multiple => multiple,
             :expert => expert_attributes.include?(rel_key),
             :admin => false,
             :binary => false
           }]
        end
      end
      Hash[rel_data.compact]
    end
  end

  class ExternalLdapObject < ActiveLdap::Base
    self.base = ''
  end
end
end
