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

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.query.CannedQuery;
import org.alfresco.query.CannedQueryFactory;
import org.alfresco.query.CannedQueryParameters;
import org.alfresco.query.PagingRequest;
import org.alfresco.query.PagingResults;
import org.alfresco.repo.action.ActionServiceImpl;
import org.alfresco.repo.copy.CopyBehaviourCallback.AssocCopySourceAction;
import org.alfresco.repo.copy.CopyBehaviourCallback.AssocCopyTargetAction;
import org.alfresco.repo.copy.CopyBehaviourCallback.ChildAssocCopyAction;
import org.alfresco.repo.copy.CopyBehaviourCallback.ChildAssocRecurseAction;
import org.alfresco.repo.copy.CopyBehaviourCallback.CopyAssociationDetails;
import org.alfresco.repo.copy.CopyBehaviourCallback.CopyChildAssociationDetails;
import org.alfresco.repo.copy.query.AbstractCopyCannedQueryFactory;
import org.alfresco.repo.node.NodeServicePolicies;
import org.alfresco.repo.policy.BehaviourFilter;
import org.alfresco.repo.policy.ClassPolicyDelegate;
import org.alfresco.repo.policy.JavaBehaviour;
import org.alfresco.repo.policy.PolicyComponent;
import org.alfresco.repo.transaction.TransactionalResourceHelper;
import org.alfresco.service.cmr.dictionary.AspectDefinition;
import org.alfresco.service.cmr.dictionary.AssociationDefinition;
import org.alfresco.service.cmr.dictionary.ChildAssociationDefinition;
import org.alfresco.service.cmr.dictionary.ClassDefinition;
import org.alfresco.service.cmr.dictionary.DictionaryService;
import org.alfresco.service.cmr.dictionary.PropertyDefinition;
import org.alfresco.service.cmr.dictionary.TypeDefinition;
import org.alfresco.service.cmr.repository.AssociationRef;
import org.alfresco.service.cmr.repository.ChildAssociationRef;
import org.alfresco.service.cmr.repository.CopyService;
import org.alfresco.service.cmr.repository.CopyServiceException;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.service.cmr.rule.RuleService;
import org.alfresco.service.cmr.security.AccessPermission;
import org.alfresco.service.cmr.security.AccessStatus;
import org.alfresco.service.cmr.security.PermissionService;
import org.alfresco.service.cmr.security.PublicServiceAccessService;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.service.namespace.RegexQNamePattern;
import org.alfresco.util.GUID;
import org.alfresco.util.Pair;
import org.alfresco.util.registry.NamedObjectRegistry;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.extensions.surf.util.I18NUtil;
import org.springframework.extensions.surf.util.ParameterCheck;

/**
 * Service implementation of copy operations.
 * 
 * @author Roy Wetherall
 * @author Derek Hulley
 */
public class CopyServiceImpl extends AbstractBaseCopyService implements CopyService
{
    private static Log logger = LogFactory.getLog(ActionServiceImpl.class);
    
    /* Query names */
    private static final String QUERY_FACTORY_GET_COPIES = "getCopiesCannedQueryFactory";
    
    /* I18N labels */
    private static final String COPY_OF_LABEL = "copy_service.copy_of_label";
    
    /* Services */
    private NodeService nodeService;
    private NodeService internalNodeService;
    private NamedObjectRegistry<CannedQueryFactory<CopyInfo>> cannedQueryRegistry;
    private DictionaryService dictionaryService;     
    private PolicyComponent policyComponent;
    private BehaviourFilter behaviourFilter;
    private RuleService ruleService;
    private PermissionService permissionService;
    private PublicServiceAccessService publicServiceAccessService;

    /* Policy delegates */
    private ClassPolicyDelegate<CopyServicePolicies.OnCopyNodePolicy> onCopyNodeDelegate;
    private ClassPolicyDelegate<CopyServicePolicies.OnCopyCompletePolicy> onCopyCompleteDelegate;
    private ClassPolicyDelegate<CopyServicePolicies.BeforeCopyPolicy> beforeCopyDelegate;

    public CopyServiceImpl()
    {
        super();
    }

    /**
     * @param nodeService  the node service
     */
    public void setNodeService(NodeService nodeService)
    {
        this.nodeService = nodeService;
    }
    
    /**
     * @param internalNodeService    the internal node service
     */
    public void setInternalNodeService(NodeService internalNodeService) 
    {
        this.internalNodeService = internalNodeService;
    }
    
    public void setCannedQueryRegistry(NamedObjectRegistry<CannedQueryFactory<CopyInfo>> cannedQueryRegistry)
    {
        this.cannedQueryRegistry = cannedQueryRegistry;
    }

    /**
     * @param dictionaryService  the dictionary service
     */
    public void setDictionaryService(DictionaryService dictionaryService) 
    {
        this.dictionaryService = dictionaryService;
    }
    
    /**
     * @param policyComponent  the policy component
     */
    public void setPolicyComponent(PolicyComponent policyComponent) 
    {
        this.policyComponent = policyComponent;
    }

    /**
     * @param behaviourFilter   used to disable specific behaviours while doing background tasks
     */
    public void setBehaviourFilter(BehaviourFilter behaviourFilter)
    {
        this.behaviourFilter = behaviourFilter;
    }

    /**
     * @param ruleService  the rule service
     */
    public void setRuleService(RuleService ruleService)
    {
        this.ruleService = ruleService;
    }
    
    /**
     * @param permissionService the permissionService to set
     */
    public void setPermissionService(PermissionService permissionService)
    {
        this.permissionService = permissionService;
    }

    /**
     * @param publicServiceAccessService the publicServiceAccessService to set
     */
    public void setPublicServiceAccessService(PublicServiceAccessService publicServiceAccessService)
    {
        this.publicServiceAccessService = publicServiceAccessService;
    }

    /**
     * Initialise method
     */
    public void init()
    {
        // Register the policies
        onCopyNodeDelegate = policyComponent.registerClassPolicy(CopyServicePolicies.OnCopyNodePolicy.class);
        onCopyCompleteDelegate = policyComponent.registerClassPolicy(CopyServicePolicies.OnCopyCompletePolicy.class);
        beforeCopyDelegate = policyComponent.registerClassPolicy(CopyServicePolicies.BeforeCopyPolicy.class);
        
        // Register policy behaviours
        this.policyComponent.bindAssociationBehaviour(
                NodeServicePolicies.BeforeDeleteAssociationPolicy.QNAME,
                ContentModel.ASPECT_COPIEDFROM,
                ContentModel.ASSOC_ORIGINAL,
                new JavaBehaviour(this, "beforeDeleteOriginalAssociation"));    
        this.policyComponent.bindClassBehaviour(
                CopyServicePolicies.OnCopyNodePolicy.QNAME,
                ContentModel.ASPECT_COPIEDFROM,
                new JavaBehaviour(this, "getCallbackForCopiedFromAspect"));    
        this.policyComponent.bindClassBehaviour(
                CopyServicePolicies.OnCopyNodePolicy.QNAME,
                ContentModel.TYPE_FOLDER,
                new JavaBehaviour(this, "getCallbackForFolderType"));    
        this.policyComponent.bindClassBehaviour(
                CopyServicePolicies.OnCopyNodePolicy.QNAME,
                ContentModel.ASPECT_OWNABLE,
                new JavaBehaviour(this, "getCallbackForOwnableAspect"));
    }
    
