/*
 * #%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.person;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.sync.events.types.Event;
import org.alfresco.sync.events.types.UserManagementEvent;
import org.alfresco.model.ContentModel;
import org.alfresco.query.CannedQueryFactory;
import org.alfresco.query.CannedQueryResults;
import org.alfresco.query.PagingRequest;
import org.alfresco.query.PagingResults;
import org.alfresco.repo.action.executer.MailActionExecuter;
import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.repo.cache.TransactionalCache;
import org.alfresco.repo.domain.permissions.AclDAO;
import org.alfresco.sync.repo.events.EventPreparator;
import org.alfresco.sync.repo.events.EventPublisher;
import org.alfresco.repo.node.NodeServicePolicies;
import org.alfresco.repo.node.NodeServicePolicies.BeforeCreateNodePolicy;
import org.alfresco.repo.node.NodeServicePolicies.BeforeDeleteNodePolicy;
import org.alfresco.repo.node.NodeServicePolicies.OnCreateNodePolicy;
import org.alfresco.repo.node.NodeServicePolicies.OnUpdatePropertiesPolicy;
import org.alfresco.repo.policy.JavaBehaviour;
import org.alfresco.repo.policy.PolicyComponent;
import org.alfresco.repo.search.SearcherException;
import org.alfresco.repo.security.authentication.AuthenticationException;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.permissions.PermissionServiceSPI;
import org.alfresco.repo.tenant.TenantDomainMismatchException;
import org.alfresco.repo.tenant.TenantService;
import org.alfresco.repo.transaction.AlfrescoTransactionSupport;
import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState;
import org.alfresco.repo.transaction.RetryingTransactionHelper;
import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback;
import org.alfresco.repo.transaction.TransactionListenerAdapter;
import org.alfresco.repo.transaction.TransactionalResourceHelper;
import org.alfresco.service.ServiceRegistry;
import org.alfresco.service.cmr.action.Action;
import org.alfresco.service.cmr.action.ActionService;
import org.alfresco.service.cmr.admin.RepoAdminService;
import org.alfresco.service.cmr.dictionary.DictionaryService;
import org.alfresco.service.cmr.invitation.InvitationException;
import org.alfresco.service.cmr.model.FileFolderService;
import org.alfresco.service.cmr.repository.ChildAssociationRef;
import org.alfresco.service.cmr.repository.InvalidNodeRefException;
import org.alfresco.service.cmr.repository.InvalidStoreRefException;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.repository.StoreRef;
import org.alfresco.service.cmr.repository.TemplateService;
import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.service.cmr.search.LimitBy;
import org.alfresco.service.cmr.search.ResultSet;
import org.alfresco.service.cmr.search.SearchParameters;
import org.alfresco.service.cmr.search.SearchService;
import org.alfresco.service.cmr.security.AccessStatus;
import org.alfresco.service.cmr.security.AuthorityService;
import org.alfresco.service.cmr.security.AuthorityType;
import org.alfresco.service.cmr.security.MutableAuthenticationService;
import org.alfresco.service.cmr.security.NoSuchPersonException;
import org.alfresco.service.cmr.security.PermissionService;
import org.alfresco.service.cmr.security.PersonService;
import org.alfresco.service.namespace.NamespacePrefixResolver;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.service.namespace.RegexQNamePattern;
import org.alfresco.service.transaction.TransactionService;
import org.alfresco.util.EqualsHelper;
import org.alfresco.util.GUID;
import org.alfresco.util.ModelUtil;
import org.alfresco.util.Pair;
import org.alfresco.util.ParameterCheck;
import org.alfresco.util.PropertyCheck;
import org.alfresco.util.registry.NamedObjectRegistry;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class PersonServiceImpl extends TransactionListenerAdapter implements PersonService,
                                                                             NodeServicePolicies.BeforeCreateNodePolicy,
                                                                             NodeServicePolicies.OnCreateNodePolicy,
                                                                             NodeServicePolicies.BeforeDeleteNodePolicy,
                                                                             NodeServicePolicies.OnUpdatePropertiesPolicy
                                                                             
{
    private static Log logger = LogFactory.getLog(PersonServiceImpl.class);
    
    static final String CANNED_QUERY_PEOPLE_LIST = "getPeopleCannedQueryFactory";

    private static final String DELETE = "DELETE";
    private static final String SPLIT = "SPLIT";
    private static final String LEAVE = "LEAVE";
    public static final String SYSTEM_FOLDER_SHORT_QNAME = "sys:system";
    public static final String PEOPLE_FOLDER_SHORT_QNAME = "sys:people";

    private static final String KEY_POST_TXN_DUPLICATES = "PersonServiceImpl.KEY_POST_TXN_DUPLICATES";
    public static final String KEY_ALLOW_UID_UPDATE = "PersonServiceImpl.KEY_ALLOW_UID_UPDATE";
    private static final String KEY_USERS_CREATED = "PersonServiceImpl.KEY_USERS_CREATED";
    private static final char[] ILLEGAL_CHARACTERS = {'/', '\\', '\r', '\n', '\"'};

    private StoreRef storeRef;
    private TransactionService transactionService;
    private NodeService nodeService;
    private TenantService tenantService;
    private SearchService searchService;
    private AuthorityService authorityService;
    private MutableAuthenticationService authenticationService;
    private DictionaryService dictionaryService;
    private PermissionServiceSPI permissionServiceSPI;
    private NamespacePrefixResolver namespacePrefixResolver;
    private HomeFolderManager homeFolderManager;
    private PolicyComponent policyComponent;
    private AclDAO aclDao;
    private PermissionsManager permissionsManager;
    private RepoAdminService repoAdminService;
    private ServiceRegistry serviceRegistry;

    private boolean createMissingPeople;
    private static Set<QName> mutableProperties;
    private String defaultHomeFolderProvider;
    private boolean processDuplicates = true;
    private String duplicateMode = LEAVE;
    private boolean lastIsBest = true;
    private boolean includeAutoCreated = false;
    private EventPublisher eventPublisher;
    
    private NamedObjectRegistry<CannedQueryFactory<NodeRef>> cannedQueryRegistry;
    
    /** a transactionally-safe cache to be injected */
    private SimpleCache<String, Set<NodeRef>> personCache;
    
    // note: cache is tenant-aware (if using EhCacheAdapter shared cache)
    private SimpleCache<String, Object> singletonCache; // eg. for peopleContainerNodeRef
    private final String KEY_PEOPLECONTAINER_NODEREF = "key.peoplecontainer.noderef";
    
    private UserNameMatcher userNameMatcher;
    
    private JavaBehaviour beforeCreateNodeValidationBehaviour;
    private JavaBehaviour beforeDeleteNodeValidationBehaviour;
    
    private boolean homeFolderCreationEager;
    
    private boolean homeFolderCreationDisabled = false; // if true then home folders creation is disabled (ie. home folders are not created - neither eagerly nor lazily)
    
    static
    {
        Set<QName> props = new HashSet<QName>();
        props.add(ContentModel.PROP_HOMEFOLDER);
        props.add(ContentModel.PROP_FIRSTNAME);
        // Middle Name
        props.add(ContentModel.PROP_LASTNAME);
        props.add(ContentModel.PROP_EMAIL);
        props.add(ContentModel.PROP_ORGID);
        mutableProperties = Collections.unmodifiableSet(props);
    }

    @Override
    public boolean equals(Object obj)
    {
        return this == obj;
    }

    @Override
    public int hashCode()
    {
        return 1;
    }

    /**
     * Spring bean init method
     */
    public void init()
    {
        PropertyCheck.mandatory(this, "storeUrl", storeRef);
        PropertyCheck.mandatory(this, "transactionService", transactionService);
        PropertyCheck.mandatory(this, "nodeService", nodeService);
        PropertyCheck.mandatory(this, "permissionServiceSPI", permissionServiceSPI);
        PropertyCheck.mandatory(this, "authorityService", authorityService);
        PropertyCheck.mandatory(this, "authenticationService", authenticationService);
        PropertyCheck.mandatory(this, "namespacePrefixResolver", namespacePrefixResolver);
        PropertyCheck.mandatory(this, "policyComponent", policyComponent);
        PropertyCheck.mandatory(this, "personCache", personCache);
        PropertyCheck.mandatory(this, "aclDao", aclDao);
        PropertyCheck.mandatory(this, "homeFolderManager", homeFolderManager);
        PropertyCheck.mandatory(this, "repoAdminService", repoAdminService);

        beforeCreateNodeValidationBehaviour = new JavaBehaviour(this, "beforeCreateNodeValidation");
        this.policyComponent.bindClassBehaviour(
                BeforeCreateNodePolicy.QNAME,
                ContentModel.TYPE_PERSON,
                beforeCreateNodeValidationBehaviour);

        beforeDeleteNodeValidationBehaviour = new JavaBehaviour(this, "beforeDeleteNodeValidation");
        this.policyComponent.bindClassBehaviour(
                BeforeDeleteNodePolicy.QNAME,
                ContentModel.TYPE_PERSON,
                beforeDeleteNodeValidationBehaviour);
        
        this.policyComponent.bindClassBehaviour(
                OnCreateNodePolicy.QNAME,
                ContentModel.TYPE_PERSON,
                new JavaBehaviour(this, "onCreateNode"));
        
        this.policyComponent.bindClassBehaviour(
                BeforeDeleteNodePolicy.QNAME,
                ContentModel.TYPE_PERSON,
                new JavaBehaviour(this, "beforeDeleteNode"));
        
        this.policyComponent.bindClassBehaviour(
                OnUpdatePropertiesPolicy.QNAME,
                ContentModel.TYPE_PERSON,
                new JavaBehaviour(this, "onUpdateProperties"));
        
        this.policyComponent.bindClassBehaviour(
                OnUpdatePropertiesPolicy.QNAME,
                ContentModel.TYPE_USER,
                new JavaBehaviour(this, "onUpdatePropertiesUser"));
    }
    
    /**
     * {@inheritDoc}
     */
    public void setCreateMissingPeople(boolean createMissingPeople)
    {
        this.createMissingPeople = createMissingPeople;
    }

    public void setNamespacePrefixResolver(NamespacePrefixResolver namespacePrefixResolver)
    {
        this.namespacePrefixResolver = namespacePrefixResolver;
    }

    public void setAuthorityService(AuthorityService authorityService)
    {
        this.authorityService = authorityService;
    }
    
    public void setAuthenticationService(MutableAuthenticationService authenticationService)
    {
        this.authenticationService = authenticationService;
    }

    public void setDictionaryService(DictionaryService dictionaryService)
    {
        this.dictionaryService = dictionaryService;
    }

    public void setPermissionServiceSPI(PermissionServiceSPI permissionServiceSPI)
    {
        this.permissionServiceSPI = permissionServiceSPI;
    }

    public void setTransactionService(TransactionService transactionService)
    {
        this.transactionService = transactionService;
    }

    public void setServiceRegistry(ServiceRegistry serviceRegistry)
    {
        this.serviceRegistry = serviceRegistry;
    }
    
    public void setNodeService(NodeService nodeService)
    {
        this.nodeService = nodeService;
    }
    
    public void setTenantService(TenantService tenantService)
    {
        this.tenantService = tenantService;
    }
    
    public void setSingletonCache(SimpleCache<String, Object> singletonCache)
    {
        this.singletonCache = singletonCache;
    }
    
    public void setSearchService(SearchService searchService)
    {
        this.searchService = searchService;
    }

    public void setRepoAdminService(RepoAdminService repoAdminService)
    {
        this.repoAdminService = repoAdminService;
    }
    
    public void setPolicyComponent(PolicyComponent policyComponent)
    {
        this.policyComponent = policyComponent;
    }
    
    public void setStoreUrl(String storeUrl)
    {
        this.storeRef = new StoreRef(storeUrl);
    }
    
    public void setUserNameMatcher(UserNameMatcher userNameMatcher)
    {
        this.userNameMatcher = userNameMatcher;
    }

    void setDefaultHomeFolderProvider(String defaultHomeFolderProvider)
    {
        this.defaultHomeFolderProvider = defaultHomeFolderProvider;
    }

    public void setDuplicateMode(String duplicateMode)
    {
        this.duplicateMode = duplicateMode;
    }

    public void setIncludeAutoCreated(boolean includeAutoCreated)
    {
        this.includeAutoCreated = includeAutoCreated;
    }

    public void setLastIsBest(boolean lastIsBest)
    {
        this.lastIsBest = lastIsBest;
    }

    public void setProcessDuplicates(boolean processDuplicates)
    {
        this.processDuplicates = processDuplicates;
    }

    public void setHomeFolderManager(HomeFolderManager homeFolderManager)
    {
        this.homeFolderManager = homeFolderManager;
    }
    
    /**
     * Indicates if home folders should be created when the person
     * is created or delayed until first accessed.
     */
    public void setHomeFolderCreationEager(boolean homeFolderCreationEager)
    {
        this.homeFolderCreationEager = homeFolderCreationEager;
    }
    
    /**
     * Indicates if home folder creation should be disabled.
     */
    public void setHomeFolderCreationDisabled(boolean homeFolderCreationDisabled)
    {
        this.homeFolderCreationDisabled = homeFolderCreationDisabled;
    }
    
    public void setAclDAO(AclDAO aclDao)
    {
        this.aclDao = aclDao;
    }

    public void setPermissionsManager(PermissionsManager permissionsManager)
    {
        this.permissionsManager = permissionsManager;
    }
    
    /**
     * Set the registry of {@link CannedQueryFactory canned queries}
     */
    public void setCannedQueryRegistry(NamedObjectRegistry<CannedQueryFactory<NodeRef>> cannedQueryRegistry)
    {
        this.cannedQueryRegistry = cannedQueryRegistry;
    }
    
    /**
     * Set the username to person cache.
     */
    public void setPersonCache(SimpleCache<String, Set<NodeRef>> personCache)
    {
        this.personCache = personCache;
    }
    
    /**
     * Avoid injection issues: Look it up from the Service Registry as required
     */
    private FileFolderService getFileFolderService()
    {
        return serviceRegistry.getFileFolderService();
    }
    
    /**
     * Avoid injection issues: Look it up from the Service Registry as required
     */
    private NamespaceService getNamespaceService()
    {
        return serviceRegistry.getNamespaceService();
    }
    
    /**
     * Avoid injection issues: Look it up from the Service Registry as required
     */
    private ActionService getActionService()
    {
        return serviceRegistry.getActionService();
    }
    
    /**
     * {@inheritDoc}
     */
    public NodeRef getPerson(String userName)
    {
        return getPerson(userName, true);
    }
    
    /**
     * {@inheritDoc}
     */
    public PersonInfo getPerson(NodeRef personRef) throws NoSuchPersonException
    {
        Map<QName, Serializable> props = null;
        try
        {
            props = nodeService.getProperties(personRef);
        }
        catch (InvalidNodeRefException inre)
        {
            throw new NoSuchPersonException(personRef.toString());
        }
        
        String username  = (String)props.get(ContentModel.PROP_USERNAME);
        if (username == null)
        {
            throw new NoSuchPersonException(personRef.toString());
        }
        
        return new PersonInfo(personRef, 
                              username, 
                              (String)props.get(ContentModel.PROP_FIRSTNAME),
                              (String)props.get(ContentModel.PROP_LASTNAME));
    }
    
    /**
     * {@inheritDoc}
     */
    public NodeRef getPersonOrNull(String userName)
    {
        return getPersonImpl(userName, false, false);
    }
    
    /**
     * {@inheritDoc}
     */
    public NodeRef getPerson(final String userName, final boolean autoCreateHomeFolderAndMissingPersonIfAllowed)
    {
        return getPersonImpl(userName, autoCreateHomeFolderAndMissingPersonIfAllowed, true);
    }
    
    
    private NodeRef getPersonImpl(
            final String userName,
            final boolean autoCreateHomeFolderAndMissingPersonIfAllowed,
            final boolean exceptionOrNull)
    {
        if (userName == null || userName.length() == 0)
        {
            return null;
        }
        if (isSystemUserName(userName))
        {
            // The built-in authority SYSTEM is a user, but not a Person (i.e. it does not have a profile).
            if (exceptionOrNull)
            {
                throw new NoSuchPersonException(userName);
            }
            return null;
        }
        
        final NodeRef personNode = getPersonOrNullImpl(userName);
        if (personNode == null)
        {
            TxnReadState txnReadState = AlfrescoTransactionSupport.getTransactionReadState();
            if (autoCreateHomeFolderAndMissingPersonIfAllowed && createMissingPeople() &&
                txnReadState == TxnReadState.TXN_READ_WRITE)
            {
                // We create missing people AND are in a read-write txn
                return createMissingPersonAsSystem(userName, true);
            }
            else
            {
                if (exceptionOrNull)
                {
                    throw new NoSuchPersonException(userName);
                }
            }
        }
        else if (autoCreateHomeFolderAndMissingPersonIfAllowed)
        {
            makeHomeFolderIfRequired(personNode);
        }
        return personNode;
    }
    
    /**
     * {@inheritDoc}
     */
    public boolean personExists(String caseSensitiveUserName)
    {
        if (isSystemUserName(caseSensitiveUserName))
        {
            return false;
        }
        
        NodeRef person = getPersonOrNullImpl(caseSensitiveUserName); 
        if (person != null)
        {
            // re: THOR-293
            return permissionServiceSPI.hasPermission(person, PermissionService.READ) == AccessStatus.ALLOWED;
        }
        return false;
    }
    
    private NodeRef getPersonOrNullImpl(String searchUserName)
    {
        Set<NodeRef> allRefs = getFromCache(searchUserName);
        boolean addToCache = false;
        if (allRefs == null)
        {
            NodeRef peopleContainer = null;
            try
            {
                peopleContainer = getPeopleContainer();
            }
            catch(InvalidStoreRefException isre)
            {
                return null;
            }
            
            List<ChildAssociationRef> childRefs = nodeService.getChildAssocs(
                    peopleContainer,
                    ContentModel.ASSOC_CHILDREN,
                    getChildNameLower(searchUserName),
                    false);
            allRefs = new LinkedHashSet<NodeRef>(childRefs.size() * 2);
            
            for (ChildAssociationRef childRef : childRefs)
            {
                NodeRef nodeRef = childRef.getChildRef();
                allRefs.add(nodeRef);
            }
            addToCache = true;
        }
        
        List<NodeRef> refs = new ArrayList<NodeRef>(allRefs.size());
        Set<NodeRef> nodesToRemoveFromCache = new HashSet<NodeRef>();
        for (NodeRef nodeRef : allRefs)
        {
            if (nodeService.exists(nodeRef))
            {
                Serializable value = nodeService.getProperty(nodeRef, ContentModel.PROP_USERNAME);
                String realUserName = DefaultTypeConverter.INSTANCE.convert(String.class, value);
                if (userNameMatcher.matches(searchUserName, realUserName))
                {
                    refs.add(nodeRef);
                }
            }
            else
            {
                nodesToRemoveFromCache.add(nodeRef);
            }
        }

        if (!nodesToRemoveFromCache.isEmpty())
        {
            allRefs.removeAll(nodesToRemoveFromCache);
        }

        NodeRef returnRef = null;
        if (refs.size() > 1)
        {
            returnRef = handleDuplicates(refs, searchUserName);
        }
        else if (refs.size() == 1)
        {
            returnRef = refs.get(0);
            
            if (addToCache)
            {
                // Don't bother caching unless we get a result that doesn't need duplicate processing
                putToCache(searchUserName, allRefs, false);
            }
        }
        return returnRef;
    }

    private NodeRef handleDuplicates(List<NodeRef> refs, String searchUserName)
    {
        if (processDuplicates)
        {
            NodeRef best = findBest(refs);
            HashSet<NodeRef> toHandle = new HashSet<NodeRef>();
            toHandle.addAll(refs);
            toHandle.remove(best);
            addDuplicateNodeRefsToHandle(toHandle);
            return best;
        }
        else
        {
            String userNameSensitivity = " (user name is case-" + (userNameMatcher.getUserNamesAreCaseSensitive() ? "sensitive" : "insensitive") + ")";
            String domainNameSensitivity = "";
            if (!userNameMatcher.getDomainSeparator().equals(""))
            {
                domainNameSensitivity = " (domain name is case-" + (userNameMatcher.getDomainNamesAreCaseSensitive() ? "sensitive" : "insensitive") + ")";
            }

            throw new AlfrescoRuntimeException("Found more than one user for " + searchUserName + userNameSensitivity + domainNameSensitivity);
        }
    }

    /**
     * Get the txn-bound usernames that need cleaning up
     */
    private Set<NodeRef> getPostTxnDuplicates()
    {
        @SuppressWarnings("unchecked")
        Set<NodeRef> postTxnDuplicates = (Set<NodeRef>) AlfrescoTransactionSupport.getResource(KEY_POST_TXN_DUPLICATES);
        if (postTxnDuplicates == null)
        {
            postTxnDuplicates = new HashSet<NodeRef>();
            AlfrescoTransactionSupport.bindResource(KEY_POST_TXN_DUPLICATES, postTxnDuplicates);
        }
        return postTxnDuplicates;
    }

    /**
     * Flag a username for cleanup after the transaction.
     */
    private void addDuplicateNodeRefsToHandle(Set<NodeRef> refs)
    {
        // Firstly, bind this service to the transaction
        AlfrescoTransactionSupport.bindListener(this);
        // Now get the post txn duplicate list
        Set<NodeRef> postTxnDuplicates = getPostTxnDuplicates();
        postTxnDuplicates.addAll(refs);
    }

    /**
     * Process clean up any duplicates that were flagged during the transaction.
     */
    @Override
    public void afterCommit()
    {
        // Get the duplicates in a form that can be read by the transaction work anonymous instance
        final Set<NodeRef> postTxnDuplicates = getPostTxnDuplicates();
        if (postTxnDuplicates.size() == 0)
        {
            // Nothing to do
            return;
        }
        
        RetryingTransactionCallback<Object> processDuplicateWork = new RetryingTransactionCallback<Object>()
        {
            public Object execute() throws Throwable
            {
                if (duplicateMode.equalsIgnoreCase(SPLIT))
                {
                        logger.info("Splitting " + postTxnDuplicates.size() + " duplicate person objects.");
                    // Allow UIDs to be updated in this transaction
                    AlfrescoTransactionSupport.bindResource(KEY_ALLOW_UID_UPDATE, Boolean.TRUE);
                    split(postTxnDuplicates);
                        logger.info("Split " + postTxnDuplicates.size() + " duplicate person objects.");
                }
                else if (duplicateMode.equalsIgnoreCase(DELETE))
                {
                    delete(postTxnDuplicates);
                    logger.info("Deleted duplicate person objects");
                }
                else
                {
                    if (logger.isDebugEnabled())
                    {
                        logger.debug("Duplicate person objects exist");
                    }
                }
                
                // Done
                return null;
            }
        };
        transactionService.getRetryingTransactionHelper().doInTransaction(processDuplicateWork, false, true);
    }

    private void delete(Set<NodeRef> toDelete)
    {
        for (NodeRef nodeRef : toDelete)
        {
            deletePerson(nodeRef);
        }
    }

    private void split(Set<NodeRef> toSplit)
    {
        for (NodeRef nodeRef : toSplit)
        {
            String userName = (String) nodeService.getProperty(nodeRef, ContentModel.PROP_USERNAME);
            String newUserName = userName + GUID.generate();
            nodeService.setProperty(nodeRef, ContentModel.PROP_USERNAME, userName + GUID.generate());
            logger.info("   New person object: " + newUserName);
        }
    }

    private NodeRef findBest(List<NodeRef> refs)
    {
        // Given that we might not have audit attributes, use the assumption that the node ID increases to sort the
        // nodes
        if (lastIsBest)
        {
            Collections.sort(refs, new NodeIdComparator(nodeService, false));
        }
        else
        {
            Collections.sort(refs, new NodeIdComparator(nodeService, true));
        }

        NodeRef fallBack = null;

        for (NodeRef nodeRef : refs)
        {
            if (fallBack == null)
            {
                fallBack = nodeRef;
            }

            if (includeAutoCreated || !wasAutoCreated(nodeRef))
            {
                return nodeRef;
            }
        }

        return fallBack;
    }

    private boolean wasAutoCreated(NodeRef nodeRef)
    {
        String userName = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_USERNAME));

        String testString = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_FIRSTNAME));
        if ((testString == null) || !testString.equals(userName))
        {
            return false;
        }

        testString = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_LASTNAME));
        if ((testString == null) || !testString.equals(""))
        {
            return false;
        }

        testString = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_EMAIL));
        if ((testString == null) || !testString.equals(""))
        {
            return false;
        }

        testString = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_ORGID));
        if ((testString == null) || !testString.equals(""))
        {
            return false;
        }

        testString = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_HOME_FOLDER_PROVIDER));
        if ((testString == null) || !testString.equals(defaultHomeFolderProvider))
        {
            return false;
        }

        return true;
    }
    
    /**
     * {@inheritDoc}
     */
    public boolean createMissingPeople()
    {
        return createMissingPeople;
    }
    
    /**
     * {@inheritDoc}
     */
    public Set<QName> getMutableProperties()
    {
        return mutableProperties;
    }
    
    /**
     * {@inheritDoc}
     */
    public void setPersonProperties(String userName, Map<QName, Serializable> properties)
    {
        setPersonProperties(userName, properties, true);
    }
    
    /**
     * {@inheritDoc}
     */
    public void setPersonProperties(String userName, Map<QName, Serializable> properties, boolean autoCreateHomeFolder)
    {
        NodeRef personNode = getPersonOrNullImpl(userName);
        if (personNode == null)
        {
            if (createMissingPeople())
            {
                personNode = createMissingPersonAsSystem(userName, autoCreateHomeFolder);
            }
            else
            {
                throw new PersonException("No person found for user name " + userName);
            }
        }
        else
        {
            // Must create the home folder first as a property holds its location.
            if (autoCreateHomeFolder)
            {
                makeHomeFolderIfRequired(personNode);                
            }
            String realUserName = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(personNode, ContentModel.PROP_USERNAME));
            String suggestedUserName;

            // LDAP sync: allow change of case if we have case insensitive user names and the same name in a different case
            if (getUserNamesAreCaseSensitive()
                    || (suggestedUserName = (String) properties.get(ContentModel.PROP_USERNAME)) == null
                    || !suggestedUserName.equalsIgnoreCase(realUserName))
            {
                properties.put(ContentModel.PROP_USERNAME, realUserName);
            }
        }

        checkIfPersonShouldBeDisabledAndSetAspect(personNode, properties);

        Map<QName, Serializable> update = nodeService.getProperties(personNode);
        update.putAll(properties);

        nodeService.setProperties(personNode, update);
    }
    
    /**
     * {@inheritDoc}
     */
    public boolean isMutable()
    {
        return true;
    }
    
    private NodeRef createMissingPersonAsSystem(final String userName, final boolean autoCreateHomeFolder)
    {
        return AuthenticationUtil.runAsSystem(new AuthenticationUtil.RunAsWork<NodeRef>()
        {
            @Override
            public NodeRef doWork() throws Exception
            {
                HashMap<QName, Serializable> properties = getDefaultProperties(userName);
                NodeRef person = createPerson(properties);

                // The home folder will ONLY exist after the the person is created if
                // homeFolderCreationEager == true
                if (autoCreateHomeFolder && homeFolderCreationEager == false)
                {
                    makeHomeFolderIfRequired(person);
                }

                return person;
            }
        });
    }
    
    private void makeHomeFolderIfRequired(NodeRef person)
    {
        if ((person != null) && (homeFolderCreationDisabled == false))
        {
            NodeRef homeFolder = DefaultTypeConverter.INSTANCE.convert(NodeRef.class, nodeService.getProperty(person, ContentModel.PROP_HOMEFOLDER));
            if (homeFolder == null)
            {
                final ChildAssociationRef ref = nodeService.getPrimaryParent(person);
                RetryingTransactionHelper txnHelper = transactionService.getRetryingTransactionHelper();
                txnHelper.setForceWritable(true);
                boolean requiresNew = false;
                if (AlfrescoTransactionSupport.getTransactionReadState() != TxnReadState.TXN_READ_WRITE)
                {
                    // We can be in a read-only transaction, so force a new transaction
                    // Note that the transaction will *always* be in read-only mode if the server read-only veto is there 
                    requiresNew = true;
                }
                txnHelper.doInTransaction(new RetryingTransactionCallback<Object>()
                {
                    public Object execute() throws Throwable
                    {
                        makeHomeFolderAsSystem(ref);
                        return null;
                    }
                }, false, requiresNew);
            }
        }
    }
    
    private void makeHomeFolderAsSystem(final ChildAssociationRef childAssocRef)
    {
        AuthenticationUtil.runAs(new AuthenticationUtil.RunAsWork<Object>()
        {
            @Override
            public Object doWork() throws Exception
            {
                homeFolderManager.makeHomeFolder(childAssocRef);
                return null;
            }
        }, AuthenticationUtil.getSystemUserName());
    }

    private HashMap<QName, Serializable> getDefaultProperties(String userName)
    {
        HashMap<QName, Serializable> properties = new HashMap<QName, Serializable>();
        properties.put(ContentModel.PROP_USERNAME, userName);
        properties.put(ContentModel.PROP_FIRSTNAME, tenantService.getBaseNameUser(userName));
        properties.put(ContentModel.PROP_LASTNAME, "");
        properties.put(ContentModel.PROP_EMAIL, "");
        properties.put(ContentModel.PROP_ORGID, "");
        properties.put(ContentModel.PROP_HOME_FOLDER_PROVIDER, defaultHomeFolderProvider);

        properties.put(ContentModel.PROP_SIZE_CURRENT, 0L);
        properties.put(ContentModel.PROP_SIZE_QUOTA, -1L); // no quota

        return properties;
    }
    
    /**
     * {@inheritDoc}
     */
    public NodeRef createPerson(Map<QName, Serializable> properties)
    {
        return createPerson(properties, authorityService.getDefaultZones());
    }
    
    /**
     * {@inheritDoc}
     */
    public NodeRef createPerson(Map<QName, Serializable> properties, Set<String> zones)
    {
        ParameterCheck.mandatory("properties", properties);
        String userName = DefaultTypeConverter.INSTANCE.convert(String.class, properties.get(ContentModel.PROP_USERNAME));
        if (userName == null)
        {
            throw new IllegalArgumentException("No username specified when creating the person.");
        }
        
        if (EqualsHelper.nullSafeEquals(userName, AuthenticationUtil.getSystemUserName()))
        {
            throw new AlfrescoRuntimeException("The built-in authority '" + AuthenticationUtil.getSystemUserName()  + "' is a user, but not a Person (i.e. it does not have a profile).");
        }

        AuthorityType authorityType = AuthorityType.getAuthorityType(userName);
        if (authorityType != AuthorityType.USER)
        {
            throw new AlfrescoRuntimeException("Attempt to create person for an authority which is not a user");
        }

        tenantService.checkDomainUser(userName);

        if (personExists(userName))
        {
            throw new AlfrescoRuntimeException("Person '" + userName + "' already exists.");
        }
        
        properties.put(ContentModel.PROP_USERNAME, userName);
        properties.put(ContentModel.PROP_SIZE_CURRENT, 0L);
        
        NodeRef personRef = null;
        try
        {
            beforeCreateNodeValidationBehaviour.disable();
            
            personRef = nodeService.createNode(
                    getPeopleContainer(),
                    ContentModel.ASSOC_CHILDREN,
                    getChildNameLower(userName), // Lowercase:
                    ContentModel.TYPE_PERSON, properties).getChildRef();         
        }
        finally
        {
            beforeCreateNodeValidationBehaviour.enable();
        }
        
        checkIfPersonShouldBeDisabledAndSetAspect(personRef, properties);
        
        if (zones != null)
        {
            for (String zone : zones)
            {
                // Add the person to an authentication zone (corresponding to an external user registry)
                // Let's preserve case on this child association
                nodeService.addChild(authorityService.getOrCreateZone(zone), personRef, ContentModel.ASSOC_IN_ZONE, QName.createQName(NamespaceService.CONTENT_MODEL_PREFIX, userName, namespacePrefixResolver));
            }
        }
        
        removeFromCache(userName, false);
        
        publishEvent("user.create", this.nodeService.getProperties(personRef));
        
        return personRef;
    }
    
    private void checkIfPersonShouldBeDisabledAndSetAspect(NodeRef person, Map<QName, Serializable> properties)
    {
        if (properties.get(ContentModel.PROP_ENABLED) != null)
        {
            boolean isEnabled = Boolean.parseBoolean(properties.get(ContentModel.PROP_ENABLED).toString());

            if (isEnabled)
            {
                if (nodeService.hasAspect(person, ContentModel.ASPECT_PERSON_DISABLED))
                {
                    nodeService.removeAspect(person, ContentModel.ASPECT_PERSON_DISABLED);
                }
            }
            else
            {
                nodeService.addAspect(person, ContentModel.ASPECT_PERSON_DISABLED, null);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    public void notifyPerson(final String userName, final String password)
    {
        // Get the details of our user, or fail trying
        NodeRef noderef = getPerson(userName, false);
        Map<QName,Serializable> userProps = nodeService.getProperties(noderef);
        
        // Do they have an email set? We can't email them if not...
        String email = null;
        if (userProps.containsKey(ContentModel.PROP_EMAIL))
        {
            email = (String)userProps.get(ContentModel.PROP_EMAIL);
        }
        
        if (email == null || email.length() == 0)
        {
            if (logger.isInfoEnabled())
            {
                logger.info("Not sending new user notification to " + userName + " as no email address found");
            }
            
            return;
        }
        
        // We need a freemarker model, so turn the QNames into
        //  something a bit more freemarker friendly
        Map<String,Serializable> model = buildEmailTemplateModel(userProps);
        model.put("password", password); // Not stored on the person
        
        // Set the details of the person sending the email into the model
        NodeRef creatorNR = getPerson(AuthenticationUtil.getFullyAuthenticatedUser());
        Map<QName,Serializable> creatorProps = nodeService.getProperties(creatorNR);
        Map<String,Serializable> creator = buildEmailTemplateModel(creatorProps);
        model.put("creator", (Serializable)creator);
        
        // Set share information into the model
        String productName = ModelUtil.getProductName(repoAdminService);
        model.put(TemplateService.KEY_PRODUCT_NAME, productName);
        
        // Set the details for the action
        Map<String,Serializable> actionParams = new HashMap<String, Serializable>();
        actionParams.put(MailActionExecuter.PARAM_TEMPLATE_MODEL, (Serializable)model);
        actionParams.put(MailActionExecuter.PARAM_TO, email);
        actionParams.put(MailActionExecuter.PARAM_FROM, creatorProps.get(ContentModel.PROP_EMAIL));
        actionParams.put(MailActionExecuter.PARAM_SUBJECT, "invitation.notification.person.email.subject");
        actionParams.put(MailActionExecuter.PARAM_SUBJECT_PARAMS, new Object[] {productName});
        
        // Pick the appropriate localised template
        actionParams.put(MailActionExecuter.PARAM_TEMPLATE, getNotifyEmailTemplateNodeRef());
        
        // Ask for the email to be sent asynchronously
        Action mailAction = getActionService().createAction(MailActionExecuter.NAME, actionParams);
        getActionService().executeAction(mailAction, noderef, false, true);
    }
    
    /**
     * Finds the email template and then attempts to find a localized version
     */
    private NodeRef getNotifyEmailTemplateNodeRef()
    {
        // Find the new user email template
        String xpath = "app:company_home/app:dictionary/app:email_templates/cm:invite/cm:new-user-email.html.ftl";
        try
        {
            NodeRef rootNodeRef = nodeService.getRootNode(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE);
            List<NodeRef> nodeRefs = searchService.selectNodes(
                    rootNodeRef,
                    xpath,
                    null,
                    getNamespaceService(),
                    false);
            if (nodeRefs.size() > 1)
            {
                logger.error("Found too many email templates using: " + xpath);
                nodeRefs = Collections.singletonList(nodeRefs.get(0));
            }
            else if (nodeRefs.size() == 0)
            {
                throw new InvitationException("Cannot find the email template using " + xpath);
            }
            // Now localise this
            NodeRef base = nodeRefs.get(0);
            NodeRef local = getFileFolderService().getLocalizedSibling(base);
            return local;
        }
        catch (SearcherException e)
        {
            throw new InvitationException("Cannot find the email template!", e);
        }
    }
    
    private Map<String,Serializable> buildEmailTemplateModel(Map<QName,Serializable> props)
    {
        Map<String,Serializable> model = new HashMap<String, Serializable>((int)(props.size()*1.5));
        for (QName qname : props.keySet())
        {
            model.put(qname.getLocalName(), props.get(qname));
            model.put(qname.getLocalName().toLowerCase(), props.get(qname));
        }
        return model;
    }
    
    /**
     * {@inheritDoc}
     */
    public NodeRef getPeopleContainer()
    {
        NodeRef peopleNodeRef = (NodeRef)singletonCache.get(KEY_PEOPLECONTAINER_NODEREF);
        if (peopleNodeRef == null)
        {
            NodeRef rootNodeRef = nodeService.getRootNode(storeRef);
            List<ChildAssociationRef> children = nodeService.getChildAssocs(rootNodeRef, RegexQNamePattern.MATCH_ALL,
                    QName.createQName(SYSTEM_FOLDER_SHORT_QNAME, namespacePrefixResolver), false);
            
            if (children.size() == 0)
            {
                throw new AlfrescoRuntimeException("Required people system path not found: "
                        + SYSTEM_FOLDER_SHORT_QNAME);
            }
            
            NodeRef systemNodeRef = children.get(0).getChildRef();
            
            children = nodeService.getChildAssocs(systemNodeRef, RegexQNamePattern.MATCH_ALL, QName.createQName(
                    PEOPLE_FOLDER_SHORT_QNAME, namespacePrefixResolver), false);
            
            if (children.size() == 0)
            {
                throw new AlfrescoRuntimeException("Required people system path not found: "
                        + PEOPLE_FOLDER_SHORT_QNAME);
            }
            
            peopleNodeRef = children.get(0).getChildRef();
            singletonCache.put(KEY_PEOPLECONTAINER_NODEREF, peopleNodeRef);
        }
        return peopleNodeRef;
    }
    
    /**
     * {@inheritDoc}
     */
    public void deletePerson(String userName)
    {
        // Normalize the username to avoid case sensitivity issues
        userName = getUserIdentifier(userName);
        if (userName == null)
        {
            return;
        }
        
        NodeRef personRef = getPersonOrNullImpl(userName);
        
        deletePersonAndAuthenticationImpl(userName, personRef);
    }
    
    /**
     * {@inheritDoc}
     */
    public void deletePerson(NodeRef personRef)
    {
        QName typeQName = nodeService.getType(personRef);
        if (typeQName.equals(ContentModel.TYPE_PERSON))
        {
            String userName = (String) this.nodeService.getProperty(personRef, ContentModel.PROP_USERNAME);
            deletePersonAndAuthenticationImpl(userName, personRef);  
        }
        else
        {
            throw new AlfrescoRuntimeException("deletePerson: invalid type of node "+personRef+" (actual="+typeQName+", expected="+ContentModel.TYPE_PERSON+")");
        }
    }

    /**
     * {@inheritDoc} 
     */
    public void deletePerson(NodeRef personRef, boolean deleteAuthentication)
    {
        QName typeQName = nodeService.getType(personRef);
        if (typeQName.equals(ContentModel.TYPE_PERSON))
        {
            if (deleteAuthentication)
            {
                String userName = (String) this.nodeService.getProperty(personRef, ContentModel.PROP_USERNAME);
                deletePersonAndAuthenticationImpl(userName, personRef);
            }
            else
            {
                deletePersonImpl(personRef);
            }
        }
        else
        {
            throw new AlfrescoRuntimeException("deletePerson: invalid type of node "+personRef+" (actual="+typeQName+", expected="+ContentModel.TYPE_PERSON+")");
        }
    }
    
    
    private void deletePersonAndAuthenticationImpl(String userName, NodeRef personRef)
    {
        if (userName != null)
        {
            // Remove internally-stored password information, if any
            try
            {
                authenticationService.deleteAuthentication(userName);
            }
            catch (AuthenticationException e)
            {
                // Ignore this - externally authenticated user
            }
            
            // Invalidate all that user's tickets
            try
            {
                authenticationService.invalidateUserSession(userName);
            }
            catch (AuthenticationException e)
            {
                // Ignore this
            }
            
            // remove any user permissions
            permissionServiceSPI.deletePermissions(userName);
        }
        
        deletePersonImpl(personRef);
    }
    
    private void deletePersonImpl(NodeRef personRef)
    {
        // delete the person
        if (personRef != null)
        {
            try
            {
                beforeDeleteNodeValidationBehaviour.disable();
                
                nodeService.deleteNode(personRef);
            }
            finally
            {
                beforeDeleteNodeValidationBehaviour.enable();
            }
        }
    }
    
    /**
     * {@inheritDoc}
     * 
     * @deprecated see getPeople
     */
    public Set<NodeRef> getAllPeople()
    {
        List<PersonInfo> personInfos = getPeople(null, null, null, new PagingRequest(Integer.MAX_VALUE, null)).getPage();
        Set<NodeRef> refs = new HashSet<NodeRef>(personInfos.size());
        for (PersonInfo personInfo : personInfos)
        {
            refs.add(personInfo.getNodeRef());
        }
        return refs;
    }
    
    /**
     * {@inheritDoc}
     */
    public PagingResults<PersonInfo> getPeople(String pattern, List<QName> filterStringProps, List<Pair<QName, Boolean>> sortProps, PagingRequest pagingRequest)
    {
        return getPeople(pattern, filterStringProps, null, null, true, sortProps, pagingRequest);
    }
    
    /**
     * {@inheritDoc}
     */
    public PagingResults<PersonInfo> getPeople(String pattern, List<QName> filterStringProps, Set<QName> inclusiveAspects, Set<QName> exclusiveAspects, boolean includeAdministraotrs, List<Pair<QName, Boolean>> sortProps, PagingRequest pagingRequest)
    {
        ParameterCheck.mandatory("pagingRequest", pagingRequest);
        
        Long start = (logger.isDebugEnabled() ? System.currentTimeMillis() : null);
        
        CannedQueryResults<NodeRef> cqResults = null;
        
        NodeRef contextNodeRef = getPeopleContainer();
        
        // get canned query
        GetPeopleCannedQueryFactory getPeopleCannedQueryFactory = (GetPeopleCannedQueryFactory)cannedQueryRegistry.getNamedObject(CANNED_QUERY_PEOPLE_LIST);
        
        GetPeopleCannedQuery cq = (GetPeopleCannedQuery)getPeopleCannedQueryFactory.getCannedQuery(contextNodeRef, pattern, filterStringProps, inclusiveAspects, exclusiveAspects, includeAdministraotrs, sortProps, pagingRequest);
        
        // execute canned query
        cqResults = cq.execute();
        
        final CannedQueryResults<NodeRef> results = cqResults;
        
        boolean nonFinalHasMoreItems = results.hasMoreItems();
        List<NodeRef> nodeRefs;
        if (results.getPageCount() > 0)
        {
            nodeRefs = results.getPages().get(0);
            if (nodeRefs.size() > pagingRequest.getMaxItems())
            {
                // eg. since hasMoreItems added one (for a pre-paged result)
                nodeRefs = nodeRefs.subList(0, pagingRequest.getMaxItems());
                nonFinalHasMoreItems = true;
            }
        }
        else
        {
            nodeRefs = Collections.emptyList();
        }
        
        final boolean hasMoreItems = nonFinalHasMoreItems;
        
        // set total count
        final Pair<Integer, Integer> totalCount;
        if (pagingRequest.getRequestTotalCountMax() > 0)
        {
            totalCount = results.getTotalResultCount();
        }
        else
        {
            totalCount = null;
        }
        
        if (start != null)
        {
            int cnt = nodeRefs.size();
            int skipCount = pagingRequest.getSkipCount();
            int maxItems = pagingRequest.getMaxItems();
            int pageNum = (skipCount / maxItems) + 1;
            
            if (logger.isDebugEnabled())
            {
                logger.debug(
                        "getPeople: "+cnt+" items in "+(System.currentTimeMillis()-start)+" msecs " +
                                "[pageNum="+pageNum+",skip="+skipCount+",max="+maxItems+",hasMorePages="+hasMoreItems+
                                ",totalCount="+totalCount+",pattern="+pattern+",filterStringProps="+filterStringProps+
                                ",sortProps="+sortProps+"]");
            }
        }
        
        final List<PersonInfo> personInfos = new ArrayList<PersonInfo>(nodeRefs.size());
        for (NodeRef nodeRef : nodeRefs)
        {
            if (nodeService.exists(nodeRef))
            {
                Map<QName, Serializable> props = nodeService.getProperties(nodeRef);
                personInfos.add(new PersonInfo(nodeRef, 
                                               (String)props.get(ContentModel.PROP_USERNAME), 
                                               (String)props.get(ContentModel.PROP_FIRSTNAME),
                                               (String)props.get(ContentModel.PROP_LASTNAME)));
            }
        }
        
        return new PagingResults<PersonInfo>()
        {
            @Override
            public String getQueryExecutionId()
            {
                return results.getQueryExecutionId();
            }
            @Override
            public List<PersonInfo> getPage()
            {
                return personInfos;
            }
            @Override
            public boolean hasMoreItems()
            {
                return hasMoreItems;
            }
            @Override
            public Pair<Integer, Integer> getTotalResultCount()
            {
                return totalCount;
            }
        };
    }
    
    /**
     * {@inheritDoc}
     * 
     * @deprecated see getPeople(String pattern, List<QName> filterProps, List<Pair<QName, Boolean>> sortProps, PagingRequest pagingRequest)
     */
    public PagingResults<PersonInfo> getPeople(List<Pair<QName, String>> stringPropFilters, boolean filterIgnoreCase, List<Pair<QName, Boolean>> sortProps, PagingRequest pagingRequest)
    {
        ParameterCheck.mandatory("pagingRequest", pagingRequest);
        
        if (stringPropFilters == null)
        {
            return getPeople(null, null, sortProps, pagingRequest);
        }
        
        String firstName = "";
        String lastName = "";
        String userName = "";
        for (Pair<QName, String> item : stringPropFilters)
        {
            if (ContentModel.PROP_FIRSTNAME.equals(item.getFirst()))
            {
                firstName = item.getSecond().trim();
            }
            if (ContentModel.PROP_LASTNAME.equals(item.getFirst()))
            {
                lastName = item.getSecond().trim();
            }
            if (ContentModel.PROP_USERNAME.equals(item.getFirst()))
            {
                userName = item.getSecond().trim();
            }
        }
        String searchStr = "";
        boolean useCQ = false;
        if (userName.length() == 0)
        {
            if (firstName.equalsIgnoreCase(lastName))
            {
                searchStr = firstName;
                useCQ = true;
            }
            else
            {
                searchStr = firstName + " " + lastName;
            }
        }
        else
        {
            searchStr = userName;
            useCQ = searchStr.split(" ").length == 1;
        }
        
        PagingResults<PersonInfo> result = null;
        if (useCQ)
        {
            List<QName> filterProps = new ArrayList<QName>(3);
            filterProps.add(ContentModel.PROP_FIRSTNAME);
            filterProps.add(ContentModel.PROP_LASTNAME);
            filterProps.add(ContentModel.PROP_USERNAME);
            sortProps = sortProps == null ? new ArrayList<Pair<QName, Boolean>>(1) : new ArrayList<Pair<QName, Boolean>>(sortProps);
            sortProps.add(new Pair<QName, Boolean>(ContentModel.PROP_USERNAME, true)); 
            result = getPeople(searchStr, filterProps, sortProps, pagingRequest);
            
            // Fall back to FTS if no results. For case:  First Name: Gerard, Last Name: Perez Winkler
            if (result.getPage().size() == 0)
            {
                result = null;
            }
        }
        
        if (result == null)
        {
            result = getPeopleFts(searchStr, pagingRequest);
        }
        
        return result;
    }
    
    /**
     * Get paged list of people optionally filtered and/or sorted using FTS
     * 
     * @param pattern - String to search
     * @param pagingRequest PagingRequest
     * @return PagingResults<PersonInfo>
     */
    private PagingResults<PersonInfo> getPeopleFts(String pattern, PagingRequest pagingRequest)
    {
        Long start = (logger.isDebugEnabled() ? System.currentTimeMillis() : null);
        List<NodeRef> people = null;
        try
        {
            people = getPeopleFtsList(pattern, pagingRequest);
        }
        catch (Throwable e1)
        {
            // search is failed
        }

        List<NodeRef> nodeRefs;
        boolean nonFinalHasMoreItems = false;
        if (people != null && people.size() > 0)
        {
            nodeRefs = people;
            if (nodeRefs.size() > pagingRequest.getMaxItems())
            {
                // eg. since hasMoreItems added one (for a pre-paged result)
                nodeRefs = nodeRefs.subList(0, pagingRequest.getMaxItems());
                nonFinalHasMoreItems = true;
            }
        }
        else
        {
            nodeRefs = Collections.emptyList();
        }
        if (people == null || people.size() == 0)
        {
            nodeRefs = Collections.emptyList();
        }
        
        final boolean hasMoreItems = nonFinalHasMoreItems;

        // set total count
        final Pair<Integer, Integer> totalCount;
        if (pagingRequest.getRequestTotalCountMax() > 0)
        {
            int size = people != null ? people.size() : 0;
            totalCount = new Pair<Integer, Integer>(size, size);
        }
        else
        {
            totalCount = null;
        }
        
        if (start != null)
        {
            int cnt = nodeRefs.size();
            int skipCount = pagingRequest.getSkipCount();
            int maxItems = pagingRequest.getMaxItems();
            int pageNum = (skipCount / maxItems) + 1;
            
            if (logger.isDebugEnabled())
            {
                logger.debug(
                        "getPeople: " + cnt + " items in " + (System.currentTimeMillis() - start) + " msecs " +
                        "[pageNum=" + pageNum + ",skip=" + skipCount + ",max="+ maxItems + ",hasMorePages=" + hasMoreItems +
                        ",totalCount=" + totalCount + ",pattern=" + pattern + "]");
            }
        }
        
        final List<PersonInfo> personInfos = new ArrayList<PersonInfo>(nodeRefs.size());
        for (NodeRef nodeRef : nodeRefs)
        {
            try
            {
                Map<QName, Serializable> props = nodeService.getProperties(nodeRef);
                personInfos.add(new PersonInfo(nodeRef,
                                (String)props.get(ContentModel.PROP_USERNAME),
                                (String) props.get(ContentModel.PROP_FIRSTNAME),
                                (String) props.get(ContentModel.PROP_LASTNAME)));
            }
            catch (InvalidNodeRefException e)
            {
                logger.warn("Stale search result", e);
            }
        }

        return new PagingResults<PersonInfo>()
        {
            @Override
            public String getQueryExecutionId()
            {
                // it's FTS search
                return null;
            }

            @Override
            public List<PersonInfo> getPage()
            {
                return personInfos;
            }

            @Override
            public boolean hasMoreItems()
            {
                return hasMoreItems;
            }

            @Override
            public Pair<Integer, Integer> getTotalResultCount()
            {
                return totalCount;
            }
        };
    }
    
    private List<NodeRef> getPeopleFtsList(String pattern, PagingRequest pagingRequest) throws Throwable
    {
        // Think this code is based on org.alfresco.repo.jscript.People.getPeopleImplSearch(String, StringTokenizer, int, int)
        List<NodeRef> people = null;

        SearchParameters params = new SearchParameters();
        params.addQueryTemplate("_PERSON", "|%firstName OR |%lastName OR |%userName");
        params.setDefaultFieldName("_PERSON");

        StringBuilder query = new StringBuilder(256);

        query.append("TYPE:\"").append(ContentModel.TYPE_PERSON).append("\" AND (");

        StringTokenizer t = new StringTokenizer(pattern, " ");

        if (t.countTokens() == 1)
        {
            // fts-alfresco property search i.e. location:"maidenhead"
            query.append('"').append(pattern).append("*\"");
        }
        else
        {
            // multiple terms supplied - look for first and second name etc.
            // assume first term is first name, any more are second i.e.
            // "Fraun van de Wiels"
            // also allow fts-alfresco property search to reduce results
            params.setDefaultOperator(SearchParameters.Operator.AND);
            StringBuilder multiPartNames = new StringBuilder(pattern.length());
            int numOfTokens = t.countTokens();
            int counter = 1;
            String term = null;
            // MNT-8539, in order to support firstname and lastname search
            while (t.hasMoreTokens())
            {
                term = t.nextToken();
                // ALF-11311, in order to support multi-part
                // firstNames/lastNames, we need to use the whole tokenized term for both
                // firstName and lastName
                if (term.endsWith("*"))
                {
                    term = term.substring(0, term.lastIndexOf("*"));
                }
                multiPartNames.append("\"");
                multiPartNames.append(term);
                multiPartNames.append("*\"");
                if (numOfTokens > counter)
                {
                    multiPartNames.append(' ');
                }
                counter++;
            }
            // ALF-11311, in order to support multi-part firstNames/lastNames,
            // we need to use the whole tokenized term for both firstName and lastName.
            // e.g. "john junior lewis martinez", where "john junior" is the first
            // name and "lewis martinez" is the last name.
            if (multiPartNames.length() > 0)
            {
                query.append("firstName:");
                query.append(multiPartNames);
                query.append(" OR lastName:");
                query.append(multiPartNames);
            }
        }
        query.append(")");

        // define the search parameters
        params.setLanguage(SearchService.LANGUAGE_FTS_ALFRESCO);
        params.addStore(this.storeRef);
        params.setQuery(query.toString());
        if (pagingRequest.getMaxItems() > 0)
        {
            params.setLimitBy(LimitBy.FINAL_SIZE);
            params.setLimit(pagingRequest.getMaxItems());
        }

        ResultSet results = null;
        try
        {
            results = searchService.query(params);
            people = results.getNodeRefs();
        }
        catch (Throwable err)
        {
            if (logger.isDebugEnabled())
            {
                logger.debug("Failed to execute people search: " + query.toString(), err);
            }

            throw err;
        }
        finally
        {
            if (results != null)
            {
                results.close();
            }
        }

        return people;
    }
    
    /**
     * {@inheritDoc}
     */
    @Override
    public Set<NodeRef> getPeopleFilteredByProperty(QName propertyKey, Serializable propertyValue, int count)
    {
        if (count > 1000)
        {
            throw new IllegalArgumentException("Only 1000 results are allowed but got a request for " + count + ". Use getPeople.");
        }
        
        // check that given property key is defined for content model type 'cm:person'
        // and throw exception if it isn't
        if (this.dictionaryService.getProperty(ContentModel.TYPE_PERSON, propertyKey) == null)
        {
            throw new AlfrescoRuntimeException("Property '" + propertyKey + "' is not defined " + "for content model type cm:person");
        }
        if (!propertyKey.equals(ContentModel.PROP_FIRSTNAME) &&
            !propertyKey.equals(ContentModel.PROP_LASTNAME) &&
            !propertyKey.equals(ContentModel.PROP_USERNAME))
        {
            logger.warn("PersonService.getPeopleFilteredByProperty() is being called to find people by "+propertyKey+
                    ". Only PROP_FIRSTNAME, PROP_LASTNAME, PROP_USERNAME are now used in the search, so fewer nodes may " +
                    "be returned than expected of there are more than "+count+" users in total.");
        }
        
        List<Pair<QName, String>> filterProps = new ArrayList<Pair<QName, String>>(1);
        filterProps.add(new Pair<QName, String>(propertyKey, (String)propertyValue));
        
        PagingRequest pagingRequest = new PagingRequest(count, null);
        List<PersonInfo> personInfos = getPeople(filterProps, true, null, pagingRequest).getPage();
        
        Set<NodeRef> refs = new HashSet<NodeRef>(personInfos.size());
        for (PersonInfo personInfo : personInfos)
        {
            NodeRef nodeRef = personInfo.getNodeRef();
            String value = (String) this.nodeService.getProperty(nodeRef, propertyKey);
            if (EqualsHelper.nullSafeEquals(value, propertyValue))
            {
                refs.add(nodeRef);
            }
        }
        
        return refs;
    }

    // Policies
    
    /**
     * {@inheritDoc}
     */
    public void onCreateNode(ChildAssociationRef childAssocRef)
    {
        NodeRef personRef = childAssocRef.getChildRef();
        
        String userName = (String) this.nodeService.getProperty(personRef, ContentModel.PROP_USERNAME);

        if (getPeopleContainer().equals(childAssocRef.getParentRef()))
        {
            if (userName != null)
            {
                for (char illegalCharacter : ILLEGAL_CHARACTERS)
                {
                    if (userName.indexOf(illegalCharacter) != -1)
                    {
                        throw new IllegalArgumentException("Person name contains characters that are not permitted: "+userName.charAt(userName.indexOf(illegalCharacter)));
                    }
                }
            }

            // The value is stale.  However, we have already made the data change and
            // therefore do not need to lock the removal from further changes.
            removeFromCache(userName, false);
        }
        
        permissionsManager.setPermissions(personRef, userName, userName);
        
        // Make sure there is an authority entry - with a DB constraint for uniqueness
        // aclDao.createAuthority(username);
        
        if ((homeFolderCreationEager) && (homeFolderCreationDisabled == false))
        {
            makeHomeFolderAsSystem(childAssocRef);
        }

    }
    
    private QName getChildNameLower(String userName)
    {
        return QName.createQName(NamespaceService.CONTENT_MODEL_PREFIX, userName.toLowerCase(), namespacePrefixResolver);
    }
    
    public void beforeCreateNode(
            NodeRef parentRef,
            QName assocTypeQName,
            QName assocQName,
            QName nodeTypeQName)
    {
        // NOOP
    }
    
    public void beforeCreateNodeValidation(
            NodeRef parentRef,
            QName assocTypeQName,
            QName assocQName,
            QName nodeTypeQName)
    {
        if (getPeopleContainer().equals(parentRef))
        {
            throw new AlfrescoRuntimeException("beforeCreateNode: use PersonService to create person");
        }
        else
        {
            logger.info("Person node is not being created under the people container (actual="+parentRef+", expected="+getPeopleContainer()+")");
        }
    }
    
    public void beforeDeleteNode(NodeRef nodeRef)
    {
        String userName = (String) this.nodeService.getProperty(nodeRef, ContentModel.PROP_USERNAME);
        if (this.authorityService.isGuestAuthority(userName) && !this.tenantService.isTenantUser(userName))
        {
            throw new AlfrescoRuntimeException("The " + userName + " user cannot be deleted.");
        }
        
        NodeRef parentRef = null;
        ChildAssociationRef parentAssocRef = nodeService.getPrimaryParent(nodeRef);
        if (parentAssocRef != null)
        {
            parentRef = parentAssocRef.getParentRef();
            if (getPeopleContainer().equals(parentRef))
            {
                // Remove the cache entry.
                // Note that the associated node has not been deleted and is therefore still
                // visible to any other code that attempts to see it.  We therefore need to
                // prevent the value from being added back before the node is actually
                // deleted.
                removeFromCache(userName, true);
            }
        }
    }
    
    public void beforeDeleteNodeValidation(NodeRef nodeRef)
    {
        NodeRef parentRef = null;
        ChildAssociationRef parentAssocRef = nodeService.getPrimaryParent(nodeRef);
        if (parentAssocRef != null)
        {
            parentRef = parentAssocRef.getParentRef();
        }
        
        if (getPeopleContainer().equals(parentRef))
        {
            throw new AlfrescoRuntimeException("beforeDeleteNode: use PersonService to delete person");
        }
        else
        {
            logger.info("Person node that is being deleted is not under the parent people container (actual="+parentRef+", expected="+getPeopleContainer()+")");
        }
    }
    
    private Set<NodeRef> getFromCache(String userName)
    {
        return this.personCache.get(userName.toLowerCase());
    }

    /**
     * Put a value into the {@link #setPersonCache(SimpleCache) personCache}, optionally
     * locking the value against any changes.
     */
    private void putToCache(String userName, Set<NodeRef> refs, boolean lock)
    {
        String key = userName.toLowerCase();
        this.personCache.put(key, refs);
        if (lock && personCache instanceof TransactionalCache)
        {
            TransactionalCache<String, Set<NodeRef>> personCacheTxn = (TransactionalCache<String, Set<NodeRef>>) personCache;
            personCacheTxn.lockValue(key);
        }
    }
    
    /**
     * Remove a value from the {@link #setPersonCache(SimpleCache) personCache}, optionally
     * locking the value against any changes.
     */
    private void removeFromCache(String userName, boolean lock)
    {
        String key = userName.toLowerCase();
        personCache.remove(key);
        if (lock && personCache instanceof TransactionalCache)
        {
            TransactionalCache<String, Set<NodeRef>> personCacheTxn = (TransactionalCache<String, Set<NodeRef>>) personCache;
            personCacheTxn.lockValue(key);
        }
    }

    /**
     * {@inheritDoc}
     */
    public String getUserIdentifier(String caseSensitiveUserName)
    {
        NodeRef nodeRef = getPersonOrNullImpl(caseSensitiveUserName);
        if ((nodeRef != null) && nodeService.exists(nodeRef))
        {
            String realUserName = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(nodeRef, ContentModel.PROP_USERNAME));
            return realUserName;
        }
        return null;
    }

    public static class NodeIdComparator implements Comparator<NodeRef>
    {
        private NodeService nodeService;

        boolean ascending;

        NodeIdComparator(NodeService nodeService, boolean ascending)
        {
            this.nodeService = nodeService;
            this.ascending = ascending;
        }

        public int compare(NodeRef first, NodeRef second)
        {
            Long firstId = DefaultTypeConverter.INSTANCE.convert(Long.class, nodeService.getProperty(first, ContentModel.PROP_NODE_DBID));
            Long secondId = DefaultTypeConverter.INSTANCE.convert(Long.class, nodeService.getProperty(second, ContentModel.PROP_NODE_DBID));

            if (firstId != null)
            {
                if (secondId != null)
                {
                    return firstId.compareTo(secondId) * (ascending ? 1 : -1);
                }
                else
                {
                    return ascending ? -1 : 1;
                }
            }
            else
            {
                if (secondId != null)
                {
                    return ascending ? 1 : -1;
                }
                else
                {
                    return 0;
                }
            }
        }
    }
    
    /**
     * {@inheritDoc}
     */
    public boolean getUserNamesAreCaseSensitive()
    {
        return userNameMatcher.getUserNamesAreCaseSensitive();
    }

    /**
     * When a uid is changed we need to create an alias for the old uid so permissions are not broken. This can happen
     * when an already existing user is updated via LDAP e.g. migration to LDAP, or when a user is auto created and then
     * updated by LDAP This is probably less likely after 3.2 and sync on missing person See
     * https://issues.alfresco.com/jira/browse/ETWOTWO-389 (non-Javadoc)
     */
    public void onUpdateProperties(NodeRef nodeRef, Map<QName, Serializable> before, Map<QName, Serializable> after)
    {
        String uidBefore = DefaultTypeConverter.INSTANCE.convert(String.class, before.get(ContentModel.PROP_USERNAME));
        if (uidBefore == null)
        {
            // Node has just been created; nothing to do
            return;
        }
        String uidAfter = DefaultTypeConverter.INSTANCE.convert(String.class, after.get(ContentModel.PROP_USERNAME));
        if (!EqualsHelper.nullSafeEquals(uidBefore, uidAfter))
        {
            // Only allow UID update if we are in the special split processing txn or we are just changing case
            if (AlfrescoTransactionSupport.getResource(KEY_ALLOW_UID_UPDATE) != null || uidBefore.equalsIgnoreCase(uidAfter))
            {
                if (uidBefore != null)
                {
                    // Fix any ACLs
                    aclDao.renameAuthority(uidBefore, uidAfter);
                }
                
                // Fix primary association local name
                QName newAssocQName = getChildNameLower(uidAfter);
                ChildAssociationRef assoc = nodeService.getPrimaryParent(nodeRef);
                nodeService.moveNode(nodeRef, assoc.getParentRef(), assoc.getTypeQName(), newAssocQName);
                
                // Fix other non-case sensitive parent associations
                QName oldAssocQName = QName.createQName(NamespaceService.CONTENT_MODEL_PREFIX, uidBefore, namespacePrefixResolver);
                newAssocQName = QName.createQName(NamespaceService.CONTENT_MODEL_PREFIX, uidAfter, namespacePrefixResolver);
                for (ChildAssociationRef parent : nodeService.getParentAssocs(nodeRef))
                {
                    if (!parent.isPrimary() && parent.getQName().equals(oldAssocQName))
                    {
                        nodeService.removeChildAssociation(parent);
                        nodeService.addChild(parent.getParentRef(), parent.getChildRef(), parent.getTypeQName(), newAssocQName);
                    }
                }
                
                // Fix cache
                // We are going to be pessimistic here.  Even though the properties have changed and
                // should always be seen correctly by other policy listeners, we are not entirely sure
                // that there won't be some sort of corruption i.e. the behaviour was pessimistic before
                // this change so I'm leaving it that way.
                removeFromCache(uidBefore, true);
            }
            else
            {
                throw new UnsupportedOperationException("The user name on a person can not be changed");
            }
        }
        
        
        if(validUserUpdateEvent(before, after))
        {
        	publishEvent("user.update", after);
        }
      
    }
    
    /**
     * Determine if the updated properties constitute a valid user update.
     * Currently we only check for updates to the user firstname, lastname
     * 
     * @param before Map<QName, Serializable>
     * @param after Map<QName, Serializable>
     * @return boolean
     */
    private boolean validUserUpdateEvent(Map<QName, Serializable> before, Map<QName, Serializable> after)
    {
    	final String firstnameBefore = (String)before.get(ContentModel.PROP_FIRSTNAME);
        final String lastnameBefore  = (String)before.get(ContentModel.PROP_LASTNAME);
        final String firstnameAfter  = (String)after.get(ContentModel.PROP_FIRSTNAME);
        final String lastnameAfter   = (String)after.get(ContentModel.PROP_LASTNAME);
        
        boolean updatedFirstName = !EqualsHelper.nullSafeEquals(firstnameBefore, firstnameAfter);
        boolean updatedLastName  = !EqualsHelper.nullSafeEquals(lastnameBefore, lastnameAfter);
        
        return updatedFirstName || updatedLastName;
    }
    
    /**
     * Publish new user event
     * 
     * @param eventType String
     * @param properties Map<QName, Serializable>
     */
    private void publishEvent(String eventType,  Map<QName, Serializable> properties)
    {
    	if(properties == null)	return;
    	
   	 	final String managedUsername  = (String)properties.get(ContentModel.PROP_USERNAME);
        final String managedFirstname = (String)properties.get(ContentModel.PROP_FIRSTNAME);
        final String managedLastname  = (String)properties.get(ContentModel.PROP_LASTNAME);
        final String eventTType 	  = eventType;
        
        eventPublisher.publishEvent(new EventPreparator(){
            @Override
            public Event prepareEvent(String user, String networkId, String transactionId)
            {         
            	return new UserManagementEvent(eventTType , transactionId, networkId,new Date().getTime(), 
            			user, managedUsername,managedFirstname,managedLastname);
            }
        });
    }
    
    /**
     * Track the {@link ContentModel#PROP_ENABLED enabled/disabled} flag on {@link ContentModel#TYPE_USER <b>cm:user</b>}.
     */
    public void onUpdatePropertiesUser(NodeRef nodeRef, Map<QName, Serializable> before, Map<QName, Serializable> after)
    {
        String userName = (String) after.get(ContentModel.PROP_USER_USERNAME);
        if (userName == null)
        {
            // Won't find user
            return;
        }
        // Get the person
        NodeRef personNodeRef = getPersonOrNullImpl(userName);
        if (personNodeRef == null)
        {
            // Don't attempt to maintain enabled/disabled flag
            return;
        }
        
        // Check the enabled/disabled flag
        Boolean enabled = (Boolean) after.get(ContentModel.PROP_ENABLED);
        if (enabled == null || enabled.booleanValue())
        {
            nodeService.removeAspect(personNodeRef, ContentModel.ASPECT_PERSON_DISABLED);
        }
        else
        {
            nodeService.addAspect(personNodeRef, ContentModel.ASPECT_PERSON_DISABLED, null);
        }
        
        // Do post-commit user counting, if required
        Set<String> usersCreated = TransactionalResourceHelper.getSet(KEY_USERS_CREATED);
        usersCreated.add(userName);
        AlfrescoTransactionSupport.bindListener(this);
    }
    
    public int countPeople()
    {
        NodeRef peopleContainer = getPeopleContainer();
        return nodeService.countChildAssocs(peopleContainer, true);
    }
    
    /**
     * Helper for when creating new users and people:
     * Updates the supplied username with any required tenant
     *  details, and ensures that the tenant domains match.
     * If Multi-Tenant is disabled, returns the same username.
     */
    public static String updateUsernameForTenancy(String username, TenantService tenantService) 
            throws TenantDomainMismatchException
    {
        if(! tenantService.isEnabled())
        {
            // Nothing to do if not using multi tenant
            return username;
        }
        
        String currentDomain = tenantService.getCurrentUserDomain();
        if (! currentDomain.equals(TenantService.DEFAULT_DOMAIN))
        {
            if (! tenantService.isTenantUser(username))
            {
                // force domain onto the end of the username
                username = tenantService.getDomainUser(username, currentDomain);
                logger.warn("Added domain to username: " + username);
            }
            else
            {
                // Check the user's domain matches the current domain
                // Throws a TenantDomainMismatchException if they don't match
                tenantService.checkDomainUser(username);
            }
        }
        return username;
    }
    
    @Override
    public boolean isEnabled(String userName)
    {
        NodeRef noderef = getPerson(userName, false);
        
        for (QName aspectName : nodeService.getAspects(noderef))
        {
            if (ContentModel.ASPECT_PERSON_DISABLED.isMatch(aspectName))
            {
                return false;
            }
        }

        return true;
    }

    public void setEventPublisher(EventPublisher eventPublisher)
    {
        this.eventPublisher = eventPublisher;
    }

    private boolean isSystemUserName(String userName)
    {
        return EqualsHelper.nullSafeEquals(userName, AuthenticationUtil.getSystemUserName(), true);
    }

}
