/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco 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 Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.security.sync.ldap;

import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.AbstractCollection;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Pattern;

import javax.naming.CommunicationException;
import javax.naming.CompositeName;
import javax.naming.Context;
import javax.naming.InvalidNameException;
import javax.naming.Name;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.Control;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.PagedResultsControl;
import javax.naming.ldap.PagedResultsResponseControl;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.authentication.AuthenticationDiagnostic;
import org.alfresco.repo.security.authentication.AuthenticationException;
import org.alfresco.repo.security.authentication.ldap.LDAPInitialDirContextFactory;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.repo.security.sync.UserRegistry;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.Pair;
import org.alfresco.util.PropertyMap;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;

/**
 * A {@link UserRegistry} implementation with the ability to query Alfresco-like descriptions of users and groups from
 * an LDAP directory, optionally restricted to those modified since a certain time.
 * 
 * @author dward
 */
public class LDAPUserRegistry implements UserRegistry, LDAPNameResolver, InitializingBean, ActivateableBean
{

    /** The logger. */
    private static Log logger = LogFactory.getLog(LDAPUserRegistry.class);

    /** The regular expression that will match the attribute at the end of a range. */
    private static final Pattern PATTERN_RANGE_END = Pattern.compile(";range=[0-9]+-\\*");

    public static final String NAMING_TIMEOUT_EXCEPTION_MESSAGE = "LDAP response read timed out";

    /** Is this bean active? I.e. should this part of the subsystem be used? */
    private boolean active = true;

    /** Enable progress estimation? When enabled, the user query has to be run twice in order to count entries. */
    private boolean enableProgressEstimation = true;

    /** The group query. */
    private String groupQuery = "(objectclass=groupOfNames)";

    /** The group differential query. */
    private String groupDifferentialQuery = "(&(objectclass=groupOfNames)(!(modifyTimestamp<={0})))";

    /** The person query. */
    private String personQuery = "(objectclass=inetOrgPerson)";

    /** The person differential query. */
    private String personDifferentialQuery = "(&(objectclass=inetOrgPerson)(!(modifyTimestamp<={0})))";

    /** The group search base. */
    private String groupSearchBase;

    /** The user search base. */
    private String userSearchBase;

    /** The group id attribute name. */
    private String groupIdAttributeName = "cn";

    /** The user id attribute name. */
    private String userIdAttributeName = "uid";

    /** The member attribute name. */
    private String memberAttributeName = "member";

    /** The modification timestamp attribute name. */
    private String modifyTimestampAttributeName = "modifyTimestamp";

    /** The group type. */
    private String groupType = "groupOfNames";

    /** The person type. */
    private String personType = "inetOrgPerson";

    /** The ldap initial context factory. */
    private LDAPInitialDirContextFactory ldapInitialContextFactory;

    /** The namespace service. */
    private NamespaceService namespaceService;

    /** The person attribute mapping. */
    private Map<String, String> personAttributeMapping;

    /** The person attribute defaults. */
    private Map<String, String> personAttributeDefaults = Collections.emptyMap();

    /** The group attribute mapping. */
    private Map<String, String> groupAttributeMapping;

    /** The group attribute defaults. */
    private Map<String, String> groupAttributeDefaults = Collections.emptyMap();

    /**
     * The query batch size. If positive, indicates that RFC 2696 paged results should be used to split query results
     * into batches of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    private int queryBatchSize;

    /**
     * The attribute retrieval batch size. If positive, indicates that range retrieval should be used to fetch
     * multi-valued attributes (such as member) in batches of the specified size. Overcomes any size limits imposed by
     * the LDAP server.
     */
    private int attributeBatchSize;

    /** Should we error on missing group members?. */
    private boolean errorOnMissingMembers;

    /** Should we error on duplicate group IDs?. */
    private boolean errorOnDuplicateGID;

    /** Should we error on missing group IDs?. */
    private boolean errorOnMissingGID = false;

    /** Should we error on missing user IDs?. */
    private boolean errorOnMissingUID = false;

    /** An array of all LDAP attributes to be queried from users plus a set of property QNames. */
    private Pair<String[], Set<QName>> userKeys;

    /** An array of all LDAP attributes to be queried from groups plus a set of property QNames. */
    private Pair<String[], Set<QName>> groupKeys;

    /** The LDAP generalized time format. */
    private DateFormat timestampFormat;

    /** The LDAP User Account Status Property Interpreter */
    private AbstractDirectoryServiceUserAccountStatusInterpreter userAccountStatusInterpreter;

    /**
     * Instantiates a new lDAP user registry.
     */
    public LDAPUserRegistry()
    {
        // Default to official LDAP generalized time format (unfortunately not used by Active Directory)
        setTimestampFormat("yyyyMMddHHmmss'Z'");
    }

    /**
     * Controls whether this bean is active. I.e. should this part of the subsystem be used?
     * 
     * @param active
     *            <code>true</code> if this bean is active
     */
    public void setActive(boolean active)
    {
        this.active = active;
    }

    /**
     * Controls whether progress estimation is enabled. When enabled, the user query has to be run twice in order to
     * count entries.
     * 
     * @param enableProgressEstimation
     *            <code>true</code> if progress estimation is enabled
     */
    public void setEnableProgressEstimation(boolean enableProgressEstimation)
    {
        this.enableProgressEstimation = enableProgressEstimation;
    }

    /**
     * Sets the group id attribute name.
     * 
     * @param groupIdAttributeName
     *            the group id attribute name
     */
    public void setGroupIdAttributeName(String groupIdAttributeName)
    {
        this.groupIdAttributeName = groupIdAttributeName;
    }

    /**
     * Sets the group query.
     * 
     * @param groupQuery
     *            the group query
     */
    public void setGroupQuery(String groupQuery)
    {
        this.groupQuery = groupQuery;
    }

    /**
     * Sets the group differential query.
     * 
     * @param groupDifferentialQuery
     *            the group differential query
     */
    public void setGroupDifferentialQuery(String groupDifferentialQuery)
    {
        this.groupDifferentialQuery = groupDifferentialQuery;
    }

    /**
     * Sets the person query.
     * 
     * @param personQuery
     *            the person query
     */
    public void setPersonQuery(String personQuery)
    {
        this.personQuery = personQuery;
    }

    /**
     * Sets the person differential query.
     * 
     * @param personDifferentialQuery
     *            the person differential query
     */
    public void setPersonDifferentialQuery(String personDifferentialQuery)
    {
        this.personDifferentialQuery = personDifferentialQuery;
    }

    /**
     * Sets the group type.
     * 
     * @param groupType
     *            the group type
     */
    public void setGroupType(String groupType)
    {
        this.groupType = groupType;
    }

