/*
 * 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.myfaces.orchestra.conversation;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import java.util.Map;
import java.util.TreeMap;

/**
 * A Conversation is a container for a set of beans.
 *
 * <p>Optionally, a PersistenceContext can also be associated with a conversation.</p>
 *
 * <p>There are various ways how to get access to a Conversation instance:
 * <ul>
 * <li>{@link Conversation#getCurrentInstance} if you are calling from a
 * conversation-scoped bean, or something that is called from such a bean.</li>
 * <li>{@link ConversationManager#getConversation(String)}</li>
 * <li>by implementing the {@link ConversationAware} or {@link ConversationBindingListener}
 * interface in a bean.</li>
 * </ul>
 * </p>
 *
 * <p>Conversation instances are typically created when an EL expression references a
 * bean whose definition indicates that it is in a conversation scope.</p>
 *
 * <p>A conversation instance is typically destroyed:
 * <ul>
 * <li>At the end of a request when it are marked as access-scoped but
 * no bean in the conversation scope was accessed during the just-completed request.</li>
 * <li>Via an ox:endConversation component</li>
 * <li>Via an action method calling Conversation.invalidate()</li>
 * <li>Due to a conversation timeout, ie when no object in the conversation has been
 * accessed for N minutes. See ConversationManagedSessionListener,
 * ConversationTimeoutableAspect, and ConversationManager.checkTimeouts.</li>
 * </ul>
 * </p>
 */
public class Conversation
{
    // See getCurrentInstance, setCurrentInstance and class CurrentConversationAdvice.
    private final static ThreadLocal CURRENT_CONVERSATION = new ThreadLocal();

    private final Log log = LogFactory.getLog(Conversation.class);

    // The name of this conversation
    private final String name;

    // The factory that created this conversation instance; needed
    // when restarting the conversation.
    private final ConversationFactory factory;

    // The parent context to which this conversation belongs. This is needed
    // when restarting the conversation.
    private final ConversationContext conversationContext;

    // An object that can bind arbitrary objects to this conversation so that all calls to
    // methods on the target object cause this conversation to become the active conversation,
    // and for any other optional "entry actions" (Advices) to be applied.
    private ConversationBinder binder;

    // The set of managed beans that are associated with this conversation.
    private Map beans = new TreeMap();

    // See addAspect.
    private ConversationAspects conversationAspects = new ConversationAspects();

    // Is this object usable, or "destroyed"?
    private boolean invalid = false;

    // Is this object going to be destroyed as soon as it is no longer "active"?
    private boolean queueInvalid = false;

    // system timestamp in milliseconds at which this object was last accessed;
    // see method touch().
    private long lastAccess;

    private Object activeCountMutex = new Object();
    private int activeCount;

    public Conversation(ConversationContext conversationContext, String name, ConversationFactory factory)
    {
        this.conversationContext = conversationContext;
        this.name = name;
        this.factory = factory;

        if (log.isDebugEnabled())
        {
            log.debug("start conversation:" + name);
        }

        touch();
    }

    /**
     * Define the (optional) binder used by this instance in method bind(Object).
     * <p>
     * Expected to be called by code that creates instances of this type immediately
     * after the constructor is invoked. See bind(Object) for more details.
     * 
     * @since 1.3
     */
    public void setBinder(ConversationBinder binder)
    {
        this.binder = binder;
    }

    /**
     * Mark this conversation as having been used at the current time.
     * <p>
     * Conversations can have "timeouts" associated with them, so that when a user stops
     * a conversation and goes off to work on some other part of the webapp then the
     * conversation's memory can eventually be reclaimed.
     * <p>
     * Whenever user code causes this conversation object to be looked up and returned,
     * this "touch" method is invoked to indicate that the conversation is in use. Direct
     * conversation lookups by user code can occur, but the most common access is expected
     * to be via an EL expression which a lookup of a bean that is declared as being in
     * conversation scope. The bean lookup causes the corresponding conversation to be
     * looked up, which triggers this method.
     */
    protected void touch()
    {
        lastAccess = System.currentTimeMillis();
    }