    public NodeRef copy(
            NodeRef sourceNodeRef,
            NodeRef targetParentRef, 
            QName assocTypeQName,
            QName assocQName, 
            boolean copyChildren)
    {
        // Check that all the passed values are not null
        ParameterCheck.mandatory("sourceNodeRef", sourceNodeRef);
        ParameterCheck.mandatory("targetParentRef", targetParentRef);
        ParameterCheck.mandatory("assocTypeQName", assocTypeQName);
        ParameterCheck.mandatory("assocQName", assocQName);

        if (sourceNodeRef.getStoreRef().equals(targetParentRef.getStoreRef()) == false)
        {
            // Error - since at the moment we do not support cross store copying
            throw new UnsupportedOperationException("Copying nodes across stores is not currently supported.");
        }

        // ALF-17549: Transform Action causes the Path QName to change to "'cm:copy''
        QName sourceNodeTypeQName = nodeService.getType(sourceNodeRef);

        if (dictionaryService.isSubClass(sourceNodeTypeQName, ContentModel.TYPE_CONTENT) || dictionaryService.isSubClass(sourceNodeTypeQName, ContentModel.TYPE_FOLDER))
        {
            String newName = assocQName.getLocalName();
            String currentName = DefaultTypeConverter.INSTANCE.convert(String.class, nodeService.getProperty(sourceNodeRef, ContentModel.PROP_NAME));
            assocQName = getAssociationCopyInfo(nodeService, sourceNodeRef, null, newName, !currentName.equals(newName)).getTargetAssocQName();
        }

        // Clear out any record of copied associations
        TransactionalResourceHelper.getList(KEY_POST_COPY_ASSOCS).clear();
        
        // Keep track of copied children in order of copying
        Map<NodeRef, NodeRef> copiesByOriginals = new LinkedHashMap<NodeRef, NodeRef>(17);
        Set<NodeRef> copies = new HashSet<NodeRef>(17);

        NodeRef copiedNodeRef = copyImpl(
                sourceNodeRef, targetParentRef,
                assocTypeQName, assocQName,
                copyChildren, true,                     // Drop cm:name for top-level node
                copiesByOriginals, copies);
        // Check if the node was copied
        if (copiedNodeRef == null)
        {
            CopyDetails copyDetails = getCopyDetails(sourceNodeRef, targetParentRef, null, assocTypeQName, assocQName);
            // Denied!
            throw new CopyServiceException(
                    "A bound policy denied copy: \n" +
                    "   " + copyDetails);
        }
        
        // Copy an associations that were left until now
        copyPendingAssociations(copiesByOriginals);
        
        // Foreach of the newly created copies call the copy complete policy
        for (Map.Entry<NodeRef, NodeRef> entry : copiesByOriginals.entrySet())
        {
            NodeRef mappedSourceNodeRef = entry.getKey();
            NodeRef mappedTargetNodeRef = entry.getValue();
            invokeCopyComplete(mappedSourceNodeRef, mappedTargetNodeRef, true, copiesByOriginals);
        }
        
        // Done
        return copiedNodeRef;
    }
    
    public NodeRef copyAndRename(
            NodeRef sourceNodeRef,
            NodeRef destinationParent,
            QName assocTypeQName,
            QName assocQName, 
            boolean copyChildren) 
    {
        // To fix ETWOONE-224 issue it is necessary to change a QName of the new node accordingly to its name.
        NodeRef result = null;
        String sourceName = (String)this.internalNodeService.getProperty(sourceNodeRef, ContentModel.PROP_NAME);
                
        // Find a non-duplicate name
        String newName = sourceName;

        // rename top-level node if it should be renamed in process of copy
        QName qname = QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, QName.createValidLocalName(newName));
        String newNameAfterCopy = getTopLevelNodeNewName(sourceNodeRef, destinationParent, assocTypeQName, qname);
        if (newNameAfterCopy != null && !newNameAfterCopy.equals(newName))
        {
            newName = newNameAfterCopy;
        }

        while (this.internalNodeService.getChildByName(destinationParent, assocTypeQName, newName) != null)
        {
            newName = buildNewName(newName);
        }
                
