/*
 * 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.util;

import javax.jcr.Node;
import javax.jcr.Repository;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.UnsupportedRepositoryOperationException;
import javax.jcr.lock.Lock;
import javax.jcr.lock.LockException;
import javax.jcr.lock.LockManager;
import javax.jcr.observation.Event;
import javax.jcr.observation.EventIterator;
import javax.jcr.observation.EventListener;
import javax.jcr.observation.ObservationManager;

/**
 * <code>Locked</code> is a utility to synchronize modifications on a lockable
 * node. The modification is applied while the lock on the node is held, thus
 * ensuring that the modification will never fail with an {@link
 * javax.jcr.InvalidItemStateException}. This utility can be used with any
 * JCR Repository, not just Jackrabbit.
 * <p>
 * The following example shows how this utility can be used to implement
 * a persistent counter:
 * <pre>
 * Node counter = ...;
 * long nextValue = ((Long) new Locked() {
 *     protected Object run(Node counter) throws RepositoryException {
 *         Property seqProp = counter.getProperty("value");
 *         long value = seqProp.getLong();
 *         seqProp.setValue(++value);
 *         seqProp.save();
 *         return new Long(value);
 *     }
 * }.with(counter, false)).longValue();
 * </pre>
 * If you specify a <code>timeout</code> you need to check the return value
 * whether the <code>run</code> method could be executed within the timeout
 * period:
 * <pre>
 * Node counter = ...;
 * Object ret = new Locked() {
 *     protected Object run(Node counter) throws RepositoryException {
 *         Property seqProp = counter.getProperty("value");
 *         long value = seqProp.getLong();
 *         seqProp.setValue(++value);
 *         seqProp.save();
 *         return new Long(value);
 *     }
 * }.with(counter, false);
 * if (ret == Locked.TIMED_OUT) {
 *     // do whatever you think is appropriate in this case
 * } else {
 *     // get the value
 *     long nextValue = ((Long) ret).longValue();
 * }
 * </pre>
 */
public abstract class Locked {

    /** The mixin namespace */
    private static final String MIX = "http://www.jcp.org/jcr/mix/1.0";

    /**
     * Object returned when timeout is reached without being able to call
     * {@link #run} while holding the lock.
     */
    public static final Object TIMED_OUT = new Object();

    /**
     * Executes {@link #run} while the lock on <code>lockable</code> is held.
     * This method will block until {@link #run} is executed while holding the
     * lock on node <code>lockable</code>.
     *
     * @param lockable a lockable node.
     * @param isDeep   <code>true</code> if <code>lockable</code> will be locked
     *                 deep.
     * @return the object returned by {@link #run}.
     * @throws IllegalArgumentException if <code>lockable</code> is not
     *      <i>mix:lockable</i>.
     * @throws RepositoryException  if {@link #run} throws an exception.
     * @throws InterruptedException if this thread is interrupted while waiting
     *                              for the lock on node <code>lockable</code>.
     */
    public Object with(Node lockable, boolean isDeep)
            throws RepositoryException, InterruptedException {
        return with(lockable, isDeep, true);
    }

    /**
     * Executes {@link #run} while the lock on <code>lockable</code> is held.
     * This method will block until {@link #run} is executed while holding the
     * lock on node <code>lockable</code>.
     *
     * @param lockable a lockable node.
     * @param isDeep   <code>true</code> if <code>lockable</code> will be locked
     *                 deep.
     * @param isSessionScoped <code>true</code> if the lock is session scoped.
     * @return the object returned by {@link #run}.
     * @throws IllegalArgumentException if <code>lockable</code> is not
     *      <i>mix:lockable</i>.
     * @throws RepositoryException  if {@link #run} throws an exception.
     * @throws InterruptedException if this thread is interrupted while waiting
     *                              for the lock on node <code>lockable</code>.
     */
    public Object with(Node lockable, boolean isDeep, boolean isSessionScoped)
            throws RepositoryException, InterruptedException {
        return with(lockable, isDeep, Long.MAX_VALUE, isSessionScoped);
    }

    /**
     * Executes the method {@link #run} within the scope of a lock held on
     * <code>lockable</code>.
     *
     * @param lockable the node where the lock is obtained from.
     * @param isDeep   <code>true</code> if <code>lockable</code> will be locked
     *                 deep.
     * @param timeout  time in milliseconds to wait at most to acquire the lock.
     * @return the object returned by {@link #run} or {@link #TIMED_OUT} if the
     *         lock on <code>lockable</code> could not be acquired within the
     *         specified timeout.
     * @throws IllegalArgumentException if <code>timeout</code> is negative or
     *                                  <code>lockable</code> is not
     *                                  <i>mix:lockable</i>.
     * @throws RepositoryException      if {@link #run} throws an exception.
     * @throws UnsupportedRepositoryOperationException
     *                                  if this repository does not support
     *                                  locking.
     * @throws InterruptedException     if this thread is interrupted while
     *                                  waiting for the lock on node
     *                                  <code>lockable</code>.
     */
    public Object with(Node lockable, boolean isDeep, long timeout)
            throws UnsupportedRepositoryOperationException, RepositoryException, InterruptedException {
        return with(lockable, isDeep, timeout, true);
    }

