/*
 * 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 org.apache.myfaces.orchestra.lib.OrchestraException;
import org.apache.myfaces.orchestra.lib._ReentrantLock;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
import java.util.Collection;

/**
 * A ConversationContext is a container for a set of conversations.
 * <p>
 * Normally there is only one ConversationContext per http session. However there can
 * be multiple instances if the user has multiple concurrent windows open into the same
 * webapp, using the ox:separateConversationContext or other similar mechanism.
 * <p>
 * Like the conversation class, a context can also have a timeout which will cause it
 * to be ended automatically if not accessed within the given period.
 */
public class ConversationContext
{
    private final Log log = LogFactory.getLog(ConversationContext.class);

    // This id is attached as a query parameter to every url rendered in a page
    // (forms and links) so that if that url is invoked then the request will
    // cause the same context to be used from the user's http session.
    //
    // This value is never null, but an Object is used to store it rather than
    // a primitive long because it is used as a key into a collection of
    // conversation contexts, and using an object here saves wrapping this
    // value in a new object instance every time it must be used as a key.
    private final Long id;

    // See addAttribute
    private final Map attributes = new TreeMap();

    // the parent conversation context
    private final ConversationContext parent;

    // The conversations held by this context, keyed by conversation name.
    private final Map conversations = new TreeMap();

    /**
     * A name associated with this context
     */
    private String name;

    // time at which this was last accessed, used for timeouts.
    private long lastAccess;

    // default timeout for contexts: 30 minutes.
    private long timeoutMillis = 30 * 60 * 1000;

    private final _ReentrantLock lock = new _ReentrantLock();

    // Map of all child contexts of this context, keyed by child.id
    private Map childContexts = new HashMap();

    /**
     * Constructor.
     */
    protected ConversationContext(long id)
    {
        this(null, id);
    }

    /**
     * Constructor.
     * 
     * @since 1.2
     */
    protected ConversationContext(ConversationContext parent, long id)
    {
        this.parent = parent;
        this.id = Long.valueOf(id);

        if (parent != null)
        {
            parent.addChild(this);
        }

        touch();
    }

    /**
     * Get the name associated to this context.
     * 
     * @since 1.2
     */
    public String getName()
    {
        return name;
    }

    /**
     * Assign a name to this context.
     * 
     * @since 1.2
     */
    public void setName(String name)
    {
        this.name = name;
    }

    /**
     * The conversation context id, unique within the current http session.
     */
    public long getId()
    {
        return id.longValue();
    }

    /**
     * The conversation context id, unique within the current http session.
     */
    public Long getIdAsLong()
    {
        return id;
    }

    /**
     * Return the parent conversation context (if any).
     * 
     * @since 1.2
     */
    public ConversationContext getParent()
    {
        return parent;
    }

    /**
     * @since 1.3
     */
    public void addChild(ConversationContext context)
    {
        childContexts.put(context.getIdAsLong(), context);
    }

    /**
     * @since 1.4
     */
    protected Collection getChildren()
    {
        return childContexts.values();
    }

    /**
     * @since 1.3
     */
    public void removeChild(ConversationContext context)
    {
        Object o = childContexts.remove(context.getIdAsLong());
        if (o != context)
        {
            // Sanity check failed: o is null, or o is a different object.
            // In either case, something is very wrong.
            throw new OrchestraException("Invalid call of removeChild");
        }
    }

    /**
     * @since 1.3
     */
    public boolean hasChildren()
    {
        return !childContexts.isEmpty();
    }

    /**
     * Mark this context as having been used.
     */
    protected void touch()
    {
        lastAccess = System.currentTimeMillis();

        if (getParent() != null)
        {
            getParent().touch();
        }
    }

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

    /**
     * Get the timeout after which this context will be closed.
     *
     * @see #setTimeout
     */
    public long getTimeout()
    {
        return timeoutMillis;
    }

    /**
     * Set the timeout after which this context will be closed.
     * <p>
     * A value of -1 means no timeout checking.
     */
    public void setTimeout(long timeoutMillis)
    {
        this.timeoutMillis = timeoutMillis;
    }

    /**
     * Invalidate all conversations within this context.
     * 
     * @deprecated Use the "invalidate" method instead.
     */
    protected void clear()
    {
        invalidate();
    }

    /**
     * Invalidate all conversations within this context.
     *
     * @since 1.3
     */
    protected void invalidate()
    {
        synchronized (this)
        {
            Conversation[] convArray = new Conversation[conversations.size()];
            conversations.values().toArray(convArray);

            for (int i = 0; i < convArray.length; i++)
            {
                Conversation conversation = convArray[i];
                conversation.invalidate();
            }

            conversations.clear();
        }
    }

    /**
     * Start a conversation if not already started.
     */
    protected Conversation startConversation(String name, ConversationFactory factory)
    {
        synchronized (this)
        {
            touch();
            Conversation conversation = (Conversation) conversations.get(name);
            if (conversation == null)
            {
                conversation = factory.createConversation(this, name);

                conversations.put(name, conversation);
            }
            return conversation;
        }
    }

    /**
     * Remove the conversation from this context.
     *
     * <p>Notice: It is assumed that the conversation has already been invalidated.</p>
     */
    protected void removeConversation(Conversation conversation)
    {
        synchronized (this)
        {
            touch();
            conversations.remove(conversation.getName());
        }
    }

