/*
 * 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 java.io.IOException;
import java.io.ObjectStreamException;
import java.io.Serializable;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.myfaces.orchestra.FactoryFinder;
import org.apache.myfaces.orchestra.frameworkAdapter.FrameworkAdapter;
import org.apache.myfaces.orchestra.lib.OrchestraException;
import org.apache.myfaces.orchestra.requestParameterProvider.RequestParameterProviderManager;

/**
 * Deals with the various conversation contexts in the current session.
 * <p>
 * There is expected to be one instance of this class per http-session, managing all of the
 * data associated with all browser windows that use that http-session.
 * <p>
 * One particular task of this class is to return "the current" ConversationContext object for
 * the current http request (from the set of ConversationContext objects that this manager
 * object holds). The request url is presumed to include a query-parameter that specifies the
 * id of the appropriate ConversationContext object to be used. If no such query-parameter is
 * present, then a new ConversationContext object will automatically be created.
 * <p>
 * At the current time, this object does not serialize well. Any attempt to serialize
 * this object (including any serialization of the user session) will just cause it
 * to be discarded.
 * <p>
 * TODO: fix serialization issues.
 */
public class ConversationManager implements Serializable
{
    private static final long serialVersionUID = 1L;

    final static String CONVERSATION_CONTEXT_PARAM = "conversationContext";

    private final static String CONVERSATION_MANAGER_KEY = "org.apache.myfaces.ConversationManager";
    private final static String CONVERSATION_CONTEXT_REQ = "org.apache.myfaces.ConversationManager.conversationContext";

    private static final Iterator EMPTY_ITERATOR = Collections.EMPTY_LIST.iterator();

    // See method readResolve
    private static final Object DUMMY = new Integer(-1);

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

    /**
     * Used to generate a unique id for each "window" that a user has open
     * on the same webapp within the same HttpSession. Note that this is a
     * property of an object stored in the session, so will correctly
     * migrate from machine to machine along with a distributed HttpSession.
     *
     */
    private long nextConversationContextId = 1;

    // This member must always be accessed with a lock held on the parent ConverstationManager instance;
    // a HashMap is not thread-safe and this class must be thread-safe.
    private final Map conversationContexts = new HashMap();

    protected ConversationManager()
    {
    }

    /**
     * Get the conversation manager for the current http session.
     * <p>
     * If none exists, then a new instance is allocated and stored in the current http session.
     * Null is never returned.
     * <p>
     * Throws IllegalStateException if the Orchestra FrameworkAdapter has not been correctly
     * configured.
     */
    public static ConversationManager getInstance()
    {
        return getInstance(true);
    }

    /**
     * Get the conversation manager for the current http session.
     * <p>
     * When create is true, an instance is always returned; one is created if none currently exists
     * for the current user session.
     * <p>
     * When create is false, null is returned if no instance yet exists for the current user session.
     */
    public static ConversationManager getInstance(boolean create)
    {
        FrameworkAdapter frameworkAdapter = FrameworkAdapter.getCurrentInstance();
        if (frameworkAdapter == null)
        {
            if (!create)
            {
                // if we don't have to create a conversation manager, then it doesn't
                // matter if there is no FrameworkAdapter available.
                return null;
            }
            else
            {
                throw new IllegalStateException("FrameworkAdapter not found");
            }
        }

        Object cmObj = frameworkAdapter.getSessionAttribute(CONVERSATION_MANAGER_KEY);
        // hack: see method readResolve
        if (cmObj == DUMMY)
        {
            Log log = LogFactory.getLog(ConversationManager.class);
            log.debug("Method getInstance found dummy ConversationManager object");
            cmObj = null;
        }


        ConversationManager conversationManager = (ConversationManager) cmObj;

        if (conversationManager == null && create)
        {
            Log log = LogFactory.getLog(ConversationManager.class);
            log.debug("Register ConversationRequestParameterProvider");
            
            conversationManager = FactoryFinder.getConversationManagerFactory().createConversationManager();

            // initialize environmental systems
            RequestParameterProviderManager.getInstance().register(new ConversationRequestParameterProvider());

            // set mark
            FrameworkAdapter.getCurrentInstance().setSessionAttribute(CONVERSATION_MANAGER_KEY, conversationManager);
        }

        return conversationManager;
    }

