/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.jackrabbit.core.version;

import java.util.Calendar;
import java.util.Set;
import java.util.HashSet;

import javax.jcr.RepositoryException;
import javax.jcr.UnsupportedRepositoryOperationException;
import javax.jcr.NamespaceException;
import javax.jcr.Node;
import javax.jcr.PropertyType;
import javax.jcr.ItemNotFoundException;
import javax.jcr.version.Version;

import org.apache.jackrabbit.core.HierarchyManager;
import org.apache.jackrabbit.core.SessionImpl;
import org.apache.jackrabbit.core.NodeImpl;
import org.apache.jackrabbit.core.value.InternalValue;
import org.apache.jackrabbit.core.id.NodeId;
import org.apache.jackrabbit.core.nodetype.NodeTypeRegistry;
import org.apache.jackrabbit.core.session.SessionContext;
import org.apache.jackrabbit.core.state.ItemStateException;
import org.apache.jackrabbit.core.state.LocalItemStateManager;
import org.apache.jackrabbit.core.state.UpdatableItemStateManager;
import org.apache.jackrabbit.core.state.NodeState;
import org.apache.jackrabbit.spi.commons.name.NameConstants;
import org.apache.jackrabbit.spi.Path;
import org.slf4j.LoggerFactory;
import org.slf4j.Logger;

/**
 * The JCR Version Manager implementation is split in several classes in order to
 * group related methods together.
 * </p>
 * this class provides basic routines for all operations and the methods related
 * to checkin and checkout.
 */
abstract public class VersionManagerImplBase {

    /**
     * default logger
     */
    private static final Logger log = LoggerFactory.getLogger(VersionManagerImplBase.class);

    /**
     * Component context of the current session
     */
    protected final SessionContext context;

    /**
     * workspace session
     */
    protected final SessionImpl session;

    /**
     * item state manager for all workspace operations
     */
    protected final UpdatableItemStateManager stateMgr;

    /**
     * hierarchy manager that operates on the locale state manager
     */
    protected final HierarchyManager hierMgr;

    /**
     * node type registry
     */
    protected final NodeTypeRegistry ntReg;

    /**
     * the session version manager.
     */
    protected final InternalVersionManager vMgr;

    /**
     * the lock on this version manager
     */
    private final VersioningLock rwLock = new VersioningLock();

    /**
     * the node id of the current activity
     */
    protected NodeId currentActivity;

    /**
     * Creates a new version manager base for the given session
     *
     * @param context component context of the current session
     * @param stateMgr the underlying state manager
     * @param hierMgr local hierarchy manager
     */
    protected VersionManagerImplBase(
            SessionContext context, UpdatableItemStateManager stateMgr,
            HierarchyManager hierMgr) {
        this.context = context;
        this.session = context.getSessionImpl();
        this.stateMgr = stateMgr;
        this.hierMgr = hierMgr;
        this.ntReg = session.getNodeTypeManager().getNodeTypeRegistry();
        this.vMgr = session.getInternalVersionManager();
    }

