/*
 * Copyright 2004-2014 ICEsoft Technologies Canada Corp.
 *
 * Licensed 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.icefaces.impl.application;

import org.icefaces.bean.WindowDisposed;
import org.icefaces.util.EnvUtils;

import javax.annotation.PreDestroy;
import javax.el.ELContext;
import javax.faces.application.*;
import javax.faces.bean.ViewScoped;
import javax.faces.component.UIViewRoot;
import javax.faces.context.ExceptionHandler;
import javax.faces.context.ExceptionHandlerWrapper;
import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.faces.event.*;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.http.HttpSession;
import java.io.*;
import java.lang.reflect.Method;
import java.util.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;

public class WindowScopeManager extends SessionAwareResourceHandlerWrapper {
    public static final String SessionSynchronizationMonitor = WindowScopeManager.class.getName() + "$SessionSynchronizationMonitor";
    public static final String AssociatedWindowID = WindowScopeManager.class.getName() + "$WindowID";
    public static final String ScopeName = "window";
    private static final String ICEFACES_BEAN_DESTROY_RECORDER = "icefacesBeanDestroyRecorder";
    private static final Logger log = Logger.getLogger(WindowScopeManager.class.getName());
    private static final String seed = Integer.toString(new Random().nextInt(1000), 36);

    private static WindowScopeTracker tracker;

    static {
        boolean urlBasedWindowTracking = "url".equals(FacesContext.getCurrentInstance().getExternalContext().getInitParameter("javax.faces.CLIENT_WINDOW_MODE"));
        if (urlBasedWindowTracking) {
            tracker = new JSFURLWindowScopeTracker();
        } else {
            tracker = new TimeBasedWindowScopeTracker();
        }
    }

    private ResourceHandler wrapped;

    public WindowScopeManager(ResourceHandler wrapped) {
        this.wrapped = wrapped;
    }

    public ResourceHandler getWrapped() {
        return wrapped;
    }

    public void handleSessionAwareResourceRequest(FacesContext facesContext) throws IOException {
        wrapped.handleResourceRequest(facesContext);
    }

    public boolean isResourceRequest(FacesContext facesContext) {
        ExternalContext externalContext = facesContext.getExternalContext();
        Map parameters = externalContext.getRequestParameterMap();
        if (isDisposeWindowRequest(parameters)) {
            //force the running of the JSF lifecycle so that the registered phase listener has a chance to destroy
            //the @WindowDisposed annotated view scope beans
            //
            //avoid running JSF lifecycle when session is not yet created (for example when dispose window request is
            //received first after the application was restarted)
            return facesContext.getExternalContext().getSession(false) == null;
        }

        return super.isResourceRequest(facesContext);
    }

    public boolean isSessionAwareResourceRequest(FacesContext context) {
        return wrapped.isResourceRequest(context);
    }

    public static String lookupAssociatedWindowID(Map requestMap) {
        return (String) requestMap.get(AssociatedWindowID);
    }

    public static String determineWindowID(FacesContext context) {
        return tracker.setupScope(context);
    }

    public static ScopeMap lookupWindowScope(FacesContext context) {
        return tracker.lookupScope(context);
    }

    static Lock getSynchronizationObject(FacesContext context) {
        // ICE-7749: By default, in portals, the ExternalContext.getSessionMap() returns a map of
        // values that are in PortletSession.PORTLET_SCOPE.  But for the synchronization monitor we
        // are using here, it's initially put into PortletSession.APPLICATION_SCOPE so we need to
        // access it using our own session proxy which, with portlets, defaults to APPLICATION_SCOPE.
        HttpSession safeSession = EnvUtils.getSafeSession(context);
        if (safeSession == null) {
            //this method was called before the session was created (such as when application starts up)
            return null;
        }

        Object synchronizationMonitor = safeSession.getAttribute(SessionSynchronizationMonitor);
        if (synchronizationMonitor == null) {
            log.log(Level.FINE, "synchronization monitor not set by session listener");
            synchronizationMonitor = new ReentrantLock();
            safeSession.setAttribute(SessionSynchronizationMonitor, synchronizationMonitor);
        }
        return (Lock) synchronizationMonitor;
    }

    public static boolean isDisposeWindowRequest(Map parameters) {
        return "ice.dispose.window".equals(parameters.get("ice.submit.type"));
    }

    public static void disposeWindows(final HttpSession session) {
        // The strategy is to invoke @PreDestroy on as many applicable window scoped beans as possible and not to bail
        // out on the first fail.
        WindowState state = (WindowState) session.getAttribute(WindowScopeManager.class.getName());

        //ICE-9071: It's possible for a session to expire without a WindowScopeManager if the session was created and
        //invalidated outside of ICEfaces' scope.
        if (state == null) {
            return;
        }

        notifyPreDestroyForAll(state.windowScopedMaps.values());
        notifyPreDestroyForAll(state.disposedWindowScopedMaps);
    }

    static void notifyPreDestroyForAll(Collection<ScopeMap> scopeMaps) {
        for (final ScopeMap scopeMap : scopeMaps) {
            if (!scopeMap.isPreDestroyInvoked()) {
                try {
                    notifyPreDestroy(scopeMap.values());
                } finally {
                    scopeMap.preDestroyInvoked();
                }
            }
        }
    }

    static void notifyPreDestroy(Collection beans) {
        try {
            for (final Object bean : beans) {
                try {
                    callAnnotatedMethod(bean, PreDestroy.class);
                } catch (Exception exception) {
                    log.log(Level.FINE,
                            "An exception occurred while trying to invoke @PreDestroy on a window scoped bean: " +
                                    exception.getMessage());
                }
            }
        } catch (Exception exception) {
            log.log(Level.FINE,
                    "An exception occurred while trying to invoke @PreDestroy on window scoped beans: " +
                            exception.getMessage());
        }
    }

    static void disposeViewScopeBeans(FacesContext facesContext) {
        ExceptionHandler oldHandler = facesContext.getExceptionHandler();
        //all Exceptions will be ignored since no further action can be taken
        //during window disposal
        facesContext.setExceptionHandler(new DiscardingExceptionHandler(oldHandler));
        UIViewRoot viewRoot = facesContext.getViewRoot();
        if (null == viewRoot) {
            return;
        }
        Map viewMap = viewRoot.getViewMap();
        //additional test necessary when running with Myfaces 2.1.1* or Mojarra 2.2.1*
        IcefacesBeanDestroyRecorder beanDestroyRecorder = (IcefacesBeanDestroyRecorder) viewMap.get(ICEFACES_BEAN_DESTROY_RECORDER);

        //clearing the view map triggers a PreDestroyViewMapEvent captured by ViewScopeManager who in turn
        //invokes the InjectionProvider implementation to dispose the view scope beans
        if (!beanDestroyRecorder.isDisposed()) {
            viewMap.clear();
        }

        //invoke @PreDestroy annotated method directly in case there's no InjectionProvider implementation configured
        if (!beanDestroyRecorder.isDisposed()) {
            Iterator keys = viewMap.keySet().iterator();
            while (keys.hasNext()) {
                Object key = keys.next();
                Object object = viewMap.get(key);
                if (object.getClass().isAnnotationPresent(WindowDisposed.class)) {
                    keys.remove();
                    callAnnotatedMethod(object, PreDestroy.class);
                    if (log.isLoggable(Level.FINE)) {
                        log.log(Level.FINE, "Closing window disposed ViewScoped bean " + key);
                    }
                }
            }
        }
        facesContext.setExceptionHandler(oldHandler);
    }

    static void callAnnotatedMethod(Object object, Class annotation) {
        Class theClass = object.getClass();
        try {
            while (null != theClass) {
                Method[] methods = object.getClass().getDeclaredMethods();
                for (Method method : methods) {
                    if (method.isAnnotationPresent(annotation)) {
                        method.setAccessible(true);
                        method.invoke(object);
                        return;
                    }
                }
                theClass = theClass.getSuperclass();
            }
        } catch (Exception e) {
            log.log(Level.WARNING, "Failed to invoke" + annotation + " on " + theClass, e);
        }
    }

    public static void sessionCreated(HttpSession session) {
        session.setAttribute(SessionSynchronizationMonitor, new ReentrantLock());
    }

    public static class ScopeMap extends HashMap {
        String id;
        private boolean preDestroyInvoked;

        public ScopeMap(String id, FacesContext facesContext) {
            this.id = id;
            boolean processingEvents = facesContext.isProcessingEvents();
            try {
                facesContext.setProcessingEvents(true);
                ScopeContext context = new ScopeContext(ScopeName, this);
                facesContext.getApplication().publishEvent(facesContext, PostConstructCustomScopeEvent.class, context);
            } finally {
                facesContext.setProcessingEvents(processingEvents);
            }
        }

        public String getId() {
            return id;
        }

        public ScopeMap(FacesContext facesContext) {
            this(generateID(), facesContext);
        }

        public void activate(WindowState state) {
            state.windowScopedMaps.put(getId(), this);
        }

        public void disactivate(WindowState state) {
            state.disposedWindowScopedMaps.addLast(state.windowScopedMaps.remove(getId()));
        }

        public void preDestroyInvoked() {
            preDestroyInvoked = true;
        }

        public boolean isPreDestroyInvoked() {
            return preDestroyInvoked;
        }

        private static synchronized String generateID() {
            return seed + Long.toString(System.currentTimeMillis(), 36);
        }
    }

    public static void associateWindowIDToRequest(String id, FacesContext facesContext) {
        associateWindowID(id, facesContext.getExternalContext().getRequestMap());
    }

    static void associateWindowID(String id, Map requestMap) {
        requestMap.put(WindowScopeManager.class.getName(), id);
    }

    static String getAssociatedWindowID(Map requestMap) {
        return (String) requestMap.get(WindowScopeManager.class.getName());
    }

    static WindowState getState(FacesContext context) {
        ExternalContext externalContext = context.getExternalContext();
        Object session = externalContext.getSession(false);
        if (session != null) {
            return EnvUtils.instanceofPortletSession(session) ?
                    getPortletState(context, session) : getServletState(context, session);
        } else {
            return null;
        }
    }

    private static WindowState getServletState(FacesContext context, Object session) {
        HttpSession servletSession = (HttpSession) session;
        WindowState state = (WindowState) servletSession.getAttribute(WindowScopeManager.class.getName());
        if (state == null) {
            state = new WindowState(EnvUtils.getWindowScopeExpiration(context));
            servletSession.setAttribute(WindowScopeManager.class.getName(), state);
        }
        return state;
    }

    //portlet session is accessed in a separate method to avoid ClassNotFound errors in servlet environment
    private static WindowState getPortletState(FacesContext context, Object session) {
        javax.portlet.PortletSession portletSession = (javax.portlet.PortletSession) session;
        WindowState state = (WindowState) portletSession.getAttribute(WindowScopeManager.class.getName(), javax.portlet.PortletSession.APPLICATION_SCOPE);
        if (state == null) {
            state = new WindowState(EnvUtils.getWindowScopeExpiration(context));
            portletSession.setAttribute(WindowScopeManager.class.getName(), state, javax.portlet.PortletSession.APPLICATION_SCOPE);
        }
        return state;
    }

    static class DiscardingExceptionHandler extends ExceptionHandlerWrapper {
        ExceptionHandler wrapped;

        public DiscardingExceptionHandler(ExceptionHandler wrapped) {
            this.wrapped = wrapped;
        }

        public void processEvent(SystemEvent exceptionQueuedEvent) {
            Throwable throwable = ((ExceptionQueuedEvent) exceptionQueuedEvent)
                    .getContext().getException();
            if (log.isLoggable(Level.FINE)) {
                log.log(Level.FINE, "Exception during window disposal " + throwable);
            }
        }

        public javax.faces.context.ExceptionHandler getWrapped() {
            return wrapped;
        }
    }

    public static class SetupTimer implements ServletContextListener {
        private Timer timer;

        public void contextInitialized(ServletContextEvent servletContextEvent) {
            timer = new Timer("WindowScopeManager timer", true);
            servletContextEvent.getServletContext().setAttribute(SetupTimer.class.getName(), timer);
        }

        public void contextDestroyed(ServletContextEvent servletContextEvent) {
            timer.cancel();
        }

        public static Timer get(FacesContext context) {
            return (Timer) context.getExternalContext().getApplicationMap().get(SetupTimer.class.getName());
        }
    }

    public static class DetermineOrDisposeScope implements PhaseListener {

        public DetermineOrDisposeScope() {
        }

        public void afterPhase(final PhaseEvent event) {
        }

        public void beforePhase(final PhaseEvent event) {
            FacesContext context = event.getFacesContext();
            if (event.getPhaseId() == PhaseId.RESTORE_VIEW) {
                tracker.setupScope(context);
            }

            ExternalContext externalContext = context.getExternalContext();
            Map parameters = externalContext.getRequestParameterMap();
            if (event.getPhaseId() == PhaseId.APPLY_REQUEST_VALUES && isDisposeWindowRequest(parameters)) {
                //shortcut the lifecycle to avoid running it with certain parts discarded or disposed
                context.responseComplete();
                tracker.disposeScope(context);
            }
        }

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

    public static class FixViewScopedBeansOnRedirect extends ConfigurableNavigationHandler {
        private NavigationHandler handler;

        public FixViewScopedBeansOnRedirect(NavigationHandler handler) {
            this.handler = handler;
        }

        public void handleNavigation(FacesContext context, String fromAction, String outcome) {
            final NavigationCase navigationCase = getNavigationCase(context, fromAction, outcome);
            boolean redirect = navigationCase != null && navigationCase.isRedirect();

            Map<String, Object> beans = Collections.EMPTY_MAP;
            if (redirect) {
                //save bean names that are disposed by JSF's view bean manager
                beans = new HashMap();
                Set<Map.Entry<String, Object>> viewMapEntries = context.getViewRoot().getViewMap().entrySet();
                for (Map.Entry<String, Object> viewMapEntry : viewMapEntries) {
                    if (viewMapEntry.getValue().getClass().isAnnotationPresent(ViewScoped.class)) {
                        beans.put(viewMapEntry.getKey(), viewMapEntry.getValue());
                    }
                }
            }

            handler.handleNavigation(context, fromAction, outcome);

            if (redirect && !beans.isEmpty()) {
                //add back no-op beans to avoid having them re-initialized when dispose-window request is sent
                Map viewMap = context.getViewRoot().getViewMap();
                viewMap.putAll(beans);
            }
        }

        public NavigationCase getNavigationCase(FacesContext context, String fromAction, String outcome) {
            if (handler instanceof ConfigurableNavigationHandler) {
                return ((ConfigurableNavigationHandler) handler).getNavigationCase(context, fromAction, outcome);
            } else {
                log.warning(handler.toString() + " is not a ConfigurableNavigationHandler");
            }

            return null;
        }

        public Map<String, Set<NavigationCase>> getNavigationCases() {
            if (handler instanceof ConfigurableNavigationHandler) {
                return ((ConfigurableNavigationHandler) handler).getNavigationCases();
            } else {
                log.warning(handler.toString() + " is not a ConfigurableNavigationHandler");
            }
            return null;
        }
    }

    public static class SaveScopeState implements PhaseListener {

        public void afterPhase(final PhaseEvent event) {
            FacesContext context = FacesContext.getCurrentInstance();
            try {
                ExternalContext externalContext = context.getExternalContext();
                Object session = externalContext.getSession(false);
                if (session != null) {
                    if (EnvUtils.instanceofPortletSession(session)) {
                        javax.portlet.PortletSession portletSession = (javax.portlet.PortletSession) session;
                        Object state = portletSession.getAttribute(WindowScopeManager.class.getName(), javax.portlet.PortletSession.APPLICATION_SCOPE);
                        if (state != null) {
                            portletSession.setAttribute(WindowScopeManager.class.getName(), state, javax.portlet.PortletSession.APPLICATION_SCOPE);
                        }
                    } else {
                        HttpSession servletSession = (HttpSession) session;
                        Object state = servletSession.getAttribute(WindowScopeManager.class.getName());
                        if (state != null) {
                            servletSession.setAttribute(WindowScopeManager.class.getName(), state);
                        }
                    }
                }
            } catch (Exception e) {
                log.log(Level.FINE, "Unable to reset WindowScope", e);
            }
        }

        public void beforePhase(final PhaseEvent event) {
            //force the creation of 'icefacesBeanDestroyRecorder' bean
            FacesContext context = FacesContext.getCurrentInstance();
            ELContext elContext = context.getELContext();
            elContext.getELResolver().getValue(elContext, null, ICEFACES_BEAN_DESTROY_RECORDER);
        }

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