    /**
     * Get the current conversationContextId.
     * <p>
     * If there is no current conversationContext, then null is returned.
     */
    private Long findConversationContextId()
    {
        FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
        
        // Has it been extracted from the req params and cached as a req attr?
        Long conversationContextId = (Long)fa.getRequestAttribute(CONVERSATION_CONTEXT_REQ);
        if (conversationContextId == null)
        {
            if (fa.containsRequestParameterAttribute(CONVERSATION_CONTEXT_PARAM))
            {
                String urlConversationContextId = fa.getRequestParameterAttribute(
                        CONVERSATION_CONTEXT_PARAM).toString();
                conversationContextId = new Long(
                        Long.parseLong(urlConversationContextId, Character.MAX_RADIX));
            }
        }
        return conversationContextId;
    }
    
    /**
     * Get the current, or create a new unique conversationContextId.
     * <p>
     * The current conversationContextId will be retrieved from the request
     * parameters. If no such parameter is present then a new id will be
     * allocated <i>and configured as the current conversation id</i>.
     * <p>
     * In either case the result will be stored within the request for
     * faster lookup.
     * <p>
     * Note that there is no security flaw regarding injection of fake
     * context ids; the id must match one already in the session and there
     * is no security problem with two windows in the same session exchanging
     * ids.
     * <p>
     * This method <i>never</i> returns null.
     */
    private Long getOrCreateConversationContextId()
    {
        Long conversationContextId = findConversationContextId();
        if (conversationContextId == null)
        {
            conversationContextId = createNextConversationContextId();
            FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
            fa.setRequestAttribute(CONVERSATION_CONTEXT_REQ, conversationContextId);
        }

        return conversationContextId;
    }

    /**
     * Get the current, or create a new unique conversationContextId.
     * <p>
     * This method is deprecated because, unlike all the other get methods, it
     * actually creates the value if it does not exist. Other get methods (except
     * getInstance) return null if the data does not exist. In addition, this
     * method is not really useful to external code and probably should never
     * have been exposed as a public API in the first place; external code should
     * never need to force the creation of a ConversationContext.
     * <p>
     * For internal use within this class, use either findConversationContextId()
     * or getOrCreateConversationContextId().
     * <p>
     * To just obtain the current ConversationContext <i>if it exists</i>, see
     * method getCurrentConversationContext().
     * 
     * @deprecated This method should not be needed by external classes, and
     * was inconsistent with other methods on this class.
     */
    public Long getConversationContextId()
    {
        return getOrCreateConversationContextId();
    }

    /**
     * Allocate a new Long value for use as a conversation context id.
     * <p>
     * The returned value must not match any conversation context id already in
     * use within this ConversationManager instance (which is scoped to the 
     * current http session).
     */
    private Long createNextConversationContextId()
    {
        Long conversationContextId;
        synchronized(this)
        {
            conversationContextId = new Long(nextConversationContextId);
            nextConversationContextId++;
        }
        return conversationContextId;
    }

    /**
     * Get the conversation context for the given id.
     * <p>
     * Null is returned if there is no ConversationContext with the specified id.
     * <p>
     * Param conversationContextId must not be null.
     * <p>
     * Public since version 1.3.
     */
    public ConversationContext getConversationContext(Long conversationContextId)
    {
        synchronized (this)
        {
            return (ConversationContext) conversationContexts.get(conversationContextId);
        }
    }

    /**
     * Get the conversation context for the given id.
     * <p>
     * If there is no such conversation context a new one will be created.
     * The new conversation context will be a "top-level" context (ie has no parent).
     * <p>
     * The new conversation context will <i>not</i> be the current conversation context,
     * unless the id passed in was already configured as the current conversation context id.
     */
    protected ConversationContext getOrCreateConversationContext(Long conversationContextId)
    {
        synchronized (this)
        {
            ConversationContext conversationContext = (ConversationContext) conversationContexts.get(
                    conversationContextId);
            if (conversationContext == null)
            {
                ConversationContextFactory factory = FactoryFinder.getConversationContextFactory();
                conversationContext = factory.createConversationContext(null, conversationContextId.longValue());
                conversationContexts.put(conversationContextId, conversationContext);

                // TODO: add the "user" name here, otherwise this debugging is not very useful
                // except when testing a webapp with only one user.
                log.debug("Created context " + conversationContextId);
            }
            return conversationContext;
        }
    }