    /**
     * The system time in millis when this conversation has been accessed last
     */
    public long getLastAccess()
    {
        return lastAccess;
    }

    /**
     * Add the given bean to the conversation scope.
     *
     * <p>This will fire a {@link ConversationBindingEvent} on the bean parameter
     * object if the bean implements the {@link ConversationBindingListener}
     * interface</p>
     *
     * <p>Note that any object can be stored into the conversation; it is not
     * limited to managed beans declared in a configuration file. This
     * feature is not expected to be heavily used however; most attributes of
     * a conversation are expected to be externally-declared "managed beans".</p>
     */
    public void setAttribute(String name, Object bean)
    {
        checkValid();

        synchronized(conversationContext)
        {
            removeAttribute(name);

            if (log.isDebugEnabled())
            {
                log.debug("put bean to conversation:" + name + "(bean=" + bean + ")");
            }

            beans.put(name, bean);
        }

        if (bean instanceof ConversationBindingListener)
        {
            ((ConversationBindingListener) bean).valueBound(
                new ConversationBindingEvent(this, name));
        }
    }

    /**
     * Assert the conversation is valid.
     *
     * Throws IllegalStateException if this conversation has been destroyed;
     * see method setInvalid.
     */
    protected void checkValid()
    {
        if (isInvalid())
        {
            throw new IllegalStateException("conversation '" + getName() + "' closed");
        }
    }

    /**
     * Return the name of this conversation.
     * <p>
     * A conversation name is unique within a conversation context.
     */
    public String getName()
    {
        return name;
    }

    /**
     * Return the factory that created this conversation.
     * <p>
     * Note that this factory will have set the initial aspects of this factory, which
     * configure such things as the lifetime (access, manual, etc) and conversation
     * timeout properties.
     */
    public ConversationFactory getFactory()
    {
        return factory;
    }

    /**
     * Invalidate (end) the conversation.
     * <p>
     * If the conversation is currently active (ie the current call stack contains an object that
     * belongs to this conversation) then the conversation will just queue the object for later
     * destruction. Calls to methods like ConversationManager.getConversation(...) may still
     * return this object, and it will continue to function as a normal instance.
     * <p>
     * Only when the conversation is no longer active will the conversation (and the beans
     * it contains) actually be marked as invalid ("destroyed"). Once the conversation has been
     * destroyed, the ConversationManager will discard all references to it, meaning it will no
     * longer be accessable via lookups like ConversationManager.getConversation(). If something
     * does still have a reference to a destroyed conversation, then invoking almost any method
     * on that object will throw an IllegalStateException. In particular, adding a bean to the
     * conversation (invoking addAttribute) is not allowed.
     */
    public void invalidate()
    {
        if (!isActive())
        {
            destroy();
        }
        else
        {
            queueInvalid = true;

            if (log.isDebugEnabled())
            {
                log.debug("conversation '" + name + "' queued for destroy.");
            }
        }
    }

    /**
     * Invalidate/End and restart the conversation.
     * <p>
     * This conversation object is immediately "destroyed" (see comments for method
     * invalidate), and a new instance is registered with the conversation manager
     * using the same name. The new instance is returned from this method.
     * <p>
     * Any code holding a reference to the old conversation instance will receive
     * an IllegalStateException when calling almost any method on that instance.
     *
     * @return the new conversation
     */
    public Conversation invalidateAndRestart()
    {
        String conversationName = getName();
        ConversationFactory factory = getFactory();

        destroy();

        return conversationContext.startConversation(conversationName, factory);
    }

    /**
     * Return true if the conversation is invalid, ie should not be used.
     */
    public boolean isInvalid()
    {
        return invalid;
    }

    /**
     * Return true if the conversation has been queued to be invalidated.
     */
    boolean isQueueInvalid()
    {
        return queueInvalid;
    }

