/*
 * ModeShape (http://www.modeshape.org)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.modeshape.jcr;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.jcr.AccessDeniedException;
import javax.jcr.Item;
import javax.jcr.ItemNotFoundException;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.PathNotFoundException;
import javax.jcr.Property;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.ValueFormatException;
import org.modeshape.common.logging.Logger;
import org.modeshape.common.util.StringUtil;
import org.modeshape.jcr.JcrRepository.RunningState;
import org.modeshape.jcr.Sequencers.SequencingContext;
import org.modeshape.jcr.Sequencers.SequencingWorkItem;
import org.modeshape.jcr.api.JcrConstants;
import org.modeshape.jcr.api.JcrTools;
import org.modeshape.jcr.api.monitor.DurationMetric;
import org.modeshape.jcr.api.monitor.ValueMetric;
import org.modeshape.jcr.api.sequencer.Sequencer;
import org.modeshape.jcr.api.value.DateTime;
import org.modeshape.jcr.cache.change.RecordingChanges;
import org.modeshape.jcr.value.Name;

final class SequencingRunner implements Runnable {

    /** We don't use the standard logging convention here; we want clients to easily configure logging for sequencing */
    private static final Logger LOGGER = Logger.getLogger("org.modeshape.jcr.sequencing.runner");
    private static final boolean TRACE = LOGGER.isTraceEnabled();
    private static final boolean DEBUG = LOGGER.isDebugEnabled();

    private static final String DERIVED_NODE_TYPE_NAME = "mode:derived";
    private static final String DERIVED_FROM_PROPERTY_NAME = "mode:derivedFrom";

    private final RunningState repository;
    private final SequencingWorkItem work;

    protected SequencingRunner( RunningState repository,
                                SequencingWorkItem work ) {
        this.repository = repository;
        this.work = work;
    }

    @Override
    public void run() {
        JcrSession inputSession = null;
        JcrSession outputSession = null;
        final RepositoryStatistics stats = repository.statistics();
        Sequencer sequencer = null;
        String sequencerName = null;
        try {
            // Create the required session(s) ...
            inputSession = repository.loginInternalSession(work.getInputWorkspaceName());
            if (work.getOutputWorkspaceName() != null && !work.getOutputWorkspaceName().equals(work.getInputWorkspaceName())) {
                outputSession = repository.loginInternalSession(work.getOutputWorkspaceName());
            } else {
                outputSession = inputSession;
            }

            // Get the sequencer ...
            sequencer = repository.sequencers().getSequencer(work.getSequencerId());
            if (sequencer == null) {
                if (DEBUG) {
                    LOGGER.debug("Unable to find sequencer with ID '{0}' in repository '{1}'; skipping input '{3}:{2}' and output '{5}:{4}'",
                                 work.getSequencerId(), repository.name(), work.getInputPath(), work.getInputWorkspaceName(),
                                 work.getOutputPath(), work.getOutputWorkspaceName());
                }
                return;
            }
            sequencerName = sequencer.getName();

            String logMsg = null;
            if (TRACE || DEBUG) {
                logMsg = StringUtil.createString("sequencer '{0}' in repository '{1}' with input '{3}:{2}' to produce '{5}:{4}'",
                                                 sequencerName,
                                                 repository.name(),
                                                 work.getInputPath(),
                                                 work.getInputWorkspaceName(),
                                                 work.getOutputPath(),
                                                 work.getOutputWorkspaceName() != null ? work.getOutputWorkspaceName() : work.getInputWorkspaceName());
                LOGGER.debug("Running {0}", logMsg);
            }

            // Find the selected node ...
            AbstractJcrNode selectedNode = inputSession.getNode(work.getSelectedPath());

            // Find the input that has changed and is to be sequenced ...
            Item inputItem = inputSession.getItem(work.getInputPath());
            Property changedProperty = null;
            if (inputItem instanceof Property) {
                changedProperty = (Property)inputItem;
            } else {
                Node changedNode = (Node)inputItem;
                // now look for a property that was changed or added ...
                changedProperty = changedNode.getProperty(work.getChangedPropertyName());
            }
            assert changedProperty != null;

            if (sequencer.hasAcceptedMimeTypes()) {
                // Get the MIME type, first by looking at the changed property's parent node
                // (or grand-parent node if parent is 'jcr:content') ...
                String mimeType = getInputMimeType(changedProperty);

                // See if the sequencer accepts the MIME type ...
                if (mimeType != null && !sequencer.isAccepted(mimeType)) {
                    LOGGER.debug("Skipping sequencing because MIME type of input doesn't match expectations for {0}", logMsg);
                    return; // nope
                }
            }

            AbstractJcrNode outputNode = null;
            String primaryType = null;
            if (work.getSelectedPath().equals(work.getOutputPath())) {
                // The output is to go directly under the sequenced node ...
                outputNode = selectedNode.getName().equals(JcrConstants.JCR_CONTENT) ? selectedNode.getParent() : selectedNode;
                primaryType = selectedNode.getPrimaryNodeType().getName();
            } else {
                // Find the parent of the output if it exists, or create the node(s) along the path if not ...
                AbstractJcrNode parentOfOutput = null;
                try {
                    parentOfOutput = outputSession.getNode(work.getOutputPath());
                } catch (PathNotFoundException e) {
                    LOGGER.trace("Creating missing output path for {0}", logMsg);
                    JcrTools tools = new JcrTools();
                    parentOfOutput = (AbstractJcrNode)tools.findOrCreateNode(outputSession, work.getOutputPath());
                }

                // Now determine the name of top node in the output, using the last segment of the selected path ...
                String outputNodeName = computeOutputNodeName(selectedNode);

                // Remove any existing output (from a prior sequencing run on this same input) ...
                removeExistingOutputNodes(parentOfOutput, outputNodeName, work.getSelectedPath(), logMsg);

                // Create the output node
                if (parentOfOutput.isNew() && parentOfOutput.getName().equals(outputNodeName)) {
                    // avoid creating a duplicate path with the same name
                    outputNode = parentOfOutput;
                } else {
                    if (TRACE) {
                        LOGGER.trace("Creating output node '{0}' under parent '{1}' for {2}", outputNodeName,
                                     parentOfOutput.getPath(), logMsg);
                    }
                    outputNode = parentOfOutput.addNode(outputNodeName, JcrConstants.NT_UNSTRUCTURED);
                }

                // and make sure the output node has the 'mode:derived' mixin ...
                outputNode.addMixin(DERIVED_NODE_TYPE_NAME);
                outputNode.setProperty(DERIVED_FROM_PROPERTY_NAME, work.getSelectedPath());
            }

            // Execute the sequencer ...
            DateTime now = outputSession.dateFactory().create();
            Sequencer.Context context = new SequencingContext(now, outputSession.getValueFactory());
            if (inputSession.isLive() && (inputSession == outputSession || outputSession.isLive())) {
                final long start = System.nanoTime();

                try {
                    LOGGER.trace("Executing {0}", logMsg);
                    if (sequencer.execute(changedProperty, outputNode, context)) {
                        LOGGER.trace("Completed executing {0}", logMsg);

                        // Make sure that the sequencer did not change the primary type of the selected node ..
                        if (selectedNode == outputNode && !selectedNode.getPrimaryNodeType().getName().equals(primaryType)) {
                            String msg = RepositoryI18n.sequencersMayNotChangeThePrimaryTypeOfTheSelectedNode.text();
                            throw new RepositoryException(msg);
                        }

                        // find the new nodes created by the sequencing before saving, so we can properly fire the events
                        List<AbstractJcrNode> outputNodes = findOutputNodes(outputNode);

                        // set the createdBy property (if it applies) to the user which triggered the sequencing, not the context
                        // of the saving session
                        setCreatedByIfNecessary(outputSession, outputNodes);

                        // outputSession
                        LOGGER.trace("Saving session used by {0}", logMsg);
                        outputSession.save();
                        
                        long durationInNanos = Math.abs(System.nanoTime() - start);
                        Map<String, String> payload = new HashMap<String, String>();
                        payload.put("sequencerName", sequencer.getClass().getName());
                        payload.put("sequencedPath", changedProperty.getPath());
                        payload.put("outputPath", outputNode.getPath());
                        stats.recordDuration(DurationMetric.SEQUENCER_EXECUTION_TIME, durationInNanos, TimeUnit.NANOSECONDS,
                                             payload);

                        // fire the sequencing event after save (hopefully by this time the transaction has been committed)
                        // and after the metrics have been updated
                        LOGGER.trace("Firing events resulting from {0}", logMsg);
                        fireSequencingEvent(selectedNode, outputNodes, outputSession, sequencerName);
                    }
                } catch (Throwable t) {
                    try {
                        fireSequencingFailureEvent(selectedNode, inputSession, t, sequencerName);
                    } catch (Exception e) {
                        LOGGER.debug(e, "Error while trying to fire sequencing failure event");
                    }
                    // let it bubble down, because we still want to log it and update the stats
                    throw t;
                }
            }
        } catch (InterruptedException ie) {
            // most likely the repository is being shut down and is asking the runnable to interrupt
            Thread.interrupted();
            LOGGER.warn(RepositoryI18n.shutdownWhileSequencing, work.getInputPath(), ie.getMessage());
        } catch (Throwable t) {
            if (!repository.sequencers().acceptsWork()) {
                // the repository has already been shut down, so we'll just log a warning
                LOGGER.warn(RepositoryI18n.shutdownWhileSequencing, work.getInputPath(), t.getMessage());
            } else {
                if (work.getOutputWorkspaceName() != null) {
                    LOGGER.error(t, RepositoryI18n.errorWhileSequencingNodeIntoWorkspace, sequencerName, repository.name(),
                                 work.getInputPath(), work.getInputWorkspaceName(), work.getOutputPath(),
                                 work.getOutputWorkspaceName());
                } else {
                    LOGGER.error(t, RepositoryI18n.errorWhileSequencingNode, sequencerName, repository.name(),
                                 work.getInputPath(),
                                 work.getInputWorkspaceName(), work.getOutputPath());
                }
            }
        } finally {
            stats.increment(ValueMetric.SEQUENCED_COUNT);
            stats.decrement(ValueMetric.SEQUENCER_QUEUE_SIZE);
            if (inputSession != null && inputSession.isLive()) inputSession.logout();
            if (outputSession != null && outputSession != inputSession && outputSession.isLive()) outputSession.logout();
        }
    }

    /**
     * @param changedProperty the property being sequenced
     * @return the MIME type, or null if the MIME type could not be found
     * @throws ItemNotFoundException
     * @throws AccessDeniedException
     * @throws RepositoryException
     * @throws PathNotFoundException
     * @throws ValueFormatException
     * @throws IOException
     */
    static String getInputMimeType( Property changedProperty )
        throws ItemNotFoundException, AccessDeniedException, RepositoryException, PathNotFoundException, ValueFormatException,
        IOException {
        Node parent = changedProperty.getParent();
        String mimeType = null;
        if (parent.hasProperty(JcrConstants.JCR_MIME_TYPE)) {
            // The parent node has a 'jcr:mimeType' node ...
            Property property = parent.getProperty(JcrConstants.JCR_MIME_TYPE);
            if (!property.isMultiple()) {
                // The standard 'jcr:mimeType' property is single valued, but we're technically not checking if
                // the property has that particular property definition (only by name) ...
                mimeType = property.getString();
            }
        } else if (parent.getName().equals(JcrConstants.JCR_CONTENT)) {
            // There is no 'jcr:mimeType' property, and since the sequenced property is on the 'jcr:content' node,
            // get the parent (probably 'nt:file') node and look for the 'jcr:mimeType' property there ...
            try {
                parent = parent.getParent();
                if (parent.hasProperty(JcrConstants.JCR_MIME_TYPE)) {
                    Property property = parent.getProperty(JcrConstants.JCR_MIME_TYPE);
                    if (!property.isMultiple()) {
                        // The standard 'jcr:mimeType' property is single valued, but we're technically not checking if
                        // the property has that particular property definition (only by name) ...
                        mimeType = property.getString();
                    }
                }
            } catch (ItemNotFoundException e) {
                // must be the root ...
            }
        }
        if (mimeType == null && !changedProperty.isMultiple() && changedProperty.getType() == PropertyType.BINARY) {
            // Still don't know the MIME type of the property, so if it's a BINARY property we can check it ...
            javax.jcr.Binary binary = changedProperty.getBinary();
            if (binary instanceof org.modeshape.jcr.api.Binary) {
                mimeType = ((org.modeshape.jcr.api.Binary)binary).getMimeType(parent.getName());
            }
        }
        return mimeType;
    }

    private void setCreatedByIfNecessary( JcrSession outputSession,
                                          List<AbstractJcrNode> outputNodes ) throws RepositoryException {
        // if the mix:created mixin is on any of the new nodes, we need to set the createdBy here, otherwise it will be
        // set by the system session when it saves and it will default to "modeshape-worker"
        for (AbstractJcrNode node : outputNodes) {
            if (node.isNodeType(JcrMixLexicon.CREATED)) {
                node.setProperty(JcrLexicon.CREATED_BY, outputSession.getValueFactory().createValue(work.getUserId()), true,
                                 true, false, false);
            }
        }
    }

    private void fireSequencingEvent( AbstractJcrNode sequencedNode,
                                      List<AbstractJcrNode> outputNodes,
                                      JcrSession outputSession,
                                      String sequencerName ) throws RepositoryException {

        final ExecutionContext context = outputSession.context();
        RecordingChanges sequencingChanges = new RecordingChanges(outputSession.sessionId(), context.getProcessId(),
                                                                  outputSession.getRepository().repositoryKey(),
                                                                  outputSession.workspaceName(), outputSession.getRepository()
                                                                                                              .journalId());
        Name primaryType = sequencedNode.getPrimaryTypeName();
        Set<Name> mixinTypes = sequencedNode.getMixinTypeNames();
        for (AbstractJcrNode outputNode : outputNodes) {

            sequencingChanges.nodeSequenced(sequencedNode.key(), sequencedNode.path(), primaryType, mixinTypes, outputNode.key(),
                                            outputNode.path(), work.getOutputPath(), work.getUserId(), work.getSelectedPath(),
                                            sequencerName);
        }
        sequencingChanges.freeze(outputSession.getUserID(), null, context.getValueFactories().getDateFactory().create());
        repository.changeBus().notify(sequencingChanges);
    }

    private void fireSequencingFailureEvent( AbstractJcrNode sequencedNode,
                                             JcrSession inputSession,
                                             Throwable cause,
                                             String sequencerName ) throws RepositoryException {
        assert sequencedNode != null;
        assert inputSession != null;
        Name primaryType = sequencedNode.getPrimaryTypeName();
        Set<Name> mixinTypes = sequencedNode.getMixinTypeNames();
        final ExecutionContext context = inputSession.context();
        RecordingChanges sequencingChanges = new RecordingChanges(inputSession.sessionId(), context.getProcessId(),
                                                                  inputSession.getRepository().repositoryKey(),
                                                                  inputSession.workspaceName(), inputSession.getRepository()
                                                                                                            .journalId());
        sequencingChanges.nodeSequencingFailure(sequencedNode.key(), sequencedNode.path(), primaryType, mixinTypes,
                                                work.getOutputPath(), work.getUserId(), work.getSelectedPath(), sequencerName,
                                                cause);
        repository.changeBus().notify(sequencingChanges);
    }

    /**
     * Finds the top nodes which have been created during the sequencing process, based on the original output node. It is
     * important that this is called before the session is saved, because it uses the new flag.
     * 
     * @param rootOutputNode the node under which the output of the sequencing process was written to.
     * @return the first level of output nodes that were created during the sequencing process; never null
     * @throws RepositoryException if there is a problem finding the output nodes
     */
    private List<AbstractJcrNode> findOutputNodes( AbstractJcrNode rootOutputNode ) throws RepositoryException {
        if (rootOutputNode.isNew()) {
            return Arrays.asList(rootOutputNode);
        }

        // if the node was not new, we need to find the new sequenced nodes
        List<AbstractJcrNode> nodes = new ArrayList<AbstractJcrNode>();
        NodeIterator childrenIt = rootOutputNode.getNodesInternal();
        while (childrenIt.hasNext()) {
            Node child = childrenIt.nextNode();
            if (child.isNew()) {
                nodes.add((AbstractJcrNode)child);
            }
        }
        return nodes;
    }

    /**
     * Compute the name of the output node. If the selected node is named "jcr:content", this method assumes that the selected
     * node is a child of an 'nt:file' node, and so it returns the name of that 'nt:file' node. Otherwise, this method returns the
     * name of the selected node.
     * 
     * @param selectedNode the node that was selected for sequencing; may not be null
     * @return the name that should be used for the output node; never null
     * @throws RepositoryException if there is a problem accessing the repository content
     */
    protected final String computeOutputNodeName( Node selectedNode ) throws RepositoryException {
        String selectedNodeName = selectedNode.getName();
        if (selectedNodeName.equals(JcrConstants.JCR_CONTENT)) {
            try {
                return selectedNode.getParent().getName();
            } catch (ItemNotFoundException e) {
                // selected node must be the root node ?!?!
            }
        }
        return selectedNodeName;
    }

    /**
     * Remove any existing nodes that were generated by previous sequencing operations of the node at the selected path.
     * 
     * @param parentOfOutput the parent of the output; may not be null
     * @param outputNodeName the name of the output node; may not be null or empty
     * @param selectedPath the path of the node that was selected for sequencing
     * @param logMsg the log message, or null if trace/debug logging is not being used (this is passed in for efficiency reasons)
     * @throws RepositoryException if there is a problem accessing the repository content
     */
    private void removeExistingOutputNodes( AbstractJcrNode parentOfOutput,
                                            String outputNodeName,
                                            String selectedPath,
                                            String logMsg ) throws RepositoryException {
        // Determine if there is an existing output node ...
        if (TRACE) {
            LOGGER.trace("Looking under '{0}' for existing output to be removed for {1}", parentOfOutput.getPath(), logMsg);
        }
        NodeIterator outputIter = parentOfOutput.getNodesInternal(outputNodeName);
        while (outputIter.hasNext()) {
            Node outputNode = outputIter.nextNode();
            // See if this is indeed the output, which should have the 'mode:derived' mixin ...
            if (outputNode.isNodeType(DERIVED_NODE_TYPE_NAME) && outputNode.hasProperty(DERIVED_FROM_PROPERTY_NAME)) {
                // See if it was an output for the same input node ...
                String derivedFrom = outputNode.getProperty(DERIVED_FROM_PROPERTY_NAME).getString();
                if (selectedPath.equals(derivedFrom)) {
                    // Delete it ...
                    if (DEBUG) {
                        LOGGER.debug("Removing existing output node '{0}' for {1}", outputNode.getPath(), logMsg);
                    }
                    outputNode.remove();
                }
            }
        }

    }
}