    /**
     * Remove the conversation with the given name from this context.
     *
     * <p>Notice: Its assumed that the conversation has already been invalidated</p>
     */
    protected void removeConversation(String name)
    {
        synchronized (this)
        {
            touch();
            Conversation conversation = (Conversation) conversations.get(name);
            if (conversation != null)
            {
                removeConversation(conversation);
            }
        }
    }

    /**
     * Return true if there are one or more conversations in this context.
     */
    protected boolean hasConversations()
    {
        synchronized (this)
        {
            touch();
            return conversations.size() > 0;
        }
    }

    /**
     * Check if the given conversation exists.
     */
    protected boolean hasConversation(String name)
    {
        synchronized (this)
        {
            touch();
            return conversations.get(name) != null;
        }
    }

    /**
     * Get a conversation by name.
     * <p>
     * This looks only in the current context, not in any child contexts.
     */
    protected Conversation getConversation(String name)
    {
        synchronized (this)
        {
            touch();

            Conversation conv = (Conversation) conversations.get(name);
            if (conv != null)
            {
                conv.touch();
            }

            return conv;
        }
    }

    /**
     * Iterates over all the conversations in this context.
     * <p>
     * This does not include conversations in parent contexts.
     *
     * @return An iterator over a copy of the conversation list. It is safe to remove objects from
     * the conversation list while iterating, as the iterator refers to a different collection.
     */
    public Iterator iterateConversations()
    {
        synchronized (this)
        {
            touch();

            Conversation[] convs = (Conversation[]) conversations.values().toArray(
                    new Conversation[conversations.size()]);
            return Arrays.asList(convs).iterator();
        }
    }

    /**
     * Check the timeout for every conversation in this context.
     * <p>
     * This method does not check the timeout for this context object itself.
     */
    protected void checkConversationTimeout()
    {
        synchronized (this)
        {
            Conversation[] convArray = new Conversation[conversations.size()];
            conversations.values().toArray(convArray);

            for (int i = 0; i < convArray.length; i++)
            {
                Conversation conversation = convArray[i];

                ConversationTimeoutableAspect timeoutAspect =
                    (ConversationTimeoutableAspect)
                        conversation.getAspect(ConversationTimeoutableAspect.class);

                if (timeoutAspect != null && timeoutAspect.isTimeoutReached())
                {
                    if (log.isDebugEnabled())
                    {
                        log.debug("end conversation due to timeout: " + conversation.getName());
                    }

                    conversation.invalidate();
                }
            }
        }
    }

    /**
     * Add an attribute to the conversationContext.
     * <p>
     * A context provides a map into which any arbitrary objects can be stored. It
     * isn't a major feature of the context, but can occasionally be useful.
     */
    public void setAttribute(String name, Object attribute)
    {
        synchronized(attributes)
        {
            attributes.remove(name);
            attributes.put(name, attribute);
        }
    }

    /**
     * Check if this conversationContext holds a specific attribute.
     */
    public boolean hasAttribute(String name)
    {
        synchronized(attributes)
        {
            return attributes.containsKey(name);
        }
    }

    /**
     * Get a specific attribute.
     */
    public Object getAttribute(String name)
    {
        synchronized(attributes)
        {
            return attributes.get(name);
        }
    }

    /**
     * Remove an attribute from the conversationContext.
     */
    public Object removeAttribute(String name)
    {
        synchronized(attributes)
        {
            return attributes.remove(name);
        }
    }

    /**
     * Block until no other thread has this instance marked as reserved, then
     * mark the object as reserved for this thread.
     * <p>
     * It is safe to call this method multiple times.
     * <p>
     * If this method is called, then an equal number of calls to
     * unlockForCurrentThread <b>MUST</b> made, or this context object
     * will remain locked until the http session times out.
     * <p>
     * Note that this method may be called very early in the request processing
     * lifecycle, eg before a FacesContext exists for a JSF request.
     *
     * @since 1.1
     */
    public void lockInterruptablyForCurrentThread() throws InterruptedException
    {
        if (log.isDebugEnabled())
        {
            log.debug("Locking context " + this.id);
        }
        lock.lockInterruptibly();
    }

    /**
     * Block until no other thread has this instance marked as reserved, then
     * mark the object as reserved for this thread.
     * <p>
     * Note that this method may be called very late in the request processing
     * lifecycle, eg after a FacesContext has been destroyed for a JSF request.
     *
     * @since 1.1
     */
    public void unlockForCurrentThread()
    {
        if (log.isDebugEnabled())
        {
            log.debug("Unlocking context " + this.id);
        }
        lock.unlock();
    }

    /**
     * Return true if this object is currently locked by the calling thread.
     *
     * @since 1.1
     */
    public boolean isLockedForCurrentThread()
    {
        return lock.isHeldByCurrentThread();
    }

    /**
     * Get the root conversation context this conversation context is
     * associated with.
     * <p>
     * This is equivalent to calling getParent repeatedly until a context
     * with no parent is found.
     * 
     * @since 1.2
     */
    public ConversationContext getRoot()
    {
        ConversationContext cctx = this;
        while (cctx != null && cctx.getParent() != null)
        {
            cctx = getParent();
        }

        return cctx;
    }
}