    /**
     * Destroy the conversation.
     * <ul>
     * <li>inform all beans implementing the {@link ConversationBindingListener} about the conversation end</li>
     * <li>free all beans</li>
     * </ul>
     * <p>
     * After return from this method, this conversation object's invalid flag is set and the map of
     * beans associated with this conversation is empty. In addition, the parent context no longer
     * holds a reference to this conversation.
     */
    protected void destroy()
    {
        if (log.isDebugEnabled())
        {
            log.debug("destroy conversation:" + name);
        }

        synchronized(conversationContext)
        {
            String[] beanNames = (String[]) beans.keySet().toArray(new String[beans.size()]);
            for (int i = 0; i< beanNames.length; i++)
            {
                removeAttribute(beanNames[i]);
            }
        }

        conversationContext.removeConversation(getName());

        invalid = true;
    }

    /**
     * Check if this conversation holds a specific attribute (ie has a specific
     * named managed bean instance).
     */
    public boolean hasAttribute(String name)
    {
        synchronized(conversationContext)
        {
            return beans.containsKey(name);
        }
    }

    /**
     * Get a specific attribute, ie a named managed bean.
     */
    public Object getAttribute(String name)
    {
        synchronized(conversationContext)
        {
            return beans.get(name);
        }
    }

    /**
     * Remove a bean from the conversation.
     *
     * <p>This will fire a {@link ConversationBindingEvent} if the bean implements the
     * {@link ConversationBindingListener} interface.</p>
     */
    public Object removeAttribute(String name)
    {
        synchronized(conversationContext)
        {
            Object bean = beans.remove(name);
            if (bean instanceof ConversationBindingListener)
            {
                ((ConversationBindingListener) bean).valueUnbound(
                    new ConversationBindingEvent(this, name));
            }
            return bean;
        }
    }

    /**
     * Get the current conversation.
     *
     * @return The conversation object associated with the nearest object in the call-stack that
     * is configured to be in a conversation.<br />
     * If there is no object in the call-stack the system will lookup the single conversation
     * bound to the conversationContext.<br />
     * If not found, null will be returned.
     */
    public static Conversation getCurrentInstance()
    {
        CurrentConversationInfo conversation = getCurrentInstanceInfo();
        if (conversation != null)
        {
            return conversation.getConversation();
        }

        return null;
    }

    /**
     * Sets info about the current conversation instance.
     * <p>
     * This method is only expected to be called by CurrentConversationAdvice.invoke,
     * which ensures that the current instance is reset to null as soon as no bean
     * in the call-stack is within a conversation.
     */
    static void setCurrentInstance(CurrentConversationInfo conversation)
    {
        CURRENT_CONVERSATION.set(conversation);
    }

    /**
     * Returns the info about the current conversation
     */
    static CurrentConversationInfo getCurrentInstanceInfo()
    {
        CurrentConversationInfo conversationInfo = (CurrentConversationInfo) CURRENT_CONVERSATION.get();
        if (conversationInfo != null && conversationInfo.getConversation() != null)
        {
            conversationInfo.getConversation().touch();
            return conversationInfo;
        }

        return null;
    }

    /**
     * Increase one to the "conversation active" counter.
     * <p>
     * This is called when a method is invoked on a bean that is within this conversation.
     * When the method returns, leaveConversation is invoked. The result is that the count
     * is greater than zero whenever there is a bean belonging to this conversation on
     * the callstack.
     * <p>
     * This method throws IllegalStateException if it is called on a conversation that has
     * been destroyed.
     */
    void enterConversation()
    {
        checkValid();

        synchronized (activeCountMutex)
        {
            activeCount++;
        }
    }

    /**
     * decrease one from the "conversation active" counter
     */
    void leaveConversation()
    {
        synchronized (activeCountMutex)
        {
            activeCount--;
        }
    }

    /**
     * check if the conversation is active
     */
    private boolean isActive()
    {
        synchronized (activeCountMutex)
        {
            return activeCount > 0;
        }
    }

    ConversationAspects getAspects()
    {
        return conversationAspects;
    }