    /**
     * This will create a new conversation context using the specified context as
     * its parent. 
     * <p>
     * The returned context is not selected as the "current" one; see activateConversationContext.
     * 
     * @since 1.3
     */
    public ConversationContext createConversationContext(ConversationContext parent)
    {
        Long ctxId = createNextConversationContextId();
        ConversationContextFactory factory = FactoryFinder.getConversationContextFactory();
        ConversationContext ctx = factory.createConversationContext(parent, ctxId.longValue());

        synchronized(this)
        {
            conversationContexts.put(ctxId, ctx);
        }
        
        return ctx;
    }

    /**
     * Make the specific context the current context for the current HTTP session.
     * <p>
     * Methods like getCurrentConversationContext will then return the specified
     * context object.
     * 
     * @since 1.2
     */
    public void activateConversationContext(ConversationContext ctx)
    {
        FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
        fa.setRequestAttribute(CONVERSATION_CONTEXT_REQ, ctx.getIdAsLong());
    }

    /**
     * Ends all conversations within the current context; the context itself will remain active.
     */
    public void clearCurrentConversationContext()
    {
        Long conversationContextId = findConversationContextId();
        if (conversationContextId != null)
        {
            ConversationContext conversationContext = getConversationContext(conversationContextId);
            if (conversationContext != null)
            {
                conversationContext.invalidate();
            }
        }
    }

    /**
     * Removes the specified contextId from the set of known contexts,
     * and deletes every conversation in it.
     * <p>
     * Objects in the conversation which implement ConversationAware
     * will have callbacks invoked.
     * <p>
     * The conversation being removed must not be the currently active
     * context. If it is, then method activateConversationContext should
     * first be called on some other instance (perhaps the parent of the
     * one being removed) before this method is called.
     * 
     * @since 1.3
     */
    public void removeAndInvalidateConversationContext(ConversationContext context)
    {
        if (context.hasChildren())
        {
            throw new OrchestraException("Cannot remove context with children");
        }

        if (context.getIdAsLong().equals(findConversationContextId()))
        {
            throw new OrchestraException("Cannot remove current context");
        }

        synchronized(conversationContexts)
        {
            conversationContexts.remove(context.getIdAsLong());
        }

        ConversationContext parent = context.getParent();
        if (parent != null)
        {
            parent.removeChild(context);
        }

        context.invalidate();
        
        // TODO: add the deleted context ids to a list stored in the session,
        // and redirect to an error page if any future request specifies this id.
        // This catches things like going "back" into a flow that has ended, or
        // navigating with the parent page of a popup flow (which kills the popup
        // flow context) then trying to use the popup page.
        //
        // We cannot simply report an error for every case where an invalid id is
        // used, because bookmarks will have ids in them; when the bookmark is used
        // after the session has died we still want the bookmark url to work. Possibly
        // we should allow GET with a bad id, but always fail a POST with one?
    }

    /**
     * Removes the specified contextId from the set of known contexts.
     * <p>
     * It does nothing else. Maybe it should be called "detachConversationContext"
     * or similar.
     * 
     * @deprecated This method is not actually used by anything.
     */
    protected void removeConversationContext(Long conversationContextId)
    {
        synchronized (this)
        {
            conversationContexts.remove(conversationContextId);
        }
    }

    /**
     * Start a conversation.
     *
     * @see ConversationContext#startConversation(String, ConversationFactory)
     */
    public Conversation startConversation(String name, ConversationFactory factory)
    {
        ConversationContext conversationContext = getOrCreateCurrentConversationContext();
        return conversationContext.startConversation(name, factory);
    }

    /**
     * Remove a conversation
     *
     * Note: It is assumed that the conversation has already been invalidated
     *
     * @see ConversationContext#removeConversation(String)
     */
    protected void removeConversation(String name)
    {
        Long conversationContextId = findConversationContextId();
        if (conversationContextId != null)
        {
            ConversationContext conversationContext = getConversationContext(conversationContextId);
            if (conversationContext != null)
            {
                conversationContext.removeConversation(name);
            }
        }
    }