    /**
     * Performs a checkin or checkout operation. if <code>checkin</code> is
     * <code>true</code> the node is checked in. if <code>checkout</code> is
     * <code>true</code> the node is checked out. if both flags are <code>true</code>
     * the checkin is performed prior to the checkout and the operation is
     * equivalent to a checkpoint operation.
     *
     * @param state node state
     * @param checkin if <code>true</code> the node is checked in.
     * @param checkout if <code>true</code> the node is checked out.
     * @param created create time of the new version (if any),
     *                or <code>null</code> for the current time
     * @return the node id of the base version or <code>null</code> for a pure
     *         checkout.
     * @throws RepositoryException if an error occurs
     */
    protected NodeId checkoutCheckin(
            NodeStateEx state,
            boolean checkin, boolean checkout, Calendar created)
            throws RepositoryException {
        assert(checkin || checkout);

        // check if versionable
        boolean isFull = checkVersionable(state);

        // check flags
        if (isCheckedOut(state)) {
            if (checkout && !checkin) {
                // pure checkout
                String msg = safeGetJCRPath(state) + ": Node is already checked-out. ignoring.";
                log.debug(msg);
                return null;
            }
        } else {
            if (!checkout) {
                // pure checkin
                String msg = safeGetJCRPath(state) + ": Node is already checked-in. ignoring.";
                log.debug(msg);
                if (isFull) {
                    return getBaseVersionId(state);
                } else {
                    // get base version from version history
                    return vMgr.getHeadVersionOfNode(state.getNodeId()).getId();
                }
            }
            checkin = false;
        }

        NodeId baseId = isFull && checkout
                ? vMgr.canCheckout(state, currentActivity)
                : null;

        // perform operation
        WriteOperation ops = startWriteOperation();
        try {
            // the 2 cases could be consolidated but is clearer this way
            if (checkin) {
                // check for configuration
                if (state.getEffectiveNodeType().includesNodeType(NameConstants.NT_CONFIGURATION)) {
                    // collect the base versions and the the rep:versions property of the configuration
                    Set<NodeId> baseVersions = collectBaseVersions(state);
                    InternalValue[] vs = new InternalValue[baseVersions.size()];
                    int i=0;
                    for (NodeId id: baseVersions) {
                        vs[i++] = InternalValue.create(id);
                    }
                    state.setPropertyValues(NameConstants.REP_VERSIONS, PropertyType.REFERENCE, vs);
                    state.store();
                }
                InternalVersion v = vMgr.checkin(session, state, created);
                baseId = v.getId();
                if (isFull) {
                    state.setPropertyValue(
                            NameConstants.JCR_BASEVERSION,
                            InternalValue.create(baseId));
                    state.setPropertyValues(NameConstants.JCR_PREDECESSORS, PropertyType.REFERENCE, InternalValue.EMPTY_ARRAY);
                    state.removeProperty(NameConstants.JCR_ACTIVITY);
                }
            }
            if (checkout) {
                if (isFull) {
                    state.setPropertyValues(
                            NameConstants.JCR_PREDECESSORS,
                            PropertyType.REFERENCE,
                            new InternalValue[]{InternalValue.create(baseId)}
                    );
                    if (currentActivity != null) {
                        state.setPropertyValue(
                                NameConstants.JCR_ACTIVITY,
                                InternalValue.create(currentActivity)
                        );
                    }
                }
            }
            state.setPropertyValue(NameConstants.JCR_ISCHECKEDOUT, InternalValue.create(checkout));
            state.store();
            ops.save();
            return baseId;
        } catch (ItemStateException e) {
            throw new RepositoryException(e);
        } finally {
            ops.close();
        }
    }

    /**
     * Collects the base versions for the workspace configuration referenced by
     * the given config node.
     * @param config the config
     * @return the id of the new base version
     * @throws RepositoryException if an error occurs
     */
    private Set<NodeId> collectBaseVersions(NodeStateEx config) throws RepositoryException {
        NodeId rootId = config.getPropertyValue(NameConstants.JCR_ROOT).getNodeId();
        NodeStateEx root = getNodeStateEx(rootId);
        if (root == null) {
            String msg = "Configuration root node for " + safeGetJCRPath(config) + " not found.";
            log.error(msg);
            throw new ItemNotFoundException(msg);
        }
        Set<NodeId> baseVersions = new HashSet<NodeId>();
        collectBaseVersions(root, baseVersions);
        return baseVersions;
    }

    /**
     * Recursively collects all base versions of this configuration tree.
     * 
     * @param root node to traverse
     * @param baseVersions set of base versions to fill
     * @throws RepositoryException if an error occurs
     */
    private void collectBaseVersions(NodeStateEx root, Set<NodeId> baseVersions)
            throws RepositoryException {
        if (!baseVersions.isEmpty()) {
            // base version of configuration root already recorded
            if (root.hasProperty(NameConstants.JCR_CONFIGURATION)
                    && root.getEffectiveNodeType().includesNodeType(NameConstants.MIX_VERSIONABLE)) {
                // don't traverse into child nodes that have a jcr:configuration
                // property as they belong to a different configuration.
                return;
            }
        }
        InternalVersion baseVersion = getBaseVersion(root);
        if (baseVersion.isRootVersion()) {
            String msg = "Unable to checkin configuration as it has unversioned child node: " + safeGetJCRPath(root);
            log.debug(msg);
            throw new UnsupportedRepositoryOperationException(msg);
        }
        baseVersions.add(baseVersion.getId());

        for (NodeStateEx child: root.getChildNodes()) {
            collectBaseVersions(child, baseVersions);
        }
    }