    /**
     * Sets the member attribute name.
     * 
     * @param memberAttribute
     *            the member attribute name
     */
    public void setMemberAttribute(String memberAttribute)
    {
        this.memberAttributeName = memberAttribute;
    }

    /**
     * Sets the person type.
     * 
     * @param personType
     *            the person type
     */
    public void setPersonType(String personType)
    {
        this.personType = personType;
    }

    /**
     * Sets the group search base.
     * 
     * @param groupSearchBase
     *            the group search base
     */
    public void setGroupSearchBase(String groupSearchBase)
    {
        this.groupSearchBase = groupSearchBase;
    }

    /**
     * Sets the user search base.
     * 
     * @param userSearchBase
     *            the user search base
     */
    public void setUserSearchBase(String userSearchBase)
    {
        this.userSearchBase = userSearchBase;
    }

    /**
     * Sets the user id attribute name.
     * 
     * @param userIdAttributeName
     *            the user id attribute name
     */
    public void setUserIdAttributeName(String userIdAttributeName)
    {
        this.userIdAttributeName = userIdAttributeName;
    }

    /**
     * Sets the modification timestamp attribute name.
     * 
     * @param modifyTimestampAttributeName
     *            the modification timestamp attribute name
     */
    public void setModifyTimestampAttributeName(String modifyTimestampAttributeName)
    {
        this.modifyTimestampAttributeName = modifyTimestampAttributeName;
    }

    /**
     * Sets the timestamp format. Unfortunately, this varies between directory servers.
     * 
     * @param timestampFormat
     *            the timestamp format
     *            <ul>
     *            <li>OpenLDAP: "yyyyMMddHHmmss'Z'"
     *            <li>Active Directory: "yyyyMMddHHmmss'.0Z'"
     *            </ul>
     */
    public void setTimestampFormat(String timestampFormat)
    {
        this.timestampFormat = new SimpleDateFormat(timestampFormat, Locale.UK);
        this.timestampFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
    }

    /**
     * Decides whether to error on missing group members.
     * 
     * @param errorOnMissingMembers
     *            <code>true</code> if we should error on missing group members
     */
    public void setErrorOnMissingMembers(boolean errorOnMissingMembers)
    {
        this.errorOnMissingMembers = errorOnMissingMembers;
    }

    /**
     * Decides whether to error on missing group IDs.
     * 
     * @param errorOnMissingGID
     *            <code>true</code> if we should error on missing group IDs
     */
    public void setErrorOnMissingGID(boolean errorOnMissingGID)
    {
        this.errorOnMissingGID = errorOnMissingGID;
    }

    /**
     * Decides whether to error on missing user IDs.
     * 
     * @param errorOnMissingUID
     *            <code>true</code> if we should error on missing user IDs
     */
    public void setErrorOnMissingUID(boolean errorOnMissingUID)
    {
        this.errorOnMissingUID = errorOnMissingUID;
    }

    /**
     * Decides whether to error on duplicate group IDs.
     * 
     * @param errorOnDuplicateGID
     *            <code>true</code> if we should error on duplicate group IDs
     */
    public void setErrorOnDuplicateGID(boolean errorOnDuplicateGID)
    {
        this.errorOnDuplicateGID = errorOnDuplicateGID;
    }

    /**
     * Sets the LDAP initial dir context factory.
     * 
     * @param ldapInitialDirContextFactory
     *            the new LDAP initial dir context factory
     */
    public void setLDAPInitialDirContextFactory(LDAPInitialDirContextFactory ldapInitialDirContextFactory)
    {
        this.ldapInitialContextFactory = ldapInitialDirContextFactory;
    }

    /**
     * Sets the namespace service.
     * 
     * @param namespaceService
     *            the namespace service
     */
    public void setNamespaceService(NamespaceService namespaceService)
    {
        this.namespaceService = namespaceService;
    }

    /**
     * Sets the person attribute defaults.
     * 
     * @param personAttributeDefaults
     *            the person attribute defaults
     */
    public void setPersonAttributeDefaults(Map<String, String> personAttributeDefaults)
    {
        this.personAttributeDefaults = personAttributeDefaults;
    }

    /**
     * Sets the person attribute mapping.
     * 
     * @param personAttributeMapping
     *            the person attribute mapping
     */
    public void setPersonAttributeMapping(Map<String, String> personAttributeMapping)
    {
        this.personAttributeMapping = personAttributeMapping;
    }

    /**
     * Sets the group attribute defaults.
     * 
     * @param groupAttributeDefaults
     *            the group attribute defaults
     */
    public void setGroupAttributeDefaults(Map<String, String> groupAttributeDefaults)
    {
        this.groupAttributeDefaults = groupAttributeDefaults;
    }

    /**
     * Sets the group attribute mapping.
     * 
     * @param groupAttributeMapping
     *            the group attribute mapping
     */
    public void setGroupAttributeMapping(Map<String, String> groupAttributeMapping)
    {
        this.groupAttributeMapping = groupAttributeMapping;
    }

    /**
     * Sets the query batch size.
     * 
     * @param queryBatchSize
     *            If positive, indicates that RFC 2696 paged results should be used to split query results into batches
     *            of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    public void setQueryBatchSize(int queryBatchSize)
    {
        this.queryBatchSize = queryBatchSize;
    }

    /**
     * Sets the attribute batch size.
     * 
     * @param attributeBatchSize
     *            If positive, indicates that range retrieval should be used to fetch multi-valued attributes (such as
     *            member) in batches of the specified size. Overcomes any size limits imposed by the LDAP server.
     */
    public void setAttributeBatchSize(int attributeBatchSize)
    {
        this.attributeBatchSize = attributeBatchSize;
    }

    public void setUserAccountStatusInterpreter(AbstractDirectoryServiceUserAccountStatusInterpreter userAccountStatusInterpreter)
    {
        this.userAccountStatusInterpreter = userAccountStatusInterpreter;
    }

    public AbstractDirectoryServiceUserAccountStatusInterpreter getUserAccountStatusInterpreter()
    {
        return userAccountStatusInterpreter;
    }

    /*
     * (non-Javadoc)
     * @see org.alfresco.repo.management.subsystems.ActivateableBean#isActive()
     */
    public boolean isActive()
    {
        return this.active;
    }

