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

import java.util.Iterator;
import java.util.Set;

import javax.faces.component.UIViewRoot;
import javax.faces.event.PhaseEvent;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseListener;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.myfaces.orchestra.conversation.AccessScopeManager;
import org.apache.myfaces.orchestra.conversation.Conversation;
import org.apache.myfaces.orchestra.conversation.ConversationAccessLifetimeAspect;
import org.apache.myfaces.orchestra.conversation.ConversationManager;

/**
 * Handle access-scoped conversations.
 * <p>
 * After a <i>new view</i> has been rendered, delete any access-scope conversations for which no
 * bean in that scope has been accessed <i>during the render phase</i> of the request.
 * <p>
 * This allows a page which handles a postback to store data into beans in an access-scoped
 * conversation, then navigate to a new page. That information is available for the new
 * page during its rendering. And if that data is referenced, it will remain around
 * until the user does a GET request, or a postback that causes navigation again. Then
 * following the rendering of that new target page, any access-scoped conversations will be
 * discarded except for those that the new target page references.
 * <p>
 * Any access-scoped conversations that a page was using, but which the new page does NOT use
 * are therefore automatically cleaned up at the earliest possibility - after rendering of the
 * new page has completed.
 * <p>
 * Note: When a "master" and "detail" page pair exist, that navigating master->detail->master->detail
 * correctly uses a fresh conversation for the second call to the detail page (and not reuse the
 * access-scoped data from the first call). By only counting accesses during the render phase, this
 * works correctly.
 * <p>
 * Note: Access-scoped conversations must be preserved when AJAX calls cause only
 * part of a page to be processed, and must be preserved when conversion/validation failure
 * cause reads of the values of input components to be skipped. By deleting unaccessed
 * conversations only after the <i>first</i> render, this happens automatically.
 * <p>
 * Note: If a view happens to want its postbacks handled by a bean in conversation A,
 * but the render phase never references anything in that conversation, then the
 * conversation will be effectively request-scoped. This is not expected to be a
 * problem in practice as it would be a pretty odd view that has stateful event
 * handling but either renders nothing or fetches its data from somewhere other 
 * than the same conversation. If such a case is necessary, the view can be modified
 * to "ping" the conversation in order to keep it active via something like an
 * h:outputText with rendered="#{backingBean.class is null}" (which always resolves
 * to false, ie not rendered, but does force a method-invocation on the backingBean
 * instance). Alternatively, a manual-scoped conversation can be used.
 * <p>
 * Note: If FacesContext.responseComplete is called during processing of a postback,
 * then no phase-listeners for the RENDER_RESPONSE phase are executed. And any navigation
 * rule that specifies "redirect" causes responseComplete to be invoked. Therefore
 * access-scoped beans are not cleaned up immediately. However the view being
 * redirected to always runs its "render" phase only, no postback. The effect, 
 * therefore, is exactly the same as when an internal forward is performed to
 * the same view: in both cases, the access-scoped beans are kept if the next view
 * refers to them, and discarded otherwise. 
 * <p>
 * Note: Some AJAX libraries effectively do their own "rendering" pass from within
 * a custom PhaseListener, during the beforePhase for RENDER_RESPONSE. This could
 * have undesirable effects on Orchestra - except that for all AJAX requests, the
 * viewRoot restored during RESTORE_VIEW will be the same viewRoot used during
 * render phase - so this PhaseListener will ignore the request anyway.
 * <p>
 * Backwards-compatibility note: The behaviour of this class has changed between
 * releases 1.2 and 1.3. In earlier releases, the access-scope checking ran on every
 * request (not just GET or navigation). Suppose a bean is in its own access-scoped
 * conversation, and the only reference to that bean is from a component that is
 * rendered or not depending upon a checkbox editable by the user. In the old version,
 * hiding the component would cause the access-scoped conversation to be discarded
 * (not accessed), while the current code will not discard it. The new behaviour does
 * fix a couple of bugs: access-scoped conversations discarded during AJAX requests
 * and after conversion/validation failure.
 * 
 * @since 1.1
 */
public class AccessScopePhaseListener implements PhaseListener
{
    private static final long serialVersionUID = 1L;
    private final Log log = LogFactory.getLog(AccessScopePhaseListener.class);

    private static final String OLD_VIEW_KEY = AccessScopePhaseListener.class.getName() + ":oldView";

    public PhaseId getPhaseId()
    {
        return PhaseId.ANY_PHASE;
    }

    public void beforePhase(PhaseEvent event)
    {
        PhaseId pid = event.getPhaseId();
        if (pid == PhaseId.RENDER_RESPONSE)
        {
            doBeforeRenderResponse(event);
        }
    }

    public void afterPhase(PhaseEvent event)
    {
        PhaseId pid = event.getPhaseId();
        if (pid == PhaseId.RESTORE_VIEW)
        {
            doAfterRestoreView(event);
        }
        else if (pid == PhaseId.RENDER_RESPONSE)
        {
            doAfterRenderResponse(event);
        }
    }