    /**
     * Get the aspect corresponding to the given class.
     *
     * @return null if such an aspect has not been attached to this conversation
     */
    public ConversationAspect getAspect(Class conversationAspectClass)
    {
        return conversationAspects.getAspect(conversationAspectClass);
    }

    /**
     * Add an Aspect to this conversation.
     *
     * See class ConversationAspects for further details.
     */
    public void addAspect(ConversationAspect aspect)
    {
        conversationAspects.addAspect(aspect);
    }

    /**
     * Get direct access to the beans map.
     * <p>
     * This method is only intended for use by subclasses that manipulate
     * the beans map in unusual ways. In general, it is better to use the
     * setAttribute/removeAttribute methods rather than accessing beans via
     * this map. Adding/removing entries in this map will not trigger the
     * usual callbacks on the bean objects themselves.
     * 
     * @since 1.2
     */
    protected Map getBeans()
    {
        synchronized(conversationContext)
        {
            return beans;
        }
    }
    
    /**
     * Replace the current beans map.
     * <p>
     * @see #getBeans()
     * @since 1.2
     */
    protected void setBeans(Map beans)
    {
        synchronized(conversationContext)
        {
            this.beans = beans;
        }
    }

    /**
     * Return a proxy object that "binds" the specified instance to this conversation.
     * <p>
     * Whenever a method is executed on the proxy, this conversation is "entered" before the method
     * is invoked on the actual instance, as if the specified instance were a bean that was defined
     * in the dependency-injection framework as belonging to this conversation.
     * <p>
     * The specified bean is NOT added to the set of beans belonging to the conversation, ie its
     * lifecycle is still independent of the conversation.
     * <p>
     * The returned proxy is bound to a specific Conversation instance, so it should not be stored for
     * a long time; if the conversation is "invalidated" the proxy will continue to reference the original
     * conversation instance meaning that invoking the proxy would use the "stale" conversation rather than
     * a new instance. It also means that memory allocated to the conversation will not be recycled, although
     * this is not too serious as the invalidated conversation will be empty of beans. This limitation on
     * the lifetime of the returned proxy is not an issue for many of the uses of this method; in particular,
     * when wrapping objects returned by property getters for the use of EL expressions this is fine as the
     * proxy is only used during the scope of the EL expression execution. If a proxy is used after the
     * conversation it is bound to has been invalidated then an IllegalStateException will be thrown.
     * <p>
     * This method is useful when a conversation-scoped object creates an object instance via new() or via
     * calling some other library, and then wants all operations on that object to run within its own
     * conversation. In particular, when a backing-bean returns a persistent object that has been loaded
     * via a DAO class, it is often desirable for all methods on the persistent object to run within
     * the backing-bean's persistence context, ie within the same persistence context set up which
     * existed when the DAO class loaded the instance. For example, a JSF EL expression may retrieve
     * a persistent object from a backing bean then navigate through its properties; walking lazy
     * relations in this way will fail unless calls to methods of the persistent object cause the
     * correct persistence-context to be set up.
     * <p>
     * This method is theoretically an optional operation; the orchestra adapter layer for some specific
     * dependency-injection frameworks might choose not to support this, in which case an
     * UnsupportedOperationException will be thrown. The default Orchestra-Spring integration
     * certainly does support it.
     * <p>
     * It initially seems reasonable for Orchestra to also provide a variant of this method that
     * returns a "scoped proxy" object that looks up the most recent version of the conversation
     * by name and then runs the bound object in the context of that conversation instance. This
     * would mean that a proxy would never throw an IllegalStateException due to its conversation
     * having been invalidated. However this is not actually very useful. The primary use for this
     * method is expected to be wrapping of persistent objects returned by JPA, Hibernate or similar.
     * In this case the point is to access the object using the conversation's "persistence context";
     * a new conversation will have a new persistence context instance, not the one the object needs.
     * 
     * @throw {@link UnsupportedOperationException}
     * @since 1.3
     */
    public Object bind(Object instance)
    {
        if (binder == null)
        {
            throw new UnsupportedOperationException("No beanBinder instance");
        }
        return binder.bind(instance);
    }
}