    /*
     * (non-Javadoc)
     * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
     */
    public void afterPropertiesSet() throws Exception
    {
        if (this.personAttributeMapping == null)
        {
            this.personAttributeMapping = new HashMap<String, String>(5);
        }
        this.personAttributeMapping.put(ContentModel.PROP_USERNAME.toPrefixString(this.namespaceService),
                this.userIdAttributeName);
        this.userKeys = initKeys(this.personAttributeMapping);
        
        // Include a range restriction for the multi-valued member attribute if this is enabled
        if (this.groupAttributeMapping == null)
        {
            this.groupAttributeMapping = new HashMap<String, String>(5);
        }
        this.groupAttributeMapping.put(ContentModel.PROP_AUTHORITY_NAME.toPrefixString(this.namespaceService),
                this.groupIdAttributeName);
        this.groupKeys = initKeys(this.groupAttributeMapping,
                this.attributeBatchSize > 0 ? this.memberAttributeName + ";range=0-" + (this.attributeBatchSize - 1)
                        : this.memberAttributeName);
    }

    
    /* (non-Javadoc)
     * @see org.alfresco.repo.security.sync.UserRegistry#getPersonMappedProperties()
     */
    public Set<QName> getPersonMappedProperties()
    {
        return this.userKeys.getSecond();
    }