    /**
     * Get the conversation with the given name
     *
     * @return null if no conversation context is active or if the conversation did not exist.
     */
    public Conversation getConversation(String name)
    {
        ConversationContext conversationContext = getCurrentConversationContext();
        if (conversationContext == null)
        {
            return null;
        }
        return conversationContext.getConversation(name);
    }

    /**
     * check if the given conversation is active
     */
    public boolean hasConversation(String name)
    {
        ConversationContext conversationContext = getCurrentConversationContext();
        if (conversationContext == null)
        {
            return false;
        }
        return conversationContext.hasConversation(name);
    }

    /**
     * Returns an iterator over all the Conversation objects in the current conversation
     * context. Never returns null, even if no conversation context exists.
     */
    public Iterator iterateConversations()
    {
        ConversationContext conversationContext = getCurrentConversationContext();
        if (conversationContext == null)
        {
            return EMPTY_ITERATOR;
        }

        return conversationContext.iterateConversations();
    }

    /**
     * Get the current conversation context.
     * <p>
     * In a simple Orchestra application this will always be a root conversation context.
     * When using a dialog/page-flow environment the context that is returned might have
     * a parent context.
     * <p>
     * Null is returned if there is no current conversationContext.
     */
    public ConversationContext getCurrentConversationContext()
    {
        Long ccid = findConversationContextId();
        if (ccid == null)
        {
            return null;
        }
        else
        {
            ConversationContext ctx = getConversationContext(ccid);
            if (ctx == null)
            {
                // Someone has perhaps used the back button to go back into a context
                // that has already ended. This simply will not work, so we should
                // throw an exception here.
                //
                // Or somebody might have just activated a bookmark. Unfortunately,
                // when someone bookmarks a page within an Orchestra app, the bookmark
                // will capture the contextId too.
                //
                // There is unfortunately no obvious way to tell these two actions apart.
                // So we cannot report an error here; instead, just return a null context
                // so that a new instance gets created - and hope that the page itself
                // detects the problem and reports an error if it needs conversation state
                // that does not exist.
                //
                // What we should do here *at least* is bump the nextConversationId value
                // to be greater than this value, so that we don't later try to allocate a
                // second conversation with the same id. Yes, evil users could pass a very
                // high value here and cause wraparound but that is really not a problem as
                // they can only screw themselves up.
                log.warn("ConversationContextId specified but context does not exist");
                synchronized(this)
                {
                    if (nextConversationContextId <= ccid.longValue())
                    {
                        nextConversationContextId = ccid.longValue() + 1;
                    }
                }
                return null;
            }
            return ctx;
        }
    }

    /**
     * Return the current ConversationContext for the current http session;
     * if none yet exists then a ConversationContext is created and configured
     * as the current context.
     * <p>
     * This is currently package-scoped because it is not clear that code
     * outside orchestra can have any use for this method. The only user
     * outside of this class is ConversationRequestParameterProvider.
     * 
     * @since 1.2
     */
    ConversationContext getOrCreateCurrentConversationContext()
    {
        Long ccid = getOrCreateConversationContextId();
        return getOrCreateConversationContext(ccid);
    }

    /**
     * Return true if there is a conversation context associated with the
     * current request.
     */
    public boolean hasConversationContext()
    {
        return getCurrentConversationContext() == null;
    }

    /**
     * Get the current root conversation context (aka the window conversation context).
     * <p>
     * Null is returned if it does not exist.
     * 
     * @since 1.2
     */
    public ConversationContext getCurrentRootConversationContext()
    {
        Long ccid = findConversationContextId();
        if (ccid == null)
        {
            return null;
        }

        synchronized (this)
        {
            ConversationContext conversationContext = getConversationContext(ccid);
            if (conversationContext == null)
            {
                return null;
            }
            else
            {
                return conversationContext.getRoot();
            }
        }
    }

    /**
     * Get the Messager used to inform the user about anomalies.
     * <p>
     * What instance is returned is controlled by the FrameworkAdapter. See
     * {@link org.apache.myfaces.orchestra.frameworkAdapter.FrameworkAdapter} for details.
     */
    public ConversationMessager getMessager()
    {
        return FrameworkAdapter.getCurrentInstance().getConversationMessager();
    }