    /**
     * Handle "afterPhase" callback for RESTORE_VIEW phase.
     * 
     * @since 1.3
     */
    private void doAfterRestoreView(PhaseEvent event)
    {
        javax.faces.context.FacesContext fc = event.getFacesContext();
        UIViewRoot oldViewRoot = fc.getViewRoot();
        if ((oldViewRoot != null) && fc.getRenderResponse())
        {
            // No view was restored; instead the viewRoot that FacesContext just returned
            // is a *newly created* view that should be rendered, not a postback to be processed.
            // In this case, save null as the "old" view to indicate that no view was restored,
            // which will trigger the access-scope checking after rendering is complete.
            oldViewRoot = null;
        }
        fc.getExternalContext().getRequestMap().put(OLD_VIEW_KEY, oldViewRoot);
    }

    /**
     * Handle "beforePhase" callback for RENDER_RESPONSE phase.
     * 
     * @since 1.3
     */
    private void doBeforeRenderResponse(PhaseEvent event)
    {
        AccessScopeManager accessManager = AccessScopeManager.getInstance();
        accessManager.beginRecording();
    }

    /**
     * Handle "afterPhase" callback for RENDER_RESPONSE phase.
     * 
     * @since 1.3
     */
    private void doAfterRenderResponse(PhaseEvent event)
    {
        javax.faces.context.FacesContext fc = event.getFacesContext();
        UIViewRoot viewRoot = fc.getViewRoot();
        UIViewRoot oldViewRoot = (UIViewRoot) fc.getExternalContext().getRequestMap().get(OLD_VIEW_KEY);
        if (viewRoot != oldViewRoot)
        {
            // Either this is a GET request (oldViewRoot is null) or this is a postback which
            // triggered a navigation (oldViewRoot is not null, but is a different instance).
            // In these cases (and only in these cases) we want to discard unaccessed conversations at
            // the end of the render phase.
            //
            // There are reasons why it is not a good idea to run the invalidation check
            // on every request:
            // (a) it doesn't work well with AJAX requests; an ajax request that only accesses
            //    part of the page should not cause access-scoped conversations to be discarded.
            // (b) on conversion or validation failure, conversations that are only referenced
            //    via the "value" attribute of an input component will not be accessed because
            //    the "submittedValue" for the component is used rather than fetching the value
            //    from the backing bean.
            // (c) running each time is somewhat inefficient
            //
            // Note that this means that an access-scoped conversation will continue to live
            // even when the components that reference it are not rendered, ie it was not
            // technically "accessed" during a request.
            invalidateAccessScopedConversations(event.getFacesContext().getViewRoot().getViewId());
        }
    }

    /**
     * Invalidates any conversation with aspect {@link ConversationAccessLifetimeAspect}
     * which has not been accessed during a http request
     */
    protected void invalidateAccessScopedConversations(String viewId)
    {
        AccessScopeManager accessManager = AccessScopeManager.getInstance();
        if (accessManager.isIgnoreRequest())
        {
            return;
        }

        if (accessManager.getAccessScopeManagerConfiguration() != null)
        {
            Set ignoredViewIds = accessManager.getAccessScopeManagerConfiguration().getIgnoreViewIds();
            if (ignoredViewIds != null && ignoredViewIds.contains(viewId))
            {
                // The scope configuration has explicitly stated that no conversations should be
                // terminated when processing this specific view, so just return.
                // 
                // Special "ignored views" are useful when dealing with things like nested
                // frames within a page that periodically refresh themselves while the "main"
                // part of the page remains unsubmitted.
                return;
            }
        }

        ConversationManager conversationManager = ConversationManager.getInstance(false);
        if (conversationManager == null)
        {
            return;
        }

        boolean isDebug = log.isDebugEnabled();
        Iterator iterConversations = conversationManager.iterateConversations();
        while (iterConversations.hasNext())
        {
            Conversation conversation = (Conversation) iterConversations.next();
            
            // This conversation has "access" scope if it has an attached Aspect
            // of type ConversationAccessLifetimeAspect. All other conversations
            // are not access-scoped and should be ignored here.
            ConversationAccessLifetimeAspect aspect =
                (ConversationAccessLifetimeAspect)
                    conversation.getAspect(ConversationAccessLifetimeAspect.class);

            if (aspect != null)
            {
                if (aspect.isAccessed())
                {
                    if (isDebug)
                    {
                        log.debug(
                            "Not clearing accessed conversation " + conversation.getName()
                            + " after rendering view " + viewId);
                    }
                }
                else
                {
                    if (isDebug)
                    {
                        log.debug(
                            "Clearing access-scoped conversation " + conversation.getName()
                            + " after rendering view " + viewId);
                    }
                    conversation.invalidate();
                }
            }
        }
    }
}