    /**
     * Checks if the underlying node is versionable, i.e. has 'mix:versionable' or a
     * 'mix:simpleVersionable'.
     * @param state node state
     * @return <code>true</code> if this node is full versionable, i.e. is
     *         of nodetype mix:versionable
     * @throws UnsupportedRepositoryOperationException if this node is not versionable at all
     */
    protected boolean checkVersionable(NodeStateEx state)
            throws UnsupportedRepositoryOperationException, RepositoryException {
        if (state.getEffectiveNodeType().includesNodeType(NameConstants.MIX_VERSIONABLE)) {
            return true;
        } else if (state.getEffectiveNodeType().includesNodeType(NameConstants.MIX_SIMPLE_VERSIONABLE)) {
            return false;
        } else {
            String msg = "Unable to perform a versioning operation on a " +
                         "non versionable node: " + safeGetJCRPath(state);
            log.debug(msg);
            throw new UnsupportedRepositoryOperationException(msg);
        }
    }

    /**
     * Returns the JCR path for the given node state without throwing an exception.
     * @param state node state
     * @return a JCR path string
     */
    protected String safeGetJCRPath(NodeStateEx state) {
        Path path;
        try {
            path = hierMgr.getPath(state.getNodeId());
        } catch (RepositoryException e) {
            log.warn("unable to calculate path for {}", state.getNodeId());
            return state.getNodeId().toString();
        }
        try {
            return session.getJCRPath(path);
        } catch (NamespaceException e) {
            log.warn("unable to calculate path for {}", path);
            return path.toString();
        }
    }

    /**
     * Determines the checked-out status of the given node state.
     * <p/>
     * A node is considered <i>checked-out</i> if it is versionable and
     * checked-out, or is non-versionable but its nearest versionable ancestor
     * is checked-out, or is non-versionable and there are no versionable
     * ancestors.
     *
     * @param state node state
     * @return a boolean
     * @see javax.jcr.version.VersionManager#isCheckedOut(String)
     * @see Node#isCheckedOut()
     * @throws RepositoryException if an error occurs
     */
    protected boolean isCheckedOut(NodeStateEx state) throws RepositoryException {
        return state.getPropertyValue(NameConstants.JCR_ISCHECKEDOUT).getBoolean();
    }

    /**
     * Returns the node id of the base version, retrieved from the node state
     * @param state node state
     * @return the node id of the base version or <code>null</code> if not defined
     */
    protected NodeId getBaseVersionId(NodeStateEx state) {
        InternalValue value = state.getPropertyValue(NameConstants.JCR_BASEVERSION);
        return value == null ? null : value.getNodeId();
    }

    /**
     * Returns the internal version history for the underlying node.
     * @param state node state
     * @return internal version history
     * @throws RepositoryException if an error occurs
     */
    protected InternalVersionHistory getVersionHistory(NodeStateEx state)
            throws RepositoryException {
        boolean isFull = checkVersionable(state);
        if (isFull) {
            NodeId id = state.getPropertyValue(NameConstants.JCR_VERSIONHISTORY).getNodeId();
            return vMgr.getVersionHistory(id);
        } else {
            return vMgr.getVersionHistoryOfNode(state.getNodeId());
        }
    }

    /**
     * helper class that returns the internal version for a JCR one.
     * @param v the jcr version
     * @return the internal version
     * @throws RepositoryException if an error occurs
     */
    protected InternalVersion getVersion(Version v) throws RepositoryException {
        if (v == null) {
            return null;
        } else {
            return vMgr.getVersion(((VersionImpl) v).getNodeId());
        }
    }

    /**
     * Returns the internal base version for the underlying node.
     * @param state node state
     * @return internal base version
     * @throws RepositoryException if an error occurs
     */
    protected InternalVersion getBaseVersion(NodeStateEx state) throws RepositoryException {
        boolean isFull = checkVersionable(state);
        if (isFull) {
            NodeId id = getBaseVersionId(state);
            return vMgr.getVersion(id);
        } else {
            // note, that the method currently only works for linear version
            // graphs (i.e. simple versioning)
            return vMgr.getHeadVersionOfNode(state.getNodeId());
        }
    }