    /**
     * Executes the method {@link #run} within the scope of a lock held on
     * <code>lockable</code>.
     *
     * @param lockable the node where the lock is obtained from.
     * @param isDeep   <code>true</code> if <code>lockable</code> will be locked
     *                 deep.
     * @param timeout  time in milliseconds to wait at most to acquire the lock.
     * @param isSessionScoped <code>true</code> if the lock is session scoped.
     * @return the object returned by {@link #run} or {@link #TIMED_OUT} if the
     *         lock on <code>lockable</code> could not be acquired within the
     *         specified timeout.
     * @throws IllegalArgumentException if <code>timeout</code> is negative or
     *                                  <code>lockable</code> is not
     *                                  <i>mix:lockable</i>.
     * @throws RepositoryException      if {@link #run} throws an exception.
     * @throws UnsupportedRepositoryOperationException
     *                                  if this repository does not support
     *                                  locking.
     * @throws InterruptedException     if this thread is interrupted while
     *                                  waiting for the lock on node
     *                                  <code>lockable</code>.
     */
    public Object with(Node lockable, boolean isDeep, long timeout, boolean isSessionScoped)
            throws UnsupportedRepositoryOperationException, RepositoryException, InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout must be >= 0");
        }

        Session session = lockable.getSession();

        EventListener listener = null;
        try {
            // check whether the lockable can be locked at all
            String mix = session.getNamespacePrefix(MIX);
            if (!lockable.isNodeType(mix + ":lockable")) {
                throw new IllegalArgumentException("Node is not lockable");
            }

            Lock lock = tryLock(lockable, isDeep, timeout, isSessionScoped);
            if (lock != null) {
                return runAndUnlock(lock);
            }

            if (timeout == 0) {
                return TIMED_OUT;
            }

            long timelimit;
            if (timeout == Long.MAX_VALUE) {
                timelimit = Long.MAX_VALUE;
            } else {
                timelimit = System.currentTimeMillis() + timeout;
            }

            // node is locked by other session -> register event listener if possible
            if (isObservationSupported(session)) {
                ObservationManager om = session.getWorkspace().getObservationManager();
                listener = new EventListener() {
                    public void onEvent(EventIterator events) {
                        synchronized (Locked.this) {
                            Locked.this.notify();
                        }
                    }
                };
                om.addEventListener(listener, Event.PROPERTY_REMOVED,
                        lockable.getPath(), false, null, null, true);
            }

            // now keep trying to acquire the lock
            // using 'this' as a monitor allows the event listener to notify
            // the current thread when the lockable node is possibly unlocked
            for (; ;) {
                synchronized (this) {
                    lock = tryLock(lockable, isDeep, timeout, isSessionScoped);
                    if (lock != null) {
                        return runAndUnlock(lock);
                    } else {
                        // check timeout
                        if (System.currentTimeMillis() > timelimit) {
                            return TIMED_OUT;
                        }
                        if (listener != null) {
                            // event listener *should* wake us up, however
                            // there is a chance that removal of the lockOwner
                            // property is notified before the node is acutally
                            // unlocked. therefore we use a safety net to wait
                            // at most 1000 millis.
                            this.wait(Math.min(1000, timeout));
                        } else {
                            // repository does not support observation
                            // wait at most 50 millis then retry
                            this.wait(Math.min(50, timeout));
                        }
                    }
                }
            }
        } finally {
            if (listener != null) {
                session.getWorkspace().getObservationManager().removeEventListener(listener);
            }
        }
    }

    /**
     * This method is executed while holding the lock.
     * @param node The <code>Node</code> on which the lock is placed.
     * @return an object which is then returned by {@link #with with()}.
     * @throws RepositoryException if an error occurs.
     */
    protected abstract Object run(Node node) throws RepositoryException;

    /**
     * Executes {@link #run} and unlocks the lockable node in any case, even
     * when an exception is thrown.
     *
     * @param lock The <code>Lock</code> to unlock in any case before returning.
     *
     * @return the object returned by {@link #run}.
     * @throws RepositoryException if an error occurs.
     */
    private Object runAndUnlock(Lock lock) throws RepositoryException {
        Node node = lock.getNode();
        try {
            return run(node);
        } finally {
            node.getSession().getWorkspace().getLockManager().unlock(node.getPath());
        }
    }

    /**
     * Tries to acquire a session scoped lock on <code>lockable</code>.
     *
     * @param lockable the lockable node
     * @param isDeep   <code>true</code> if the lock should be deep
     * @param timeout  time in milliseconds to wait at most to acquire the lock.
     * @param isSessionScoped <code>true</code> if the lock is session scoped.
     * @return The <code>Lock</code> or <code>null</code> if the
     *         <code>lockable</code> cannot be locked.
     * @throws UnsupportedRepositoryOperationException
     *                             if this repository does not support locking.
     * @throws RepositoryException if an error occurs
     */
    private static Lock tryLock(Node lockable, boolean isDeep, long timeout, boolean isSessionScoped)
            throws UnsupportedRepositoryOperationException, RepositoryException {
        try {
            LockManager lm = lockable.getSession().getWorkspace().getLockManager();
            return lm.lock(lockable.getPath(), isDeep, isSessionScoped, timeout, null);
        } catch (LockException e) {
            // locked by some other session
        }
        return null;
    }

    /**
     * Returns <code>true</code> if the repository supports observation.
     *
     * @param s a session of the repository.
     * @return <code>true</code> if the repository supports observation.
     */
    private static boolean isObservationSupported(Session s) {
        return "true".equalsIgnoreCase(s.getRepository().getDescriptor(Repository.OPTION_OBSERVATION_SUPPORTED));
    }

}
