module Devise

  module LDAP

    class Connection

      attr_reader :ldap, :login

      def initialize(params = {})

        # Option scope determines the mapping_file to use
        @scope = params[:scope]
        config_file_path = "#{Rails.root}/config/ldap/#{@scope}.yml"

        # Read the config file depending on the scope
        ldap_config = YAML.load(ERB.new(File.read(config_file_path)).result)

        # Alter the ssl option in the configuration
        ldap_config[Rails.env]["ssl"] = :simple_tls if ldap_config[Rails.env]["ssl"] === true

        # Determines if logging happens
        @logging_enabled = ldap_config['logging_enabled']

        # Evaluated into Proc objects
        @auth_username_builder  = eval ldap_config['auth_username_builder']
        @auth_password_builder  = eval ldap_config['auth_password_builder']

        # Attributes and groups required for authorisation of an account
        @group_base = ldap_config["group_base"]
        @required_groups = ldap_config["required_groups"]
        @group_membership_attribute = ldap_config.has_key?("group_membership_attribute") ? ldap_config["group_membership_attribute"] : "uniqueMember"
        @required_attributes = ldap_config["require_attribute"]
        @required_attributes_presence = ldap_config["require_attribute_presence"]

        # Various Flags
        @allow_unauthenticated_bind = ["allow_unauthenticated_bind"]
        @check_attributes = ldap_config['check_attributes']
        @check_attributes_presence = ldap_config['check_attributes_presence']
        @use_admin_to_bind = ldap_config['use_admin_to_bind']
        @check_group_membership = ldap_config["check_group_membership"]
        @check_group_membership_without_admin = ldap_config["check_group_membership_without_admin"]
        @update_passwords = ldap_config['update_passwords']
        @create_user = ldap_config['create_user']

        # Other params referencing credentails not part of the config file
        @login = params[:login]
        @password = params[:password]
        @new_password = params[:new_password]

        # Build up the options used to establish an LDAP connection
        ldap_options = {}
        ldap_options[:login] = params[:login] if params[:login]
        ldap_options[:password] =  params[:password] if params[:password]
        ldap_options[:new_password] =  params[:new_password] if params[:new_password]
        ldap_options[:ldap_auth_username_builder] = @auth_username_builder 
        ldap_options[:admin] = @use_admin_to_bind
        ldap_options[:encryption] = ldap_config[Rails.env]["ssl"].to_sym if ldap_config[Rails.env]["ssl"]

        # LDAP server to connect to depending on the current Rails environment in use
        @ldap = Net::LDAP.new(ldap_options)
        @ldap.host = ldap_config[Rails.env]["host"]
        @ldap.port = ldap_config[Rails.env]["port"]
        @ldap.base = ldap_config[Rails.env]["base"]
        @attribute = ldap_config[Rails.env]["attribute"]

        # Admin credentials to use if an admin is set to bind to LDAP (Rails environment dependent)
        @ldap.auth ldap_config[Rails.env]["admin_user"], ldap_config[Rails.env]["admin_password"] if @use_admin_to_bind

      end

      # Logs a message to the console and the environment log file
      def log (message)
        DeviseLdapMultiple::Logger.send(message) if @logging_enabled
      end

      def password_updatable?
        @update_passwords
      end

      def user_creatable?
        @create_user
      end
     
      # Deletes a parameter from LDAP for an account
      def delete_param(param)
        update_ldap [[:delete, param.to_sym, nil]]
      end

      def set_param(param, new_value, is_password = false)
        new_value = @auth_password_builder .call(new_value) if is_password
        update_ldap( { param.to_sym => new_value } )
      end

      # Returns the DistinguishedName for an account, regardless of if it exists or not (calls a proc to determine the dn if it doesn't exists - proc defined in the config file)
      def dn
        @dn ||= begin
          log("LDAP dn lookup: #{@attribute}=#{@login}") 
          log("LDAP dn lookup asdf: #{@attribute}=#{@login}") 
          ldap_entry = search_for_login
          if ldap_entry.nil?
            @auth_username_builder .call(@attribute,@login,@ldap)
          else
            ldap_entry.dn
          end
        end
      end

      def ldap_param_value(param)
        ldap_entry = search_for_login

        if ldap_entry
          unless ldap_entry[param].empty?
            value = ldap_entry.send(param)
            log("Requested param #{param} has value #{value}") 
            value
          else
            log("Requested param #{param} does not exist") 
            value = nil
          end
        else
          log("Requested ldap entry does not exist") 
          value = nil
        end
      end

      def authenticate!
        return false unless (@password.present? || @allow_unauthenticated_bind)
        @ldap.auth(dn, @password)
        @ldap.bind
      end

      def authenticated?
        authenticate!
      end

      def last_message_bad_credentials?
        @ldap.get_operation_result.error_message.to_s.include? 'AcceptSecurityContext error, data 52e'
      end

      def last_message_expired_credentials?
        @ldap.get_operation_result.error_message.to_s.include? 'AcceptSecurityContext error, data 773'
      end

      def authorized?
        log("Authorizing user #{dn}") 
        if !authenticated?
          if last_message_bad_credentials?
            log("Not authorized because of invalid credentials.") 
          elsif last_message_expired_credentials?
            log("Not authorized because of expired credentials.") 
          else
            log("Not authorized because not authenticated.") 
          end
          return false
        elsif !in_required_groups?
          log("Not authorized because not in required groups.") 
          return false
        elsif !has_required_attribute?
          log("Not authorized because does not have required attribute.") 
          return false
        elsif !has_required_attribute_presence?
          log("Not authorized because does not have required attribute present.") 
          return false
        else
          return true
        end
      end

      def expired_valid_credentials?
        log("Authorizing user #{dn}")  
        !authenticated? && last_message_expired_credentials?
      end

      def change_password!
        update_ldap(:userPassword => @auth_password_builder .call(@new_password))
      end

      def in_required_groups?
        return true unless @check_group_membership || @check_group_membership_without_admin
        ## FIXME set errors here, the ldap.yml isn't set properly.
        return false if @required_groups.nil?
        for group in @required_groups
          if group.is_a?(Array)
            return false unless in_group?(group[1], group[0])
          else
            return false unless in_group?(group)
          end
        end
        return true
      end

      def in_group?(group_name, group_attribute = LDAP::DEFAULT_GROUP_UNIQUE_MEMBER_LIST_KEY)
        in_group = false
        if @check_group_membership_without_admin
          group_checking_ldap = @ldap
        else
          group_checking_ldap = Connection.admin
        end
        unless @ldap_check_group_membership
          group_checking_ldap.search(:base => group_name, :scope => Net::LDAP::SearchScope_BaseObject) do |entry|
            if entry[group_attribute].include? dn
              in_group = true
              log("User #{dn} IS included in group: #{group_name}") 
            end
          end
        else
          # AD optimization - extension will recursively check sub-groups with one query
          # "(memberof:1.2.840.113556.1.4.1941:=group_name)"
          search_result = group_checking_ldap.search(:base => dn,
                            :filter => Net::LDAP::Filter.ex("memberof:1.2.840.113556.1.4.1941", group_name),
                            :scope => Net::LDAP::SearchScope_BaseObject)
          # Will return  the user entry if belongs to group otherwise nothing
          if search_result.length == 1 && search_result[0].dn.eql?(dn)
            in_group = true
            log("User #{dn} IS included in group: #{group_name}") 
          end
        end

        unless in_group
          log("User #{dn} is not in group: #{group_name}") 
        end

        return in_group
      end

      def has_required_attribute?
        return true unless @check_attributes
        admin_ldap = Connection.admin
        user = find_ldap_user(admin_ldap)
        @required_attributes.each do |key,val|
          matching_attributes = user[key] & Array(val)
          unless (matching_attributes).any?
            log("User #{dn} did not match attribute #{key}:#{val}") 
            return false
          end
        end
        return true
      end

      def has_required_attribute_presence?
        return true unless @check_attributes_presence
        user = search_for_login
        @required_attributes_presence.each do |key,val|
          if val && !user.attribute_names.include?(key.to_sym)
            log("User #{dn} doesn't include attribute #{key}") 
            return false
          elsif !val && user.attribute_names.include?(key.to_sym)
            log("User #{dn} includes attribute #{key}") 
            return false
          end
        end
        return true
      end

      def user_groups
        admin_ldap = Connection.admin
        log("Getting groups for #{dn}") 
        filter = Net::LDAP::Filter.eq(@group_membership_attribute, dn)
        admin_ldap.search(:filter => filter, :base => @group_base).collect(&:dn)
      end
      def valid_login?
        !search_for_login.nil?
      end

      # Searches the LDAP for the login
      #
      # @return [Object] the LDAP entry found; nil if not found
      def search_for_login
        @login_ldap_entry ||= begin
          log("LDAP search for login: #{@attribute}=#{@login}") 
          log("Attribute: #{@attribute.to_s}, Login: #{@login.to_s}")
          log("Scope is: #{@scope}")
          filter = Net::LDAP::Filter.eq(@attribute.to_s, @login.to_s)
          ldap_entry = nil
          match_count = 0
          @ldap.search(:filter => filter) { |entry| ldap_entry = entry; match_count+=1}
          op_result= @ldap.get_operation_result
          if op_result.code!=0 then
            log("LDAP Error #{op_result.code}: #{op_result.message}") 
          end
          log("LDAP search yielded #{match_count} matches") 
          ldap_entry
        end
      end

      private

      def self.admin
        ldap = Connection.new(:admin => true).ldap
        unless ldap.bind
          log("Cannot bind to admin LDAP user") 
          raise DeviseLdapAuthenticatable::LdapException, "Cannot connect to admin LDAP user" 
        end

        return ldap
      end

      def find_ldap_user(ldap)
        log("Finding user: #{dn}") 
        ldap.search(:base => dn, :scope => Net::LDAP::SearchScope_BaseObject).try(:first)
      end

      def update_ldap(ops)
        operations = []
        if ops.is_a? Hash
          ops.each do |key,value|
            operations << [:replace,key,value]
          end
        elsif ops.is_a? Array
          operations = ops
        end
        if @use_admin_to_bind
          privileged_ldap = Connection.admin
        else
          authenticate!
          privileged_ldap = self.ldap
        end
        log("Modifying user #{dn}") 
        privileged_ldap.modify(:dn => dn, :operations => operations)
      end

    end # class Connection

  end # module LDAP

end # module Devise