    /**
     * returns the node state for the given node id
     * @param nodeId the node id
     * @throws RepositoryException if an error occurs
     * @return the node state or null if not found
     */
    protected NodeStateEx getNodeStateEx(NodeId nodeId) throws RepositoryException {
        if (!stateMgr.hasItemState(nodeId)) {
            return null;
        }
        try {
            return new NodeStateEx(
                    stateMgr,
                    ntReg,
                    (NodeState) stateMgr.getItemState(nodeId),
                    null);
        } catch (ItemStateException e) {
            throw new RepositoryException(e);
        }
    }

    /**
     * Checks modify and permissions
     * @param state state to check
     * @param options options to check
     * @param permissions permissions to check
     * @throws RepositoryException if an error occurs
     */
    protected void checkModify(NodeStateEx state, int options, int permissions)
            throws RepositoryException {
        NodeImpl node;
        try {
            node = session.getNodeById(state.getNodeId());
        } catch (RepositoryException e) {
            // ignore
            return;
        }
        context.getItemValidator().checkModify(node, options, permissions);
    }

    /**
     * Checks modify and permissions
     * @param node node to check
     * @param options options to check
     * @param permissions permissions to check
     * @throws RepositoryException if an error occurs
     */
    protected void checkModify(NodeImpl node, int options, int permissions)
            throws RepositoryException {
        context.getItemValidator().checkModify(node, options, permissions);
    }

    /**
     * Helper for managing write operations.
     */
    public class WriteOperation {

        /**
         * Flag for successful completion of the write operation.
         */
        private boolean success = false;

        private final VersioningLock.WriteLock lock;

        public WriteOperation(VersioningLock.WriteLock lock) {
            this.lock = lock;
        }

        /**
         * Saves the pending operations in the {@link LocalItemStateManager}.
         *
         * @throws ItemStateException if the pending state is invalid
         * @throws RepositoryException if the pending state could not be persisted
         */
        public void save() throws ItemStateException, RepositoryException {
            stateMgr.update();
            success = true;
        }

        /**
         * Closes the write operation. The pending operations are cancelled
         * if they could not be properly saved. Finally the write lock is
         * released.
         */
        public void close() {
            try {
                if (!success) {
                    // update operation failed, cancel all modifications
                    stateMgr.cancel();
                }
            } finally {
                lock.release();
            }
        }
    }

    /**
     * Acquires the write lock on this version manager.
     * @return returns the write lock
     */
    protected VersioningLock.WriteLock acquireWriteLock() {
        while (true) {
            try {
                return rwLock.acquireWriteLock();
            } catch (InterruptedException e) {
                // ignore
            }
        }
    }

    /**
     * acquires the read lock on this version manager.
     * @return returns the read lock
     */
    protected VersioningLock.ReadLock acquireReadLock() {
        while (true) {
            try {
                return rwLock.acquireReadLock();
            } catch (InterruptedException e) {
                // ignore
            }
        }
    }


    /**
     * Starts a write operation by acquiring the write lock and setting the
     * item state manager to the "edit" state. If something goes wrong, the
     * write lock is released and an exception is thrown.
     * <p>
     * The pattern for using this method and the returned helper instance is:
     * <pre>
     *     WriteOperation operation = startWriteOperation();
     *     try {
     *         ...
     *         operation.save(); // if everything is OK
     *         ...
     *     } catch (...) {
     *         ...
     *     } finally {
     *         operation.close();
     *     }
     * </pre>
     *
     * @return write operation helper
     * @throws RepositoryException if the write operation could not be started
     */
    public WriteOperation startWriteOperation() throws RepositoryException {
        boolean success = false;
        VersioningLock.WriteLock lock = acquireWriteLock();
        try {
            stateMgr.edit();
            success = true;
            return new WriteOperation(lock);
        } catch (IllegalStateException e) {
            String msg = "Unable to start edit operation.";
            throw new RepositoryException(msg, e);
        } finally {
            if (!success) {
                lock.release();
            }
        }
    }

}