    /*
     * (non-Javadoc)
     * @see org.alfresco.repo.security.sync.UserRegistry#getPersons(java.util.Date)
     */
    public Collection<NodeDescription> getPersons(Date modifiedSince)
    {
        return new PersonCollection(modifiedSince);
    }

    
    /* (non-Javadoc)
     * @see org.alfresco.repo.security.sync.UserRegistry#getPersonNames()
     */
    public Collection<String> getPersonNames()
    {
        final List<String> personNames = new LinkedList<String>();
        processQuery(new AbstractSearchCallback()
        {
            protected void doProcess(SearchResult result) throws NamingException, ParseException
            {
                Attribute nameAttribute = result.getAttributes().get(LDAPUserRegistry.this.userIdAttributeName);
                if (nameAttribute == null)
                {
                    if (LDAPUserRegistry.this.errorOnMissingUID)
                    {
                        Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.userIdAttributeName};
                        throw new AlfrescoRuntimeException("synchronization.err.ldap.get.user.id.missing", params);
                    }
                    else
                    {
                        LDAPUserRegistry.logger.warn("User missing user id attribute DN ="
                                + result.getNameInNamespace() + "  att = " + LDAPUserRegistry.this.userIdAttributeName);
                    }
                }
                else
                {
                    if (LDAPUserRegistry.logger.isDebugEnabled())
                    {
                        LDAPUserRegistry.logger.debug("Person DN recognized: " + nameAttribute.get());
                    }
                    personNames.add((String) nameAttribute.get());
                }
            }

            public void close() throws NamingException
            {
            }

        }, this.userSearchBase, this.personQuery, new String[]
        {
            this.userIdAttributeName
        });
        return personNames;
    }

    /* (non-Javadoc)
     * @see org.alfresco.repo.security.sync.UserRegistry#getGroupNames()
     */
    public Collection<String> getGroupNames()
    {
        final List<String> groupNames = new LinkedList<String>();
        processQuery(new AbstractSearchCallback()
        {

            protected void doProcess(SearchResult result) throws NamingException, ParseException
            {
                Attribute nameAttribute = result.getAttributes().get(LDAPUserRegistry.this.groupIdAttributeName);
                if (nameAttribute == null)
                {
                    if (LDAPUserRegistry.this.errorOnMissingGID)
                    {
                        Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.groupIdAttributeName};
                        throw new AlfrescoRuntimeException("synchronization.err.ldap.get.group.id.missing", params);
                    }
                    else
                    {
                        LDAPUserRegistry.logger.warn("Missing GID on " + result.getNameInNamespace());
                    }
                }
                else
                {
                    String authority = "GROUP_" + (String) nameAttribute.get();
                    if (LDAPUserRegistry.logger.isDebugEnabled())
                    {
                        LDAPUserRegistry.logger.debug("Group DN recognized: " + authority);
                    }
                    groupNames.add(authority);
                }
            }

            public void close() throws NamingException
            {
            }

        }, this.groupSearchBase, this.groupQuery, new String[]
        {
            this.groupIdAttributeName
        });
        return groupNames;
    }

    /*
     * (non-Javadoc)
     * @see org.alfresco.repo.security.sync.UserRegistry#getGroups(java.util.Date)
     */
    public Collection<NodeDescription> getGroups(Date modifiedSince)
    {
        // Work out whether the user and group trees are disjoint. This may allow us to optimize reverse DN
        // resolution.
        final LdapName groupDistinguishedNamePrefix;
        try
        {
            groupDistinguishedNamePrefix = fixedLdapName(this.groupSearchBase.toLowerCase());
        }
        catch (InvalidNameException e)
        {
            Object[] params = {this.groupSearchBase.toLowerCase(), e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search.base.invalid", params, e);
        }
        final LdapName userDistinguishedNamePrefix;
        try
        {
            userDistinguishedNamePrefix = fixedLdapName(this.userSearchBase.toLowerCase());
        }
        catch (InvalidNameException e)
        {
            Object[] params = {this.userSearchBase.toLowerCase(), e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search.base.invalid", params, e);
        }
 
        final boolean disjoint = !groupDistinguishedNamePrefix.startsWith(userDistinguishedNamePrefix)
                && !userDistinguishedNamePrefix.startsWith(groupDistinguishedNamePrefix);

        // Choose / generate the query
        String query;
        if (modifiedSince == null)
        {
            query = this.groupQuery;
        }
        else
        {
            query = MessageFormat.format(this.groupDifferentialQuery, this.timestampFormat.format(modifiedSince));
        }

        // Run the query and process the results
        final Map<String, NodeDescription> lookup = new TreeMap<String, NodeDescription>();
        processQuery(new AbstractSearchCallback()
        {
            // We get a whole new context to avoid interference with cookies from paged results
            private DirContext ctx = LDAPUserRegistry.this.ldapInitialContextFactory.getDefaultIntialDirContext();

            protected void doProcess(SearchResult result) throws NamingException, ParseException
            {
                Attributes attributes = result.getAttributes();
                Attribute gidAttribute = attributes.get(LDAPUserRegistry.this.groupIdAttributeName);
                if (gidAttribute == null)
                {
                    if (LDAPUserRegistry.this.errorOnMissingGID)
                    {
                        Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.groupIdAttributeName};
                        throw new AlfrescoRuntimeException("synchronization.err.ldap.get.group.id.missing", params);
                    }
                    else
                    {
                        LDAPUserRegistry.logger.warn("Missing GID on " + attributes);
                        return;
                    }
                }
                String groupShortName = gidAttribute.get(0).toString();
                String gid = "GROUP_" + groupShortName;

                NodeDescription group = lookup.get(gid);
                if (group == null)
                {
                    // Apply the mapped properties to the node description
                    group = mapToNode(LDAPUserRegistry.this.groupAttributeMapping,
                            LDAPUserRegistry.this.groupAttributeDefaults, result);

                    // Make sure the "GROUP_" prefix is applied
                    group.getProperties().put(ContentModel.PROP_AUTHORITY_NAME, gid);
                    lookup.put(gid, group);
                }
                else if (LDAPUserRegistry.this.errorOnDuplicateGID)
                {
                    throw new AlfrescoRuntimeException("Duplicate group id found for " + gid);
                }
                else
                {
                    LDAPUserRegistry.logger.warn("Duplicate gid found for " + gid + " -> merging definitions");
                }

                Set<String> childAssocs = group.getChildAssociations();

                // Get the repeating (and possibly range restricted) member attribute
                Attribute memAttribute = getRangeRestrictedAttribute(attributes,
                        LDAPUserRegistry.this.memberAttributeName);
                int nextStart = LDAPUserRegistry.this.attributeBatchSize;
                if (LDAPUserRegistry.logger.isDebugEnabled())
                {
                    LDAPUserRegistry.logger.debug("Processing group: " + gid +
                            ", from source: " + group.getSourceId());
                }
                // Loop until we get to the end of the range
                while (memAttribute != null)
                {
                    for (int i = 0; i < memAttribute.size(); i++)
                    {
                        String attribute = (String) memAttribute.get(i);
                        if (attribute != null && attribute.length() > 0)
                        {
                            try
                            {
                                // Attempt to parse the member attribute as a DN. If this fails we have a fallback
                                // in the catch block
                                LdapName distinguishedNameForComparison = fixedLdapName(attribute.toLowerCase());
                                Attribute nameAttribute;

                                // If the user and group search bases are different we may be able to recognize user
                                // and group DNs without a secondary lookup
                                if (disjoint)
                                {
                                    LdapName distinguishedName = fixedLdapName(attribute);
                                    Attributes nameAttributes = distinguishedName.getRdn(distinguishedName.size() - 1)
                                            .toAttributes();

                                    // Recognize user DNs
                                    if (distinguishedNameForComparison.startsWith(userDistinguishedNamePrefix)
                                            && (nameAttribute = nameAttributes
                                                    .get(LDAPUserRegistry.this.userIdAttributeName)) != null)
                                    {
                                        if (LDAPUserRegistry.logger.isDebugEnabled())
                                        {
                                            LDAPUserRegistry.logger.debug("User DN recognized: " + nameAttribute.get());
                                        }
                                        childAssocs.add((String) nameAttribute.get());
                                        continue;
                                    }

                                    // Recognize group DNs
                                    if (distinguishedNameForComparison.startsWith(groupDistinguishedNamePrefix)
                                            && (nameAttribute = nameAttributes
                                                    .get(LDAPUserRegistry.this.groupIdAttributeName)) != null)
                                    {
                                        if (LDAPUserRegistry.logger.isDebugEnabled())
                                        {
                                            LDAPUserRegistry.logger.debug("Group DN recognized: " + "GROUP_" + nameAttribute.get());
                                        }
                                        childAssocs.add("GROUP_" + nameAttribute.get());
                                        continue;
                                    }
                                }

                                // If we can't determine the name and type from the DN alone, try a directory lookup
                                if (distinguishedNameForComparison.startsWith(userDistinguishedNamePrefix)
                                        || distinguishedNameForComparison.startsWith(groupDistinguishedNamePrefix))
                                {
                                    try
                                    {
                                        Attributes childAttributes = this.ctx.getAttributes(jndiName(attribute),
                                                new String[]
                                                {
                                                    "objectclass", LDAPUserRegistry.this.groupIdAttributeName,
                                                    LDAPUserRegistry.this.userIdAttributeName
                                                });
                                        Attribute objectClass = childAttributes.get("objectclass");
                                        if (hasAttributeValue(objectClass, LDAPUserRegistry.this.personType))
                                        {
                                            nameAttribute = childAttributes
                                                    .get(LDAPUserRegistry.this.userIdAttributeName);
                                            if (nameAttribute == null)
                                            {
                                                if (LDAPUserRegistry.this.errorOnMissingUID)
                                                {
                                                    throw new AlfrescoRuntimeException(
                                                            "User missing user id attribute DN =" + attribute
                                                                    + "  att = "
                                                                    + LDAPUserRegistry.this.userIdAttributeName);
                                                }
                                                else
                                                {
                                                    LDAPUserRegistry.logger.warn("User missing user id attribute DN ="
                                                            + attribute + "  att = "
                                                            + LDAPUserRegistry.this.userIdAttributeName);
                                                    continue;
                                                }
                                            }
                                            if (LDAPUserRegistry.logger.isDebugEnabled())
                                            {
                                                LDAPUserRegistry.logger.debug("User DN recognized by directory lookup: " + nameAttribute.get());
                                            }
                                            childAssocs.add((String) nameAttribute.get());
                                            continue;
                                        }
                                        else if (hasAttributeValue(objectClass, LDAPUserRegistry.this.groupType))
                                        {
                                            nameAttribute = childAttributes
                                                    .get(LDAPUserRegistry.this.groupIdAttributeName);
                                            if (nameAttribute == null)
                                            {
                                                if (LDAPUserRegistry.this.errorOnMissingGID)
                                                {
                                                    Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.groupIdAttributeName};
                                                    throw new AlfrescoRuntimeException("synchronization.err.ldap.get.group.id.missing", params);
                                                }
                                                else
                                                {
                                                    LDAPUserRegistry.logger.warn("Missing GID on " + childAttributes);
                                                    continue;
                                                }
                                            }
                                            if (LDAPUserRegistry.logger.isDebugEnabled())
                                            {
                                                LDAPUserRegistry.logger.debug("Group DN recognized by directory lookup: " + "GROUP_" + nameAttribute.get());
                                            }
                                            childAssocs.add("GROUP_" + nameAttribute.get());
                                            continue;
                                        }
                                    }
                                    catch (NamingException e)
                                    {
                                        // Check if it is a timeout and fail
                                        // MNT-17966
                                        if (e.getMessage() != null && e.getMessage().startsWith(NAMING_TIMEOUT_EXCEPTION_MESSAGE))
                                        {
                                            Object[] params = {e.getLocalizedMessage() };
                                            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
                                        }
                                        // Unresolvable name
                                        if (LDAPUserRegistry.this.errorOnMissingMembers)
                                        {
                                            Object[] params = {groupShortName, attribute, e.getLocalizedMessage() };
                                            throw new AlfrescoRuntimeException("synchronization.err.ldap.group.member.missing.exception", params, e);
                                        }
                                        LDAPUserRegistry.logger.warn("Failed to resolve member of group '"
                                                + groupShortName + "' with distinguished name: " + attribute, e);
                                        continue;
                                    }
                                }
                                if (LDAPUserRegistry.this.errorOnMissingMembers)
                                {
                                    Object[] params = {groupShortName, attribute};
                                    throw new AlfrescoRuntimeException("synchronization.err.ldap.group.member.missing", params);
                                }
                                LDAPUserRegistry.logger.warn("Failed to resolve member of group '" + groupShortName
                                        + "' with distinguished name: " + attribute);
                            }
                            catch (InvalidNameException e)
                            {
                                // The member attribute didn't parse as a DN. So assume we have a group class like
                                // posixGroup (FDS) that directly lists user names
                                if (LDAPUserRegistry.logger.isDebugEnabled())
                                {
                                    LDAPUserRegistry.logger.debug("Member DN recognized as posixGroup: " + attribute);
                                }                                
                                childAssocs.add(attribute);
                            }
                        }
                    }

                    // If we are using attribute matching and we haven't got to the end (indicated by an asterisk),
                    // fetch the next batch
                    if (nextStart > 0
                            && !LDAPUserRegistry.PATTERN_RANGE_END.matcher(memAttribute.getID().toLowerCase()).find())
                    {
                        Attributes childAttributes = this.ctx.getAttributes(jndiName(result.getNameInNamespace()),
                                new String[]
                                {
                                    LDAPUserRegistry.this.memberAttributeName + ";range=" + nextStart + '-'
                                            + (nextStart + LDAPUserRegistry.this.attributeBatchSize - 1)
                                });
                        memAttribute = getRangeRestrictedAttribute(childAttributes,
                                LDAPUserRegistry.this.memberAttributeName);
                        nextStart += LDAPUserRegistry.this.attributeBatchSize;
                    }
                    else
                    {
                        memAttribute = null;
                    }
                }
            }

            public void close() throws NamingException
            {
                this.ctx.close();
            }
        }, this.groupSearchBase, query, this.groupKeys.getFirst());

        if (LDAPUserRegistry.logger.isDebugEnabled())
        {
            LDAPUserRegistry.logger.debug("Found " + lookup.size());
        }

        return lookup.values();
    }

    /*
     * (non-Javadoc)
     * @see org.alfresco.repo.security.sync.ldap.LDAPNameResolver#resolveDistinguishedName(java.lang.String)
     */
    public String resolveDistinguishedName(String userId, AuthenticationDiagnostic diagnostic) throws AuthenticationException
    {
        if(logger.isDebugEnabled())
        {
            logger.debug("resolveDistinguishedName userId:" + userId);
        }
        SearchControls userSearchCtls = new SearchControls();
        userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        // Although we don't actually need any attributes, we ask for the UID for compatibility with Sun Directory Server. See ALF-3868
        userSearchCtls.setReturningAttributes(new String[]
        {
            this.userIdAttributeName
        });
        
        String query = this.userSearchBase + "(&" + this.personQuery
        + "(" + this.userIdAttributeName + "= userId))";


        NamingEnumeration<SearchResult> searchResults = null;
        SearchResult result = null;

        InitialDirContext ctx = null;
        try
        {
            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext(diagnostic);

            // Execute the user query with an additional condition that ensures only the user with the required ID is
            // returned. Force RFC 2254 escaping of the user ID in the filter to avoid any manipulation            
            
             searchResults = ctx.search(this.userSearchBase, "(&" + this.personQuery
                    + "(" + this.userIdAttributeName + "={0}))", new Object[]
            {
                userId
            }, userSearchCtls);

            if (searchResults.hasMore())
            {
                result = searchResults.next();
                Attributes attributes = result.getAttributes();
                Attribute uidAttribute = attributes.get(this.userIdAttributeName);
                if (uidAttribute == null)
                {
                    if (this.errorOnMissingUID)
                    {
                        throw new AlfrescoRuntimeException(
                                "User returned by user search does not have mandatory user id attribute "
                                        + attributes);
                    }
                    else
                    {
                        LDAPUserRegistry.logger
                                .warn("User returned by user search does not have mandatory user id attribute "
                                        + attributes);
                    }
                }
                // MNT:2597 We don't trust the LDAP server's treatment of whitespace, accented characters etc. We will
                // only resolve this user if the user ID matches
                else if (userId.equalsIgnoreCase((String) uidAttribute.get(0)))
                {
                    String name = result.getNameInNamespace();

                    // Close the contexts, see ALF-20682
                    Context context = (Context) result.getObject();
                    if (context != null)
                    {
                        context.close();
                    }
                    result = null;
                    return name;
                }

                // Close the contexts, see ALF-20682
                Context context = (Context) result.getObject();
                if (context != null)
                {
                    context.close();
                }
                result = null;
            }
            
            Object[] args = {userId, query};
            diagnostic.addStep(AuthenticationDiagnostic.STEP_KEY_LDAP_LOOKUP_USER, false, args);
            
            throw new AuthenticationException("authentication.err.connection.ldap.user.notfound", args, diagnostic);
        }
        catch (NamingException e)
        {
            // Connection is good here - AuthenticationException would be thrown by ldapInitialContextFactory
            
            Object[] args1 = {userId, query};
            diagnostic.addStep(AuthenticationDiagnostic.STEP_KEY_LDAP_SEARCH, false, args1);
            
            // failed to search
            Object[] args = {e.getLocalizedMessage()};
            throw new AuthenticationException("authentication.err.connection.ldap.search", diagnostic, args, e);
        }
        finally
        {
            if (result != null)
            {
                try
                {
                    Context context = (Context) result.getObject();
                    if (context != null)
                    {
                        context.close();
                    }
                }
                catch (Exception e)
                {
                    logger.debug("error when closing result block context", e);
                }
            }
            if (searchResults != null)
            {
                try
                {
                    searchResults.close();
                }
                catch (Exception e)
                {
                    logger.debug("error when closing searchResults context", e);
                }
            }
            if (ctx != null)
            {
                try
                {
                    ctx.close();
                }
                catch (NamingException e)
                {
                    logger.debug("error when closing ldap context", e);
                }
            }
        }
    }

    private Pair<String[], Set<QName>> initKeys(Map<String, String> attributeMapping,
            String... extraAttibutes)
    {
        // Compile a complete array of LDAP attribute names, including operational attributes
        Set<String> attributeSet = new TreeSet<String>();
        attributeSet.addAll(Arrays.asList(extraAttibutes));
        attributeSet.add(this.modifyTimestampAttributeName);
        for (String attribute : attributeMapping.values())
        {
            if (attribute != null)
            {
                attributeSet.add(attribute);
            }
        }
        String[] attributeNames = new String[attributeSet.size()];
        attributeSet.toArray(attributeNames);

        // Create a set with the property names converted to QNames
        Set<QName> qnames = new HashSet<QName>(attributeMapping.size() * 2);
        for (String property : attributeMapping.keySet())
        {
            qnames.add(QName.createQName(property, this.namespaceService));
        }

        return new Pair<String[], Set<QName>>(attributeNames, qnames);
    }

    private NodeDescription mapToNode(Map<String, String> attributeMapping, Map<String, String> attributeDefaults,
            SearchResult result) throws NamingException
    {
        NodeDescription nodeDescription = new NodeDescription(result.getNameInNamespace());
        Attributes ldapAttributes = result.getAttributes();

        // Parse the timestamp
        Attribute modifyTimestamp = ldapAttributes.get(this.modifyTimestampAttributeName);
        if (modifyTimestamp != null)
        {
            try
            {
                nodeDescription.setLastModified(this.timestampFormat.parse(modifyTimestamp.get().toString()));
            }
            catch (ParseException e)
            {
                throw new AlfrescoRuntimeException("Failed to parse timestamp.", e);
            }
        }

        // Apply the mapped attributes
        PropertyMap properties = nodeDescription.getProperties();
        for (String key : attributeMapping.keySet())
        {
            QName keyQName = QName.createQName(key, this.namespaceService);

            // cater for null
            String attributeName = attributeMapping.get(key);
            if (attributeName != null)
            {
                Attribute attribute = ldapAttributes.get(attributeName);
                String defaultAttribute = attributeDefaults.get(key);
                
                if (attribute != null)
                {
                    String value = (String) attribute.get(0);
                    if (value != null)
                    {
                        properties.put(keyQName, value);
                    }
                }
                else if (defaultAttribute != null)
                {
                    properties.put(keyQName, defaultAttribute);
                }
                else
                {
                    // Make sure that a 2nd sync, updates deleted ldap attributes(MNT-14026)
                    properties.put(keyQName, null);
                }
            }
            else
            {
                String defaultValue = attributeDefaults.get(key);
                if (defaultValue != null)
                {
                    properties.put(keyQName, defaultValue);
                }
            }
        }
        return nodeDescription;
    }

    /**
     * Converts a given DN into one suitable for use through JNDI. In particular, escapes special characters such as '/'
     * which have special meaning to JNDI.
     * 
     * @param dn
     *            the dn
     * @return the name
     * @throws InvalidNameException
     *             the invalid name exception
     */
    public static Name jndiName(String dn) throws InvalidNameException
    {
        Name n = new CompositeName();
        n.add(dn);
        return n;
    }
    
    /**
     * Works around a bug in the JDK DN parsing. If an RDN has trailing escaped whitespace in the format "\\20" then
     * LdapName would normally strip this. This method works around this by replacing "\\20" with "\\ " and "\\0D" with
     * "\\\r".
     * 
     * @param dn
     *            the DN
     * @return the parsed ldap name
     * @throws InvalidNameException
     *             if the DN is invalid
     */
    private static LdapName fixedLdapName(String dn) throws InvalidNameException
    {
        // Optimization for DNs without escapes in them
        if (dn.indexOf('\\') == -1)
        {
            return new LdapName(dn);
        }

        StringBuilder fixed = new StringBuilder(dn.length());
        int length = dn.length();
        for (int i = 0; i < length; i++)
        {
            char c = dn.charAt(i);
            char c1, c2;
            if (c == '\\')
            {
                if (i + 2 < length && Character.isLetterOrDigit(c1 = dn.charAt(i + 1))
                        && Character.isLetterOrDigit(c2 = dn.charAt(i + 2)))
                {
                    if (c1 == '2' && c2 == '0')
                    {
                        fixed.append("\\ ");
                    }
                    else if (c1 == '0' && c2 == 'D')
                    {
                        fixed.append("\\\r");
                    }
                    else
                    {
                        fixed.append(dn, i, i + 3);
                    }
                    i += 2;
                }
                else if (i + 1 < length)
                {
                    fixed.append(dn, i, i + 2);
                    i += 1;
                }
                else
                {
                    fixed.append(c);
                }
            }
            else
            {
                fixed.append(c);
            }
        }
        return new LdapName(fixed.toString());
    }

    /**
     * Invokes the given callback on each entry returned by the given query.
     * 
     * @param callback
     *            the callback
     * @param searchBase
     *            the base DN for the search
     * @param query
     *            the query
     * @param returningAttributes
     *            the attributes to include in search results
     * @throws AlfrescoRuntimeException           
     */
    private void processQuery(SearchCallback callback, String searchBase, String query, String[] returningAttributes)
    {
        SearchControls searchControls = new SearchControls();
        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        searchControls.setReturningAttributes(returningAttributes);
        if (LDAPUserRegistry.logger.isDebugEnabled())
        {
            LDAPUserRegistry.logger.debug("Processing query");
            LDAPUserRegistry.logger.debug("Search base: " + searchBase);
            LDAPUserRegistry.logger.debug("    Return result limit: " + searchControls.getCountLimit());
            LDAPUserRegistry.logger.debug("    DerefLink: " + searchControls.getDerefLinkFlag());
            LDAPUserRegistry.logger.debug("    Return named object: " + searchControls.getReturningObjFlag());
            LDAPUserRegistry.logger.debug("    Time limit for search: " + searchControls.getTimeLimit());
            LDAPUserRegistry.logger.debug("    Attributes to return: " + returningAttributes.length + " items.");
            for (String ra : returningAttributes)
            {
                LDAPUserRegistry.logger.debug("        Attribute: " + ra);
            }
        }
        InitialDirContext ctx = null;
        NamingEnumeration<SearchResult> searchResults = null;
        SearchResult result = null;
        try
        {
            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext(this.queryBatchSize);
            do
            {
                searchResults = ctx.search(searchBase, query, searchControls);

                while (searchResults.hasMore())
                {
                    result = searchResults.next();
                    callback.process(result);

                    // Close the contexts, see ALF-20682
                    Context resultCtx = (Context)result.getObject();
                    if(resultCtx != null)
                    {
                    	resultCtx.close();
                    }
                    result = null;
                }
            }
            while (this.ldapInitialContextFactory.hasNextPage(ctx, this.queryBatchSize));
        }
        catch (NamingException e)
        {
            Object[] params = {e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
        }
        catch (ParseException e)
        {
            Object[] params = {e.getLocalizedMessage()};
            throw new AlfrescoRuntimeException("synchronization.err.ldap.search", params, e);
        }
        finally
        {
            if (result != null)
            {
                try
                {
                	Context resultCtx = (Context)result.getObject();
                    if(resultCtx != null)
                    {
                    	resultCtx.close();
                    }
                }
                catch (Exception e)
                {
                    logger.debug("error when closing result block context", e);
                }
            }
            if (searchResults != null)
            {
                try
                {
                    searchResults.close();
                }
                catch (Exception e)
                {
                    logger.debug("error when closing searchResults context", e);
                }
                searchResults = null;
            }
            if (ctx != null)
            {
                try
                {
                    ctx.close();
                }
                catch (NamingException e)
                {
                }
            }
            try
            {
                callback.close();
            }
            catch (NamingException e)
            {
            }
        }
    }

    /**
     * Does a case-insensitive search for the given value in an attribute.
     * 
     * @param attribute
     *            the attribute
     * @param value
     *            the value to search for
     * @return <code>true</code>, if the value was found
     * @throws NamingException
     *             if there is a problem accessing the attribute values
     */
    private boolean hasAttributeValue(Attribute attribute, String value) throws NamingException
    {
        if (attribute != null)
        {
            NamingEnumeration<?> values = attribute.getAll();
            while (values.hasMore())
            {
                try
                {
                    if (value.equalsIgnoreCase((String) values.next()))
                    {
                        return true;
                    }
                }
                catch (ClassCastException e)
                {
                    // Not a string value. ignore and continue
                }
            }
        }
        return false;
    }

    /**
     * Gets the values of a repeating attribute that may have range restriction options. If an attribute is range
     * restricted, it will appear in the attribute set with a ";range=i-j" option, where i and j indicate the start and
     * end index, and j is '*' if it is at the end.
     * 
     * @param attributes
     *            the attributes
     * @param attributeName
     *            the attribute name
     * @return the range restricted attribute
     * @throws NamingException
     *             the naming exception
     */
    private Attribute getRangeRestrictedAttribute(Attributes attributes, String attributeName) throws NamingException
    {
        Attribute unrestricted = attributes.get(attributeName);
        if (unrestricted != null)
        {
            return unrestricted;
        }
        NamingEnumeration<? extends Attribute> i = attributes.getAll();
        String searchString = attributeName.toLowerCase() + ';';
        while (i.hasMore())
        {
            Attribute attribute = i.next();
            if (attribute.getID().toLowerCase().startsWith(searchString))
            {
                return attribute;
            }
        }
        return null;
    }

    /**
     * Wraps the LDAP user query as a virtual {@link Collection}.
     */
    public class PersonCollection extends AbstractCollection<NodeDescription>
    {

        /** The query. */
        private String query;

        /** The total estimated size. */
        private int totalEstimatedSize;

        /**
         * Instantiates a new person collection.
         * 
         * @param modifiedSince
         *            if non-null, then only descriptions of users modified since this date should be returned; if
         *            <code>null</code> then descriptions of all users should be returned.
         */
        public PersonCollection(Date modifiedSince)
        {
            // Choose / generate the appropriate query
            if (modifiedSince == null)
            {
                this.query = LDAPUserRegistry.this.personQuery;
            }
            else
            {
                this.query = MessageFormat.format(LDAPUserRegistry.this.personDifferentialQuery,
                        LDAPUserRegistry.this.timestampFormat.format(modifiedSince));
            }

            // Estimate the size of this collection by running the entire query once, if progress
            // estimation is enabled
            if (LDAPUserRegistry.this.enableProgressEstimation)
            {
                class CountingCallback extends AbstractSearchCallback
                {
                    int count;

                    /*
                     * (non-Javadoc)
                     * @see
                     * org.alfresco.repo.security.sync.ldap.LDAPUserRegistry.SearchCallback#process(javax.naming.directory
                     * .SearchResult)
                     */
                    protected void doProcess(SearchResult result) throws NamingException, ParseException
                    {
                        this.count++;
                        if (LDAPUserRegistry.logger.isDebugEnabled())
                        {
                            String personName = result.getNameInNamespace();
                            LDAPUserRegistry.logger.debug("Processing person: " + personName);
                        }
                    }

                    /*
                     * (non-Javadoc)
                     * @see org.alfresco.repo.security.sync.ldap.LDAPUserRegistry.SearchCallback#close()
                     */
                    public void close() throws NamingException
                    {
                    }

                }
                CountingCallback countingCallback = new CountingCallback();
                processQuery(countingCallback, LDAPUserRegistry.this.userSearchBase, this.query, new String[] {});
                this.totalEstimatedSize = countingCallback.count;
            }
            else
            {
                this.totalEstimatedSize = -1;
            }
        }

        /*
         * (non-Javadoc)
         * @see java.util.AbstractCollection#iterator()
         */
        @Override
        public Iterator<NodeDescription> iterator()
        {
            return new PersonIterator();
        }

        /*
         * (non-Javadoc)
         * @see java.util.AbstractCollection#size()
         */
        @Override
        public int size()
        {
            return this.totalEstimatedSize;
        }

        /**
         * An iterator over the person collection. Wraps the LDAP query in 'real time'.
         */
        private class PersonIterator implements Iterator<NodeDescription>
        {

            /** The directory context. */
            private InitialDirContext ctx;

            /** The user search controls. */
            private SearchControls userSearchCtls;

            /** The search results. */
            private NamingEnumeration<SearchResult> searchResults;

            /** The uids. */
            private HashSet<String> uids = new HashSet<String>();

            /** The next node description to return. */
            private NodeDescription next;

            /** Paged result response control retrieved from ldap server */
            private PagedResultsResponseControl pagedResultsResponseControl;

            /** Stores last processed person uid */
            private String lastProcessedPerson;

            /** Indicates that sync process has been retried and we still don't reach last processed person */
            private boolean skipToLastProcessedPerson;

            /**
             * Instantiates a new person iterator.
             */
            public PersonIterator()
            {
                try
                {

                    this.ctx = LDAPUserRegistry.this.ldapInitialContextFactory
                            .getDefaultIntialDirContext(LDAPUserRegistry.this.queryBatchSize);

                    this.userSearchCtls = new SearchControls();
                    this.userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
                    this.userSearchCtls.setReturningAttributes(LDAPUserRegistry.this.userKeys.getFirst());
                    // MNT-14001 fix, set search limit to ensure that server will not return more search results then provided by paged result control
                    this.userSearchCtls.setCountLimit(LDAPUserRegistry.this.queryBatchSize > 0 ? LDAPUserRegistry.this.queryBatchSize : 0);

                    this.next = fetchNext();
                }
                catch (NamingException e)
                {
                    throw new AlfrescoRuntimeException("Failed to import people.", e);
                }
                finally
                {
                    if (this.searchResults == null)
                    {
                        try
                        {
                            this.ctx.close();
                        }
                        catch (Exception e)
                        {
                        }
                        this.ctx = null;
                    }
                }
            }

            /*
             * (non-Javadoc)
             * @see java.util.Iterator#hasNext()
             */
            public boolean hasNext()
            {
                return this.next != null;
            }

            /*
             * (non-Javadoc)
             * @see java.util.Iterator#next()
             */
            public NodeDescription next()
            {
                if (this.next == null)
                {
                    throw new IllegalStateException();
                }
                NodeDescription current = this.next;
                try
                {
                    this.next = fetchNext();
                }
                catch(CommunicationException e)
                {
                    try
                    {
                        if (LDAPUserRegistry.logger.isDebugEnabled())
                        {
                            LDAPUserRegistry.logger.debug("CommunicationException was thrown with message: " + e.getMessage() + ". "
                                    + "Performing another attempt to sync with ldap. Last processed person was: '"
                                    + (this.lastProcessedPerson == null ? "null" : this.lastProcessedPerson) + "'");
                        }
    
                        this.ctx = LDAPUserRegistry.this.ldapInitialContextFactory
                                .getDefaultIntialDirContext(LDAPUserRegistry.this.queryBatchSize);

                        if (LDAPUserRegistry.this.queryBatchSize > 0)
                        {
                            ((LdapContext)this.ctx).setRequestControls(new Control[]
                            {
                                new PagedResultsControl(LDAPUserRegistry.this.queryBatchSize, pagedResultsResponseControl == null ? null : pagedResultsResponseControl.getCookie(), Control.CRITICAL)
                            });
                        }

                        // make sure we will skip already processed persons 
                        this.skipToLastProcessedPerson = true;
                        // release previous search results
                        this.searchResults.close();
                        this.searchResults = null;
                        // move position to next element
                        this.next = fetchNext();
                    }
                    catch(Exception ex)
                    {
                        throw new AlfrescoRuntimeException("Failed to import people. Also failed to restart sync process.", ex);
                    }
                }
                catch (NamingException e)
                {
                    throw new AlfrescoRuntimeException("Failed to import people.", e);
                }
                return current;
            }

            /**
             * Pre-fetches the next node description to be returned.
             * 
             * @return the node description
             * @throws NamingException
             *             on a naming exception
             */
            private NodeDescription fetchNext() throws NamingException
            {
                boolean readyForNextPage;
                do
                {
                    readyForNextPage = this.searchResults == null;
                    while (!readyForNextPage && this.searchResults.hasMore())
                    {
                        SearchResult result = this.searchResults.next();
                        Attributes attributes = result.getAttributes();
                        Attribute uidAttribute = attributes.get(LDAPUserRegistry.this.userIdAttributeName);
                        if (uidAttribute == null)
                        {
                            if (LDAPUserRegistry.this.errorOnMissingUID)
                            {
                                Object[] params = {result.getNameInNamespace(), LDAPUserRegistry.this.userIdAttributeName};
                                throw new AlfrescoRuntimeException("synchronization.err.ldap.get.user.id.missing", params);
                            }
                            else
                            {
                                LDAPUserRegistry.logger
                                        .warn("User returned by user search does not have mandatory user id attribute "
                                                + attributes);
                                continue;
                            }
                        }
                        String uid = (String) uidAttribute.get(0);

                        if (!this.skipToLastProcessedPerson)
                        {
                            // MNT-14001 fix, remember last processed person
                            // this will serve as indicator where we should restart sync in case if sync retry occurs
                            this.lastProcessedPerson = uid;
                        }

                        if (this.uids.contains(uid))
                        {
                            if (this.skipToLastProcessedPerson)
                            {
                                LDAPUserRegistry.logger
                                        .info("Skipping already synchronized person during sync retry - "
                                                + uid);
                                
                                if (uid.equals(this.lastProcessedPerson))
                                {
                                    // MNT-14001 fix, it looks like we already reached last processed person 
                                    this.skipToLastProcessedPerson = false;
                                }
                                continue;
                            }
                            else
                            {
                                LDAPUserRegistry.logger
                                        .warn("Duplicate uid found - there will be more than one person object for this user - "
                                                + uid);
                            }
                        }

                        this.uids.add(uid);

                        if (LDAPUserRegistry.logger.isDebugEnabled())
                        {
                            LDAPUserRegistry.logger.debug("Adding user for " + uid);
                        }

                        // Apply the mapped properties to the node description
                        NodeDescription nodeDescription = mapToNode(LDAPUserRegistry.this.personAttributeMapping,
                                LDAPUserRegistry.this.personAttributeDefaults, result);

                        Object obj = result.getObject();
                        if (obj != null && obj instanceof Context)
                        {
                            ((Context)obj).close();
                            obj = null;
                        }
                        result = null;

                        return nodeDescription;
                    }

                    // Examine the paged results control response for an indication that another page is available
                    if (!readyForNextPage)
                    {
                        readyForNextPage = LDAPUserRegistry.this.ldapInitialContextFactory.hasNextPage(this.ctx,
                                LDAPUserRegistry.this.queryBatchSize);

                        if (readyForNextPage)
                        {
                            // MNT-14001 fix, next page available - remember last paged results control
                            // using cookie from this control we can restart search from current position if needed 
                            LdapContext ldapContext = (LdapContext) this.ctx;
                            Control[] controls = ldapContext.getResponseControls();

                            if (controls != null)
                            {
                                for (Control control : controls)
                                {
                                    if (control instanceof PagedResultsResponseControl)
                                    {
                                        this.pagedResultsResponseControl = (PagedResultsResponseControl)control;
                                    }
                                }
                            }
                        }
                    }

                    // Fetch the next page if there is one
                    if (readyForNextPage)
                    {
                        this.searchResults = this.ctx.search(LDAPUserRegistry.this.userSearchBase,
                                PersonCollection.this.query, this.userSearchCtls);
                    }
                }
                while (readyForNextPage);
                this.searchResults.close();
                this.searchResults = null;
                this.ctx.close();
                this.ctx = null;
                return null;
            }

            /*
             * (non-Javadoc)
             * @see java.util.Iterator#remove()
             */
            public void remove()
            {
                throw new UnsupportedOperationException();
            }
        };
    }

    /**
     * An interface for callbacks passed to the
     * {@link LDAPUserRegistry#processQuery(SearchCallback, String, String, String[])} method.
     */
    protected static interface SearchCallback
    {

        /**
         * Processes the given search result.
         * 
         * @param result
         *            the result
         * @throws NamingException
         *             on naming exceptions
         * @throws ParseException
         *             on parse exceptions
         */
        public void process(SearchResult result) throws NamingException, ParseException;

        /**
         * Release any resources held by the callback.
         * 
         * @throws NamingException
         *             the naming exception
         */
        public void close() throws NamingException;
    }

    /**
     * An abstract implementation of SearchCallback interface.
     * Responsible for correct release of SearchResult resource.
     */
    protected abstract static class AbstractSearchCallback implements SearchCallback
    {
        @Override
        public void process(SearchResult result) throws NamingException, ParseException
        {
            try
            {
                doProcess(result);
            }
            finally
            {
                Object obj = result.getObject();
                
                if (obj != null && obj instanceof Context)
                {
                    try
                    {
                        ((Context)obj).close();
                    }
                    catch (NamingException e)
                    {
                        logger.debug("error when closing result block context", e);
                    }
                    obj = null;
                }
                result = null;
            }
        }

        protected abstract void doProcess(SearchResult result) throws NamingException, ParseException;
    }
}