        if (assocQName == null)
        {
            // Change a QName of the new node accordingly to its name
            assocQName = QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, QName.createValidLocalName(newName));
        }
        
        // Make a copy
        result = copy(sourceNodeRef, destinationParent, assocTypeQName, assocQName, copyChildren);
        
        // Set name property
        this.internalNodeService.setProperty(result, ContentModel.PROP_NAME, newName);
        
        // Return new NodeRef
        return result;    
    }
    
    /**
     * {@inheritDoc}
     * 
     * Defer to the standard implementation with copyChildren set to false
     */
    public NodeRef copy(
            NodeRef sourceNodeRef,
            NodeRef targetParentNodeRef, 
            QName assocTypeQName,
            QName assocQName)
    {
        return copy(
                sourceNodeRef, 
                targetParentNodeRef, 
                assocTypeQName, 
                assocQName, 
                false);
    }

    /**
     * {@inheritDoc}
     * 
     * Defer to the standard implementation with copyChildren set to false
     */
    public void copy(NodeRef sourceNodeRef, NodeRef targetNodeRef)
    {
        QName sourceNodeTypeQName = nodeService.getType(sourceNodeRef);
        QName targetNodeTypeQName = nodeService.getType(targetNodeRef);
        // Check that the source and destination node are the same type
        if (!sourceNodeTypeQName.equals(targetNodeTypeQName))
        {
            // Error - can not copy objects that are of different types
            throw new CopyServiceException("The source and destination node must be the same type.");
        }
        
        // Get the destinations node's details
        ChildAssociationRef destinationPrimaryAssocRef = nodeService.getPrimaryParent(targetNodeRef);
        NodeRef destinationParentNodeRef = destinationPrimaryAssocRef.getParentRef();
        QName assocTypeQName = destinationPrimaryAssocRef.getTypeQName();
        QName assocQName = destinationPrimaryAssocRef.getQName();
        
        // Get the copy details
        CopyDetails copyDetails = getCopyDetails(
                sourceNodeRef, destinationParentNodeRef, targetNodeRef,
                assocTypeQName, assocQName);
        
        // Get callbacks
        Map<QName, CopyBehaviourCallback> callbacks = getCallbacks(copyDetails);
        
        // invoke the before copy policy
        invokeBeforeCopy(sourceNodeRef, targetNodeRef);
        
        // Clear out any record of copied associations
        TransactionalResourceHelper.getList(KEY_POST_COPY_ASSOCS).clear();
        
        // Copy 
        copyProperties(copyDetails, targetNodeRef, sourceNodeTypeQName, callbacks);
        copyAspects(copyDetails, targetNodeRef, Collections.<QName>emptySet(), callbacks);
        copyResidualProperties(copyDetails, targetNodeRef);
        
        Map<NodeRef, NodeRef> copiedNodeRefs = new HashMap<NodeRef, NodeRef>(1);
        copiedNodeRefs.put(sourceNodeRef, targetNodeRef);

        Set<NodeRef> copies = new HashSet<NodeRef>(5);
        copyChildren(
                copyDetails,
                targetNodeRef,
                false,                              // We know that the node has been created
                true,
                copiedNodeRefs,
                copies,
                callbacks);
        
        // Copy an associations that were left until now
        copyPendingAssociations(copiedNodeRefs);
        // invoke the copy complete policy
        invokeCopyComplete(sourceNodeRef, targetNodeRef, false, copiedNodeRefs);         
    }
    
    @Override
    public NodeRef getOriginal(NodeRef nodeRef)
    {
        List<AssociationRef> assocs = internalNodeService.getTargetAssocs(nodeRef, ContentModel.ASSOC_ORIGINAL);
        if (assocs.size() > 1)
        {
            logger.warn("Multiple cm:orignal associations from node: " + nodeRef);
        }
        if (assocs.size() == 0)
        {
            return null;
        }
        else
        {
            return assocs.get(0).getTargetRef();
        }
    }

    @Override
    public List<NodeRef> getCopies(NodeRef nodeRef)
    {
        PagingRequest pagingRequest = new PagingRequest(1000);
        PagingResults<CopyInfo> page = getCopies(nodeRef, pagingRequest);
        if (page.hasMoreItems())
        {
            logger.warn("Trimmed page size for deprecated getCopies() call.");
        }
        List<CopyInfo> pageResults = page.getPage();
        List<NodeRef> results = new ArrayList<NodeRef>(pageResults.size());
        for (CopyInfo copyInfo : pageResults)
        {
            results.add(copyInfo.getNodeRef());
        }
        return results;
    }
    
    @Override
    public PagingResults<CopyInfo> getCopies(NodeRef originalNodeRef, PagingRequest pagingRequest)
    {
        CannedQueryFactory<CopyInfo> queryFactory = cannedQueryRegistry.getNamedObject(QUERY_FACTORY_GET_COPIES);
        CannedQueryParameters params = new CannedQueryParameters(
                new AbstractCopyCannedQueryFactory.CopyCannedQueryDetail(originalNodeRef),
                null,
                pagingRequest);
        CannedQuery<CopyInfo> query = queryFactory.getCannedQuery(params);
        return query.execute();
    }

    @Override
    public PagingResults<CopyInfo> getCopies(
            NodeRef originalNodeRef,
            NodeRef copyParentNodeRef,
            PagingRequest pagingRequest)
    {
        CannedQueryFactory<CopyInfo> queryFactory = cannedQueryRegistry.getNamedObject(QUERY_FACTORY_GET_COPIES);
        CannedQueryParameters params = new CannedQueryParameters(
                new AbstractCopyCannedQueryFactory.CopyCannedQueryDetail(originalNodeRef, copyParentNodeRef),
                null,
                pagingRequest);
        CannedQuery<CopyInfo> query = queryFactory.getCannedQuery(params);
        return query.execute();
    }

    /**
     * @return                  Returns <tt>null</tt> if the node was denied a copy
     */
    private NodeRef copyImpl(
            NodeRef sourceNodeRef,
            NodeRef targetParentRef,
            QName assocTypeQName,
            QName assocQName, 
            boolean copyChildren,
            boolean dropName,
            Map<NodeRef, NodeRef> copiesByOriginals,
            Set<NodeRef> copies)
    {
        // Build the top-level node's copy details
        CopyDetails copyDetails = getCopyDetails(sourceNodeRef, targetParentRef, null, assocTypeQName, assocQName);
        
        // Get the callbacks that will determine the copy behaviour
        Map<QName, CopyBehaviourCallback> callbacks = getCallbacks(copyDetails);
        
        // Check that the primary (type) callback allows copy
        QName sourceNodeTypeQName = copyDetails.getSourceNodeTypeQName();
        CopyBehaviourCallback callback = callbacks.get(sourceNodeTypeQName);
        if (callback == null)
        {
            throw new IllegalStateException("Source node type has no callback: " + sourceNodeTypeQName);
        }
        if (!callback.getMustCopy(sourceNodeTypeQName, copyDetails))
        {
            // Denied!
            return null;
        }
        
        // Recursive copies cannot have name conflicts, therefore copy names verbatim.
        NodeRef copiedNodeRef = recursiveCopy(
                copyDetails, copyChildren, dropName, copiesByOriginals, copies, callbacks);
        
        return copiedNodeRef;
    }
    
    /**
     * Recursive copy algorithm
     * 
     * @param dropName      drop the name property when associations don't allow duplicately named children
     */
    private NodeRef recursiveCopy(
            CopyDetails copyDetails,
            boolean copyChildren,
            boolean dropName,
            Map<NodeRef, NodeRef> copiesByOriginal,
            Set<NodeRef> copies,
            Map<QName, CopyBehaviourCallback> callbacks)
    {
        NodeRef sourceNodeRef = copyDetails.getSourceNodeRef();
        Set<QName> sourceNodeAspectQNames = copyDetails.getSourceNodeAspectQNames();
        NodeRef targetParentNodeRef = copyDetails.getTargetParentNodeRef();
        QName assocTypeQName = copyDetails.getAssocTypeQName();
        QName assocQName = copyDetails.getAssocQName();

        // Avoid duplicate and cyclic copies
        if (copies.contains(sourceNodeRef))
        {
            throw new IllegalStateException(
                    "Nested copy prevention has failed: \n" +
                    "   " + copyDetails + "\n" +
                    "   Copies by original: " + copiesByOriginal);
        }
        else if (copiesByOriginal.containsKey(sourceNodeRef))
        {
            throw new IllegalStateException(
                    "Multiple child assocs between same two nodes detected: \n" +
                    "   " + copyDetails + "\n" +
                    "   Copies by original: " + copiesByOriginal);
        }
        
        // Extract Type Definition
        QName sourceNodeTypeQName = copyDetails.getSourceNodeTypeQName();
        
        // Does this node get copied at all?
        // The source node's type-bound behaviour has an effective veto.
        CopyBehaviourCallback sourceTypeBehaviour = callbacks.get(sourceNodeTypeQName);
        if (sourceTypeBehaviour == null)
        {
            throw new IllegalStateException("Source node type has no callback: " + sourceNodeTypeQName);
        }
        if (!sourceTypeBehaviour.getMustCopy(sourceNodeTypeQName, copyDetails))
        {
            // Nothing to do
            return null;
        }
        
        // Get the type properties to copy
        Map<QName, Serializable> targetNodeProperties = buildCopyProperties(
                copyDetails,
                Collections.singleton(sourceNodeTypeQName),
                callbacks);

        // Some aspects are going to be applied automatically.  For efficiency, the initial node properties
        // for these aspects should be provided.
        Set<QName> defaultAspectQNames = getDefaultAspects(sourceNodeTypeQName);
        Map<QName, Serializable> defaultAspectsProperties = buildCopyProperties(
                copyDetails,
                defaultAspectQNames,
                callbacks);
        targetNodeProperties.putAll(defaultAspectsProperties);

        // Drop the name property, if required.  This prevents duplicate names and leaves it up to the client
        // to assign a new name.
        AssociationDefinition assocDef = dictionaryService.getAssociation(assocTypeQName);
        if (!assocDef.isChild())
        {
            throw new AlfrescoRuntimeException("Association is not a child association: " + assocTypeQName);
        }
        else
        {
            ChildAssociationDefinition childAssocDef = (ChildAssociationDefinition) assocDef;
            if (dropName && !childAssocDef.getDuplicateChildNamesAllowed())
            {
                // ALF-13949: A behaviour callback (e.g. WorkingCopyAspect) may already have generated a new name, so
                // preserve the new name if it has changed
                String newName = (String) targetNodeProperties.get(ContentModel.PROP_NAME);
                if (newName != null && newName.equals(nodeService.getProperty(sourceNodeRef, ContentModel.PROP_NAME)))
                {
                    // duplicate children are not allowed.
                    targetNodeProperties.remove(ContentModel.PROP_NAME);
                }
            }
        }
        
        // Lastly, make sure the the Node UUID is set correctly; after all, the contract
        // of the CopyDetails says that the targetNodeRef was already determined.
        String targetNodeUuid = copyDetails.getTargetNodeRef().getId();
        targetNodeProperties.put(ContentModel.PROP_NODE_UUID, targetNodeUuid);
        
        // The initial node copy is good to go
        ChildAssociationRef targetChildAssocRef = this.nodeService.createNode(
                targetParentNodeRef, 
                assocTypeQName,
                assocQName,
                sourceNodeTypeQName,
                targetNodeProperties);
        NodeRef copyTarget = targetChildAssocRef.getChildRef();
        // Save the mapping for later
        copiesByOriginal.put(sourceNodeRef, copyTarget);
        copies.add(copyTarget);
        
        // We now have a node, so fire the BeforeCopyPolicy
        invokeBeforeCopy(sourceNodeRef, copyTarget);

        // Work out which aspects still need copying.  The source aspects less the default aspects
        // will give this set.
        Set<QName> remainingAspectQNames = new HashSet<QName>(sourceNodeAspectQNames);
        remainingAspectQNames.removeAll(defaultAspectQNames);
        
        // Prevent any rules being fired on the new destination node
        this.ruleService.disableRules(copyTarget);
        try
        {
            // Apply the remaining aspects and properties
            for (QName remainingAspectQName : remainingAspectQNames)
            {
                copyProperties(copyDetails, copyTarget, remainingAspectQName, callbacks);
            }
            
            // Copy residual properties
            copyResidualProperties(copyDetails, copyTarget);
            
            //  Link the new node to the original, but ensure that we only keep track of the last copy
            List<AssociationRef> originalAssocs = internalNodeService.getTargetAssocs(copyTarget, ContentModel.ASSOC_ORIGINAL);
            for (AssociationRef originalAssoc : originalAssocs)
            {
                internalNodeService.removeAssociation(
                        originalAssoc.getSourceRef(),
                        originalAssoc.getTargetRef(),
                        ContentModel.ASSOC_ORIGINAL);
            }
            // We create the link if the source is a cm:object and the not sys:pendingDelete
            QName sourceTypeQName = internalNodeService.getType(sourceNodeRef);
            if (dictionaryService.isSubClass(sourceTypeQName, ContentModel.TYPE_CMOBJECT) &&
                    !sourceNodeAspectQNames.contains(ContentModel.ASPECT_PENDING_DELETE))
            {
                internalNodeService.createAssociation(copyTarget, sourceNodeRef, ContentModel.ASSOC_ORIGINAL);
            }
            else
            {
                // We are not creating the association, so remove the associated aspect
                internalNodeService.removeAspect(copyTarget, ContentModel.ASPECT_COPIEDFROM);
            }

            // Copy permissions
            copyPermissions(sourceNodeRef, copyTarget);
            
            // We present the recursion option regardless of what the client chooses
            copyChildren(
                    copyDetails,
                    copyTarget,
                    true,                               // We know that the node has been created
                    copyChildren,
                    copiesByOriginal,
                    copies,
                    callbacks);
        }
        finally
        {
            this.ruleService.enableRules(copyTarget);
        }
        
        return copyTarget;
    }
    
    private Set<QName> getDefaultAspects(QName sourceNodeTypeQName)
    {
        TypeDefinition sourceNodeTypeDef = dictionaryService.getType(sourceNodeTypeQName);
        if (sourceNodeTypeDef == null)
        {
            return Collections.emptySet();
        }
        Set<QName> defaultAspectQNames = new HashSet<QName>(7);
        for (AspectDefinition aspectDef : sourceNodeTypeDef.getDefaultAspects())
        {
            defaultAspectQNames.add(aspectDef.getName());
        }
        // Done
        return defaultAspectQNames;
    }
    
    /**
     * Constructs the properties to copy that apply to the type and default aspects 
     */
    private Map<QName, Serializable> buildCopyProperties(
            CopyDetails copyDetails,
            Set<QName> classQNames,
            Map<QName, CopyBehaviourCallback> callbacks)
    {
        Map<QName, Serializable> sourceNodeProperties = copyDetails.getSourceNodeProperties();
        Map<QName, Serializable> copyProperties = new HashMap<QName, Serializable>(sourceNodeProperties.size(), 1.0F);
        Map<QName, Serializable> scratchProperties = new HashMap<QName, Serializable>(11);
        // Each defined callback gets a chance to say which properties get copied
        // Only model-defined properties are considered
        for (QName classQName : classQNames)
        {
            CopyBehaviourCallback callback = callbacks.get(classQName);
            if (callback == null)
            {
                throw new IllegalStateException("Source node class has no callback: " + classQName);
            }
            // Ignore if not present or if not scheduled for a copy
            if (!callback.getMustCopy(classQName, copyDetails))
            {
                continue;
            }
            // Get the dictionary definition
            ClassDefinition classDef = dictionaryService.getClass(classQName);
            if (classDef == null)
            {
                continue;
            }
            // Get the defined properties
            Map<QName, PropertyDefinition> propertyDefs = classDef.getProperties();
            // Extract these from the source nodes properties and store in a safe (modifiable) map
            scratchProperties.clear();
            for (QName propertyQName : propertyDefs.keySet())
            {
                if (sourceNodeProperties.containsKey(propertyQName))
                {
                    Serializable value = sourceNodeProperties.get(propertyQName);
                    scratchProperties.put(propertyQName, value);
                }
            }
            // What does the behaviour do with properties?
            Map<QName, Serializable> propsToCopy = callback.getCopyProperties(classQName, copyDetails, scratchProperties);
            
            // Add to the final properties
            copyProperties.putAll(propsToCopy);
        }
        // Done
        return copyProperties;
    }
    
    /**
     * Invokes the before copy policy for the node reference provided
     * 
     * @param sourceNodeRef         the source node reference
     * @param targetNodeRef         the destination node reference
     */
    private void invokeBeforeCopy(
            NodeRef sourceNodeRef, 
            NodeRef targetNodeRef) 
    {
        // By Type
        QName targetClassRef = internalNodeService.getType(targetNodeRef);     
        invokeBeforeCopy(targetClassRef, sourceNodeRef, targetNodeRef);
        
        // And by Aspect
        Set<QName> targetAspects = this.nodeService.getAspects(targetNodeRef);
        for (QName targetAspect : targetAspects) 
        {
            invokeBeforeCopy(targetAspect, sourceNodeRef, targetNodeRef);
        }
    }
    
    private void invokeBeforeCopy(
          QName typeQName, 
          NodeRef sourceNodeRef, 
          NodeRef targetNodeRef)
    {
        Collection<CopyServicePolicies.BeforeCopyPolicy> policies = beforeCopyDelegate.getList(typeQName);
        for (CopyServicePolicies.BeforeCopyPolicy policy : policies) 
        {
            policy.beforeCopy(typeQName, sourceNodeRef, targetNodeRef);
        }
    }
    
    /**
     * Invokes the copy complete policy for the node reference provided
     * 
     * @param sourceNodeRef         the source node reference
     * @param targetNodeRef         the destination node reference
     * @param copiedNodeRefs        the map of copied node references
     */
    private void invokeCopyComplete(
            NodeRef sourceNodeRef, 
            NodeRef targetNodeRef, 
            boolean copyToNewNode,
            Map<NodeRef, NodeRef> copiedNodeRefs)
    {
        // By Type
        QName sourceClassRef = internalNodeService.getType(sourceNodeRef);     
        invokeCopyComplete(sourceClassRef, sourceNodeRef, targetNodeRef, copyToNewNode, copiedNodeRefs);
        
        // Get the source aspects
        Set<QName> sourceAspects = this.nodeService.getAspects(sourceNodeRef);
        for (QName sourceAspect : sourceAspects) 
        {
            invokeCopyComplete(sourceAspect, sourceNodeRef, targetNodeRef, copyToNewNode, copiedNodeRefs);
        }
    }

    private void invokeCopyComplete(
            QName typeQName, 
            NodeRef sourceNodeRef, 
            NodeRef targetNodeRef, 
            boolean copyToNewNode,
            Map<NodeRef, NodeRef> copiedNodeRefs)
    {
        Collection<CopyServicePolicies.OnCopyCompletePolicy> policies = onCopyCompleteDelegate.getList(typeQName);
        for (CopyServicePolicies.OnCopyCompletePolicy policy : policies) 
        {
            policy.onCopyComplete(typeQName, sourceNodeRef, targetNodeRef, copyToNewNode, copiedNodeRefs);
        }
    }

    /**
     * Copy any remaining associations that could not be copied or ignored during the copy process.
     * See <a href=http://issues.alfresco.com/jira/browse/ALF-958>ALF-958: Target associations aren't copied</a>.
     */
    private void copyPendingAssociations(Map<NodeRef, NodeRef> copiedNodeRefs)
    {
        // Prepare storage for post-copy association handling
        List<Pair<AssociationRef, AssocCopyTargetAction>> postCopyAssocs =
                TransactionalResourceHelper.getList(KEY_POST_COPY_ASSOCS);
        for (Pair<AssociationRef, AssocCopyTargetAction> pair : postCopyAssocs)
        {
            AssociationRef assocRef = pair.getFirst();
            AssocCopyTargetAction action = pair.getSecond();
            // Was the original target copied?
            NodeRef newSourceForAssoc = copiedNodeRefs.get(assocRef.getSourceRef());
            if (newSourceForAssoc == null)
            {
                // Developer #fail
                throw new IllegalStateException("Post-copy association has a source that was NOT copied.");
            }
            NodeRef oldTargetForAssoc = assocRef.getTargetRef();
            NodeRef newTargetForAssoc = copiedNodeRefs.get(oldTargetForAssoc);      // May be null
            QName assocTypeQName = assocRef.getTypeQName();
            switch (action)
            {
                case USE_ORIGINAL_TARGET:
                    internalNodeService.createAssociation(newSourceForAssoc, oldTargetForAssoc, assocTypeQName);
                    break;
                case USE_COPIED_TARGET:
                    // Do nothing if the target was not copied
                    if (newTargetForAssoc != null)
                    {
                        internalNodeService.createAssociation(newSourceForAssoc, newTargetForAssoc, assocTypeQName);
                    }
                    break;
                case USE_COPIED_OTHERWISE_ORIGINAL_TARGET:
                    if (newTargetForAssoc == null)
                    {
                        internalNodeService.createAssociation(newSourceForAssoc, oldTargetForAssoc, assocTypeQName);
                    }
                    else
                    {
                        internalNodeService.createAssociation(newSourceForAssoc, newTargetForAssoc, assocTypeQName);
                    }
                    break;
                default:
                    throw new IllegalStateException("Unknown association action: " + action);
            }
        }
        
    }

    /**
     * Copies the permissions of the source node reference onto the destination node reference
     * 
     * @param sourceNodeRef            the source node reference
     * @param destinationNodeRef    the destination node reference
     */
    private void copyPermissions(final NodeRef sourceNodeRef, final NodeRef destinationNodeRef) 
    {
        if((publicServiceAccessService.hasAccess("PermissionService", "getAllSetPermissions", sourceNodeRef) ==  AccessStatus.ALLOWED) &&
                (publicServiceAccessService.hasAccess("PermissionService", "getInheritParentPermissions", sourceNodeRef) ==  AccessStatus.ALLOWED))
        {
            // Get the permission details of the source node reference
            Set<AccessPermission> permissions = permissionService.getAllSetPermissions(sourceNodeRef);
            boolean includeInherited = permissionService.getInheritParentPermissions(sourceNodeRef);

            if((publicServiceAccessService.hasAccess("PermissionService", "setPermission", destinationNodeRef, "dummyAuth", "dummyPermission", true) == AccessStatus.ALLOWED) &&
                    (publicServiceAccessService.hasAccess("PermissionService", "setInheritParentPermissions", destinationNodeRef, includeInherited) == AccessStatus.ALLOWED))
            {
                // Set the permission values on the destination node        
                for (AccessPermission permission : permissions) 
                {
                    if(permission.isSetDirectly())
                    {
                        permissionService.setPermission(
                                destinationNodeRef, 
                                permission.getAuthority(), 
                                permission.getPermission(), 
                                permission.getAccessStatus().equals(AccessStatus.ALLOWED));
                    }
                }
                permissionService.setInheritParentPermissions(destinationNodeRef, includeInherited);
            }
        }
    }

    /**
     * Gets the copy details.  This calls the appropriate policies that have been registered
     * against the node and aspect types in order to pick-up any type specific copy behaviour.
     * <p>
     * The full {@link NodeService} is used for property retrieval.  After this, read permission
     * can be assumed to have passed on the source node - at least w.r.t. properties and aspects.
     * <p>
     * <b>NOTE:</b> If a target node is not supplied, then one is created in the same store as the
     *              target parent node.  This allows behavioural code always know which node will
     *              be copied to, even if the node does not exist.
     */
    private CopyDetails getCopyDetails(
            NodeRef sourceNodeRef,
            NodeRef targetParentNodeRef,
            NodeRef targetNodeRef,
            QName assocTypeQName,
            QName assocQName)
    {
        // The first call will fail permissions, so there is no point doing permission checks with
        // the other calls
        QName sourceNodeTypeQName = nodeService.getType(sourceNodeRef);
        // ALF-730: MLText is not fully carried during cut-paste or copy-paste
        //          Use the internalNodeService to fetch the properties.  It should be mlAwareNodeService.
        Map<QName, Serializable> sourceNodeProperties = internalNodeService.getProperties(sourceNodeRef);
        Set<QName> sourceNodeAspectQNames = internalNodeService.getAspects(sourceNodeRef);
        
        // Create a target node, if necessary
        boolean targetNodeIsNew = false;
        if (targetNodeRef == null)
        {
            targetNodeRef = new NodeRef(targetParentNodeRef.getStoreRef(), GUID.generate());
            targetNodeIsNew = true;
        }
        
        CopyDetails copyDetails = new CopyDetails(
                sourceNodeRef,
                sourceNodeTypeQName,
                sourceNodeAspectQNames,
                sourceNodeProperties,
                targetParentNodeRef,
                targetNodeRef,
                targetNodeIsNew,
                assocTypeQName,
                assocQName);
        
        // Done
        return copyDetails;
    }
    
    /**
     * @return         Returns a map of all the copy behaviours keyed by type and aspect qualified names
     */
    private Map<QName, CopyBehaviourCallback> getCallbacks(CopyDetails copyDetails)
    {
        QName sourceNodeTypeQName = copyDetails.getSourceNodeTypeQName();
        
        Map<QName, CopyBehaviourCallback> callbacks = new HashMap<QName, CopyBehaviourCallback>(11);
        // Get the type-specific behaviour
        CopyBehaviourCallback callback = getCallback(sourceNodeTypeQName, copyDetails);
        callbacks.put(sourceNodeTypeQName, callback);
        
        // Get the source aspects
        for (QName sourceNodeAspectQName : copyDetails.getSourceNodeAspectQNames()) 
        {
            callback = getCallback(sourceNodeAspectQName, copyDetails);
            callbacks.put(sourceNodeAspectQName, callback);
        }
        
        return callbacks;
    }
    
    /**
     * @return             Returns the copy callback for the given criteria
     */
    private CopyBehaviourCallback getCallback(QName sourceClassQName, CopyDetails copyDetails)
    {
        Collection<CopyServicePolicies.OnCopyNodePolicy> policies = this.onCopyNodeDelegate.getList(sourceClassQName);
        ClassDefinition sourceClassDef = dictionaryService.getClass(sourceClassQName);
        CopyBehaviourCallback callback = null;
        if (sourceClassDef == null)
        {
            // Do nothing as the type is not in the dictionary
            callback = DoNothingCopyBehaviourCallback.getInstance();
        }
        if (policies.isEmpty())
        {
            // Default behaviour
            callback = DefaultCopyBehaviourCallback.getInstance();
        }
        else if (policies.size() == 1)
        {
            callback = policies.iterator().next().getCopyCallback(sourceClassQName, copyDetails);
        }
        else
        {
            // There are multiple
            CompoundCopyBehaviourCallback compoundCallback = new CompoundCopyBehaviourCallback(sourceClassQName);
            for (CopyServicePolicies.OnCopyNodePolicy policy : policies)
            {
                CopyBehaviourCallback nestedCallback = policy.getCopyCallback(sourceClassQName, copyDetails);
                compoundCallback.addBehaviour(nestedCallback);
            }
            callback = compoundCallback;
        }
        // Done
        if (logger.isDebugEnabled())
        {
            logger.debug(
                    "Fetched copy callback: \n" +
                    "   Class:                      " + sourceClassQName + "\n" +
                    "   Details:                    " + copyDetails + "\n" +
                    "   Callback: " + callback);
        }
        return callback;
    }
    
    /**
     * Copies the properties for the node type or aspect onto the destination node.
     */
    private void copyProperties(
            CopyDetails copyDetails, 
            NodeRef targetNodeRef,
            QName classQName,
            Map<QName, CopyBehaviourCallback> callbacks)
    {
        ClassDefinition targetClassDef = dictionaryService.getClass(classQName);
        if (targetClassDef == null)
        {
            return;                        // Ignore unknown types
        }
        // First check if the aspect must be copied at all
        CopyBehaviourCallback callback = callbacks.get(classQName);
        if (callback == null)
        {
            throw new IllegalStateException("Source node class has no callback: " + classQName);
        }
        // Ignore if not present or if not scheduled for a copy
        if (!callback.getMustCopy(classQName, copyDetails))
        {
            // Do nothing with this
            return;
        }
        // Compile the properties to copy, even if they are empty
        Map<QName, Serializable> classProperties = buildCopyProperties(
                copyDetails,
                Collections.singleton(classQName),
                callbacks);
        // We don't need permissions as we've just created the node
        if (targetClassDef.isAspect())
        {
            internalNodeService.addAspect(targetNodeRef, classQName, classProperties);
        }
        else
        {
            internalNodeService.addProperties(targetNodeRef, classProperties);
        }
    }
    
    /**
     * Copy properties that do not belong to the source node's type or any of the aspects.
     */
    private void copyResidualProperties(
            CopyDetails copyDetails,
            NodeRef targetNodeRef)
    {
        Map<QName, Serializable> residualProperties = new HashMap<QName, Serializable>();
        // Start with the full set
        residualProperties.putAll(copyDetails.getSourceNodeProperties());
        
        QName sourceNodeTypeQName = copyDetails.getSourceNodeTypeQName();
        Set<QName> knownClassQNames = new HashSet<QName>(13);
        // We add the default aspects, source-applied aspects and the source node type
        knownClassQNames.addAll(getDefaultAspects(sourceNodeTypeQName));
        knownClassQNames.addAll(copyDetails.getSourceNodeAspectQNames());
        knownClassQNames.add(sourceNodeTypeQName);
        
        for (QName knownClassQName : knownClassQNames)
        {
            ClassDefinition classDef = dictionaryService.getClass(knownClassQName);
            if (classDef == null)
            {
                continue;
            }
            // Remove defined properties form the residual list
            for (QName definedPropQName : classDef.getProperties().keySet())
            {
                residualProperties.remove(definedPropQName);
                // We've removed them all, so shortcut out
                if (residualProperties.size() == 0)
                {
                    break;
                }
            }
        }
        // Add the residual properties to the node
        if (residualProperties.size() > 0)
        {
            internalNodeService.addProperties(targetNodeRef, residualProperties);
        }
    }
    
    /**
     * Copies aspects from the source to the target node.
     */
    private void copyAspects(
            CopyDetails copyDetails, 
            NodeRef targetNodeRef,
            Set<QName> aspectsToIgnore,
            Map<QName, CopyBehaviourCallback> callbacks)
    {
        Set<QName> sourceAspectQNames = copyDetails.getSourceNodeAspectQNames();
        for (QName aspectQName : sourceAspectQNames)
        {
            if (aspectsToIgnore.contains(aspectQName))
            {
                continue;
            }
            
            // Double check that the aspect must be copied at all
            CopyBehaviourCallback callback = callbacks.get(aspectQName);
            if (callback == null)
            {
                throw new IllegalStateException("Source aspect class has no callback: " + aspectQName);
            }
            if (!callback.getMustCopy(aspectQName, copyDetails))
            {
                continue;
            }
            copyProperties(copyDetails, targetNodeRef, aspectQName, callbacks);
        }
    }
    
    /**
     * @param copyChildren              <tt>false</tt> if the client selected not to recurse
     */
    private void copyChildren(
            CopyDetails copyDetails,
            NodeRef copyTarget,
            boolean copyTargetIsNew,
            boolean copyChildren,
            Map<NodeRef, NodeRef> copiesByOriginals,
            Set<NodeRef> copies,
            Map<QName, CopyBehaviourCallback> callbacks)
    {
        QName sourceNodeTypeQName = copyDetails.getSourceNodeTypeQName();
        Set<QName> sourceNodeAspectQNames = copyDetails.getSourceNodeAspectQNames();
        // First check associations on the type
        copyChildren(
                copyDetails,
                sourceNodeTypeQName,
                copyTarget,
                copyTargetIsNew,
                copyChildren,
                copiesByOriginals,
                copies,
                callbacks);
        // Check associations for the aspects
        for (QName aspectQName : sourceNodeAspectQNames)
        {
            AspectDefinition aspectDef = dictionaryService.getAspect(aspectQName);
            if (aspectDef == null)
            {
                continue;
            }
            copyChildren(
                    copyDetails,
                    aspectQName,
                    copyTarget,
                    copyTargetIsNew,
                    copyChildren,
                    copiesByOriginals,
                    copies,
                    callbacks);
        }
    }

    private static final String KEY_POST_COPY_ASSOCS = "CopyServiceImpl.postCopyAssocs";
    /**
     * @param copyChildren              <tt>false</tt> if the client selected not to recurse
     */
    private void copyChildren(
            CopyDetails copyDetails,
            QName classQName,
            NodeRef copyTarget,
            boolean copyTargetIsNew,
            boolean copyChildren,
            Map<NodeRef, NodeRef> copiesByOriginals,
            Set<NodeRef> copies,
            Map<QName, CopyBehaviourCallback> callbacks)
    {
        NodeRef sourceNodeRef = copyDetails.getSourceNodeRef();
        
        ClassDefinition classDef = dictionaryService.getClass(classQName);
        if (classDef == null)
        {
            // Ignore missing types
            return;
        }
        // Check the behaviour
        CopyBehaviourCallback callback = callbacks.get(classQName);
        if (callback == null)
        {
            throw new IllegalStateException("Source node class has no callback: " + classQName);
        }
        
        // Prepare storage for post-copy association handling
        List<Pair<AssociationRef, AssocCopyTargetAction>> postCopyAssocs =
                TransactionalResourceHelper.getList(KEY_POST_COPY_ASSOCS);
        
        // Handle peer associations.
        for (Map.Entry<QName, AssociationDefinition> entry : classDef.getAssociations().entrySet())
        {
            QName assocTypeQName = entry.getKey();
            AssociationDefinition assocDef = entry.getValue();
            if (assocDef.isChild())
            {
                continue;                   // Ignore child assocs
            }
            boolean haveRemovedFromCopyTarget = false;
            // Get the associations
            List<AssociationRef> assocRefs = nodeService.getTargetAssocs(sourceNodeRef, assocTypeQName);
            for (AssociationRef assocRef : assocRefs)
            {
                // Get the copy action for the association instance
                CopyAssociationDetails assocCopyDetails = new CopyAssociationDetails(
                        assocRef,
                        copyTarget,
                        copyTargetIsNew);
                Pair<AssocCopySourceAction, AssocCopyTargetAction> assocCopyAction = callback.getAssociationCopyAction(
                        classQName,
                        copyDetails,
                        assocCopyDetails);
                
                // Consider the source side first
                switch (assocCopyAction.getFirst())
                {
                    case IGNORE:
                        continue;                       // Do nothing
                    case COPY_REMOVE_EXISTING:
                        if (!copyTargetIsNew && !haveRemovedFromCopyTarget)
                        {
                            // Only do this if we are copying over an existing node and we have NOT
                            // already cleaned up for this association type
                            haveRemovedFromCopyTarget = true;
                            for (AssociationRef assocToRemoveRef : internalNodeService.getTargetAssocs(copyTarget, assocTypeQName))
                            {
                                internalNodeService.removeAssociation(assocToRemoveRef.getSourceRef(), assocToRemoveRef.getTargetRef(), assocTypeQName);
                            }
                        }
                        // Fall through to copy
                    case COPY:
                        // Record the type of target behaviour that is expected
                        switch (assocCopyAction.getSecond())
                        {
                            case USE_ORIGINAL_TARGET:
                            case USE_COPIED_TARGET:
                            case USE_COPIED_OTHERWISE_ORIGINAL_TARGET:
                                // Have to save for later to see if the target node is copied, too
                                postCopyAssocs.add(new Pair<AssociationRef, AssocCopyTargetAction>(assocRef, assocCopyAction.getSecond()));
                                break;
                            default:
                                throw new IllegalStateException("Unknown association target copy action: " + assocCopyAction);
                        }
                        break;
                    default:
                        throw new IllegalStateException("Unknown association source copy action: " + assocCopyAction);
                }
            }
        }
        
        // Handle child associations.  These need special attention due to their recursive nature.
        for (Map.Entry<QName, ChildAssociationDefinition> childEntry : classDef.getChildAssociations().entrySet())
        {
            QName childAssocTypeQName = childEntry.getKey();
            ChildAssociationDefinition childAssocDef = childEntry.getValue();
            if (!childAssocDef.isChild())
            {
                continue;                   // Ignore non-child assocs
            }
            // Get the child associations
            List<ChildAssociationRef> childAssocRefs = nodeService.getChildAssocs(
                    sourceNodeRef, childAssocTypeQName, RegexQNamePattern.MATCH_ALL);
            for (ChildAssociationRef childAssocRef : childAssocRefs)
            {
                NodeRef childNodeRef = childAssocRef.getChildRef();
                QName assocQName = childAssocRef.getQName();
                
                CopyChildAssociationDetails childAssocCopyDetails = new CopyChildAssociationDetails(
                        childAssocRef,
                        copyTarget,
                        copyTargetIsNew,
                        copyChildren);
                
                // Handle nested copies
                if (copies.contains(childNodeRef))
                {
                    // The node was already copied i.e. we are seeing a copy produced by some earlier
                    // copy process.
                    // The first way this can occur is if a hierarchy is copied into some lower part
                    // of the hierarchy.  We avoid the copied part.
                    // The other way this could occur is if there are multiple assocs between a
                    // parent and child.  Calls to this method are scoped by class, so the newly-created
                    // node will not be found because it will have been created using a different assoc
                    // type.
                    // A final edge case is where there are multiple assocs between parent and child
                    // of the same type.  This is ignorable.
                    continue;
                }
                // Get the copy action for the association instance
                ChildAssocCopyAction childAssocCopyAction = callback.getChildAssociationCopyAction(
                        classQName,
                        copyDetails,
                        childAssocCopyDetails);
                switch (childAssocCopyAction)
                {
                case IGNORE:
                    break;
                case COPY_ASSOC:
                    nodeService.addChild(copyTarget, childNodeRef, childAssocTypeQName, assocQName);
                    break;
                case COPY_CHILD:
                    // Handle potentially cyclic relationships
                    if (copiesByOriginals.containsKey(childNodeRef))
                    {
                        // This is either a cyclic relationship or there are multiple different
                        // types of associations between the same parent and child.
                        // Just hook the child up with the association.
                        nodeService.addChild(copyTarget, childNodeRef, childAssocTypeQName, assocQName);
                    }
                    else
                    {
                        // Find out whether to force a recursion
                        ChildAssocRecurseAction childAssocRecurseAction = callback.getChildAssociationRecurseAction(
                                classQName,
                                copyDetails,
                                childAssocCopyDetails);
                        switch (childAssocRecurseAction)
                        {
                        case RESPECT_RECURSE_FLAG:
                            // Keep child copy flag the same
                            break;
                        case FORCE_RECURSE:
                            // Force recurse
                            copyChildren = true;
                            break;
                        default:
                            throw new IllegalStateException("Unrecognized enum");
                        }
                        // This copy may fail silently
                        copyImpl(
                                childNodeRef, copyTarget,
                                childAssocTypeQName, assocQName,
                                copyChildren, false,                // Keep child names for deep copies
                                copiesByOriginals, copies);
                    }
                    break;
                default:
                    throw new IllegalStateException("Unrecognized enum");
                }
            }
        }
    }

    /**
     * Callback behaviour for the 'original' assoc ('copiedfrom' aspect).
     */
    public void beforeDeleteOriginalAssociation(AssociationRef nodeAssocRef)
    {
        // Remove the cm:copiedfrom aspect
        NodeRef sourceNodeRef = nodeAssocRef.getSourceRef();
        // We are about to modify a copied node.  For this specific action, we do not
        // want to leave any trace of the action as it's a task invisible to the end-user.
        try
        {
            behaviourFilter.disableBehaviour(sourceNodeRef, ContentModel.ASPECT_LOCKABLE);
            behaviourFilter.disableBehaviour(sourceNodeRef, ContentModel.ASPECT_AUDITABLE);
            internalNodeService.removeAspect(sourceNodeRef, ContentModel.ASPECT_COPIEDFROM);
        }
        finally
        {
            behaviourFilter.enableBehaviour(sourceNodeRef, ContentModel.ASPECT_LOCKABLE);
            behaviourFilter.enableBehaviour(sourceNodeRef, ContentModel.ASPECT_AUDITABLE);
        }
    }
    
    /**
     * Callback behaviour retrieval for the 'copiedfrom' aspect.
     * 
     * @return              Returns {@link DoNothingCopyBehaviourCallback} always
     */
    public CopyBehaviourCallback getCallbackForCopiedFromAspect(QName classRef, CopyDetails copyDetails)
    {
        return DoNothingCopyBehaviourCallback.getInstance();
    }
    
    /**
     * Callback behaviour retrieval for {@link ContentModel#TYPE_FOLDER} aspect.
     * 
     * @return              Returns FolderTypeCopyBehaviourCallback.INSTANCE
     */
    public CopyBehaviourCallback getCallbackForFolderType(QName classRef, CopyDetails copyDetails)
    {
        return FolderTypeCopyBehaviourCallback.INSTANCE;
    }

    /**
     * <b>cm:folder</b> behaviour
     * 
     * @author Derek Hulley
     * @since 3.2
     */
    private static class FolderTypeCopyBehaviourCallback extends DefaultCopyBehaviourCallback
    {
        private static final CopyBehaviourCallback INSTANCE = new FolderTypeCopyBehaviourCallback();

        /**
         * Respects the <code>copyChildren</code> flag.  Child nodes are copied fully if the association
         * is primary otherwise secondary associations are duplicated.
         */
        @Override
        public ChildAssocCopyAction getChildAssociationCopyAction(
                QName classQName,
                CopyDetails copyDetails,
                CopyChildAssociationDetails childAssocCopyDetails)
        {
            ChildAssociationRef childAssocRef = childAssocCopyDetails.getChildAssocRef();
            boolean copyChildren = childAssocCopyDetails.isCopyChildren();
            if (childAssocRef.getTypeQName().equals(ContentModel.ASSOC_CONTAINS))
            {
                if (!copyChildren)
                {
                    return ChildAssocCopyAction.IGNORE;
                }
                if (childAssocRef.isPrimary())
                {
                    return ChildAssocCopyAction.COPY_CHILD;
                }
                else
                {
                    return ChildAssocCopyAction.COPY_ASSOC;
                }
            }
            else
            {
                throw new IllegalStateException(
                        "Behaviour should have been invoked: \n" +
                        "   Aspect: " + this.getClass().getName() + "\n" +
                        "   Assoc:  " + childAssocRef + "\n" +
                        "   " + copyDetails);
            }
        }
    }

    /**
     * Callback behaviour retrieval for the 'ownable' aspect.
     * 
     * @return              Returns {@link DoNothingCopyBehaviourCallback} always
     */
    public CopyBehaviourCallback getCallbackForOwnableAspect(QName classRef, CopyDetails copyDetails)
    {
        return DoNothingCopyBehaviourCallback.getInstance();
    }

    /**
     * Determines if top-level node name will be changed during copy according to policies.
     */
    @Override
    public String getTopLevelNodeNewName(NodeRef sourceNodeRef, NodeRef targetParentRef, QName assocTypeQName, QName assocQName)
    {
        // Build the top-level node's copy details
        CopyDetails copyDetails = getCopyDetails(sourceNodeRef, targetParentRef, null, assocTypeQName, assocQName);
        
        // Get the callbacks that will determine the copy behaviour
        Map<QName, CopyBehaviourCallback> callbacks = getCallbacks(copyDetails);
        
        // Check that the primary (type) callback allows copy
        QName sourceNodeTypeQName = copyDetails.getSourceNodeTypeQName();
        CopyBehaviourCallback callback = callbacks.get(sourceNodeTypeQName);
        if (callback == null)
        {
            throw new IllegalStateException("Source node type has no callback: " + sourceNodeTypeQName);
        }
        if (!callback.getMustCopy(sourceNodeTypeQName, copyDetails))
        {
            // Denied!
            return null;
        }

        if (callback.isTopLevelCanBeRenamed(sourceNodeTypeQName, copyDetails))
        {
            // Get the type properties to copy
            Map<QName, Serializable> targetNodeProperties = buildCopyProperties(
                    copyDetails,
                    Collections.singleton(sourceNodeTypeQName),
                    callbacks);
            String newName = (String) targetNodeProperties.get(ContentModel.PROP_NAME);
            String oldName = (String) copyDetails.getSourceNodeProperties().get(ContentModel.PROP_NAME);
            if (oldName.equals(newName))
            {
                newName = null;
            }
            return newName;
        }

        return null;
    }

    /**
     * Builds name by appending copy label.
     */
    String buildNewName(final String name)
    {
        String baseName = FilenameUtils.getBaseName(name);
        String extension = FilenameUtils.getExtension(name);

        String newName = I18NUtil.getMessage(COPY_OF_LABEL, baseName);

        // append extension, if any, to filename
        if (extension != null && !extension.isEmpty())
        {
            newName = newName + FilenameUtils.EXTENSION_SEPARATOR_STR + extension;
        }

        return newName;
    }
}