    /**
     * Check the timeout for each conversation context, and all conversations
     * within those contexts.
     * <p>
     * If any conversation has not been accessed within its timeout period
     * then clear the context.
     * <p>
     * Invoke the checkTimeout method on each context so that any conversation
     * that has not been accessed within its timeout is invalidated.
     */
    protected void checkTimeouts()
    {
        Map.Entry[] contexts;
        synchronized (this)
        {
            contexts = new Map.Entry[conversationContexts.size()];
            conversationContexts.entrySet().toArray(contexts);
        }

        long checkTime = System.currentTimeMillis();

        for (int i = 0; i<contexts.length; i++)
        {
            Map.Entry context = contexts[i];

            ConversationContext conversationContext = (ConversationContext) context.getValue();
            if (conversationContext.hasChildren())
            {
                // Never time out contexts that have children. Let the children time out first...
                continue;
            }

            conversationContext.checkConversationTimeout();

            if (conversationContext.getTimeout() > -1 &&
                (conversationContext.getLastAccess() +
                conversationContext.getTimeout()) < checkTime)
            {
                if (log.isDebugEnabled())
                {
                    log.debug("end conversation context due to timeout: " + conversationContext.getId());
                }

                removeAndInvalidateConversationContext(conversationContext);
            }
        }
    }

    /**
     * @since 1.4
     */
    public void removeAndInvalidateAllConversationContexts()
    {
        ConversationContext[] contexts;
        synchronized (this)
        {
            contexts = new ConversationContext[conversationContexts.size()];
            conversationContexts.values().toArray(contexts);
        }

        for (int i = 0; i<contexts.length; i++)
        {
            ConversationContext context = contexts[i];
            removeAndInvalidateConversationContextAndChildren(context);
        }
    }

    private void removeAndInvalidateConversationContextAndChildren(ConversationContext conversationContext)
    {
        while (conversationContext.hasChildren())
        {
            // Get first child
            ConversationContext child = (ConversationContext) conversationContext.getChildren().iterator().next();

            // This call removes child from conversationContext.children
            removeAndInvalidateConversationContextAndChildren(child);
        }

        if (log.isDebugEnabled())
        {
            log.debug("end conversation context: " + conversationContext.getId());
        }

        removeAndInvalidateConversationContext(conversationContext);
    }

    private void writeObject(java.io.ObjectOutputStream out) throws IOException
    {
        // the conversation manager is not (yet) serializable, we just implement it
        // to make it work with distributed sessions
    }

    private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException
    {
        // nothing written, so nothing to read
    }

    private Object readResolve() throws ObjectStreamException
    {
        // Note: Returning null here is not a good idea (for Tomcat 6.0.16 at least). Null objects are
        // not permitted within an HttpSession; calling HttpSession.setAttribute(name, null) is defined as
        // removing the attribute. So returning null here when deserializing an object from the session
        // can cause problems.
        //
        // Note that nothing should have a reference to the ConversationManager *except* the entry
        // in the http session; all other code should look it up "on demand" via the getInstance
        // method rather than storing a reference to it. So we can do pretty much anything we like
        // here as long as the getInstance() method works correctly later. Thus:
        //  * returning null here is one option (getInstance just creates the item later) - except
        //    that tomcat doesn't like it.
        // * creating a new object instance that getInstance will later simply find and return will
        //   work - except that the actual type to create can be overridden via the dependency-injection
        //   config, and the FrameworkAdapter class that gives us access to that info is not available
        //   at the current time.
        //
        // To solve this, we use a hack: a special DUMMY object is returned (and therefore will be inserted
        // into the HTTP session under the ConversationManager key). The getInstance method then checks
        // for this dummy object, and treats it like NULL. Conveniently, it appears that the serialization
        // mechanism doesn't care if readResolve returns an object that is not a subclass of the one that
        // is being deserialized, so here we can return any old object (eg an Integer).
        //
        // An alternative would be to just remove the ConversationManager object from the http session
        // on passivate, so that this readResolve method is never called. However hopefully at some
        // future time we *will* get serialization for this class working nicely and then will need
        // to discard these serialization hacks; it is easier to do that when the hacks are all in
        // the same class.

        Log log = LogFactory.getLog(ConversationManager.class);
        log.debug("readResolve returning dummy ConversationManager object");
        return DUMMY;
    }
}
