/*
 * Copyright (c) 2008-2016 Haulmont.
 *
 * 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 com.haulmont.cuba.web.sys;

import com.google.common.hash.HashCode;
import com.haulmont.cuba.core.global.*;
import com.haulmont.cuba.core.sys.AppContext;
import com.haulmont.cuba.core.sys.SecurityContext;
import com.haulmont.cuba.core.sys.logging.LogMdc;
import com.haulmont.cuba.security.global.UserSession;
import com.haulmont.cuba.web.App;
import com.haulmont.cuba.web.WebConfig;
import com.haulmont.cuba.web.auth.WebAuthConfig;
import com.haulmont.cuba.web.sys.events.WebSessionDestroyedEvent;
import com.haulmont.cuba.web.sys.events.WebSessionInitializedEvent;
import com.haulmont.cuba.web.theme.ThemeVariantsProvider;
import com.haulmont.cuba.web.widgets.CubaFileUpload;
import com.vaadin.server.*;
import com.vaadin.server.communication.*;
import com.vaadin.ui.Component;
import elemental.json.Json;
import elemental.json.JsonArray;
import elemental.json.JsonObject;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static com.google.common.hash.Hashing.md5;

public class CubaVaadinServletService extends VaadinServletService
        implements AtmospherePushConnection.UidlWriterFactory {

    private static final Logger log = LoggerFactory.getLogger(CubaVaadinServletService.class);

    protected WebConfig webConfig;
    protected WebAuthConfig webAuthConfig;

    protected boolean testMode;
    protected boolean performanceTestMode;

    protected Events events;
    protected Messages messages;

    public CubaVaadinServletService(VaadinServlet servlet, DeploymentConfiguration deploymentConfiguration)
            throws ServiceException {
        super(servlet, deploymentConfiguration);

        this.events = AppBeans.get(Events.NAME);

        Configuration configuration = AppBeans.get(Configuration.NAME);
        webConfig = configuration.getConfig(WebConfig.class);
        webAuthConfig = configuration.getConfig(WebAuthConfig.class);
        testMode = configuration.getConfig(GlobalConfig.class).getTestMode();
        performanceTestMode = configuration.getConfig(GlobalConfig.class).getPerformanceTestMode();

        this.messages = AppBeans.get(Messages.NAME);

        addSessionInitListener(event -> {
            WrappedSession wrappedSession = event.getSession().getSession();
            wrappedSession.setMaxInactiveInterval(webConfig.getHttpSessionExpirationTimeoutSec());

            HttpSession httpSession = wrappedSession instanceof WrappedHttpSession ?
                    ((WrappedHttpSession) wrappedSession).getHttpSession() : null;

            log.debug("HttpSession {} initialized, timeout={}sec",
                    httpSession, wrappedSession.getMaxInactiveInterval());

            events.publish(new WebSessionInitializedEvent(event.getSession()));
        });

        addSessionDestroyListener(event -> {
            WrappedSession wrappedSession = event.getSession().getSession();
            HttpSession httpSession = wrappedSession instanceof WrappedHttpSession ?
                    ((WrappedHttpSession) wrappedSession).getHttpSession() : null;

            log.debug("HttpSession destroyed: {}", httpSession);
            App app = event.getSession().getAttribute(App.class);
            if (app != null) {
                app.cleanupBackgroundTasks();
            }

            events.publish(new WebSessionDestroyedEvent(event.getSession()));
        });

        setSystemMessagesProvider(systemMessagesInfo -> {
            Locale locale = systemMessagesInfo.getLocale();
            Locale defaultLocale = messages.getTools().getDefaultLocale();
            Locale actualLocale = locale;
            if (defaultLocale != null
                    && !Objects.equals(locale, defaultLocale)) {
                log.debug("Request and default locales are not matched. Default locale will be used");
                actualLocale = defaultLocale;
            }

            CustomizedSystemMessages msgs = new CustomizedSystemMessages();

            if (AppContext.isStarted()) {
                try {
                    msgs.setInternalErrorCaption(messages.getMainMessage("internalErrorCaption", actualLocale));
                    msgs.setInternalErrorMessage(messages.getMainMessage("internalErrorMessage", actualLocale));

                    msgs.setCommunicationErrorCaption(messages.getMainMessage("communicationErrorCaption", actualLocale));
                    msgs.setCommunicationErrorMessage(messages.getMainMessage("communicationErrorMessage", actualLocale));

                    msgs.setSessionExpiredCaption(messages.getMainMessage("sessionExpiredErrorCaption", actualLocale));
                    msgs.setSessionExpiredMessage(messages.getMainMessage("sessionExpiredErrorMessage", actualLocale));
                } catch (Exception e) {
                    log.error("Unable to set system messages", e);
                    throw new RuntimeException("Unable to set system messages. " +
                            "It usually happens when the middleware web application is not responding due to " +
                            "errors on start. See logs for details.", e);
                }
            }

            String redirectUri;
            if (RequestContext.get() != null) {
                HttpServletRequest request = RequestContext.get().getRequest();
                redirectUri = StringUtils.replace(request.getRequestURI(), "/UIDL", "");
            } else {
                String webContext = AppContext.getProperty("cuba.webContextName");
                redirectUri = "/" + webContext;
            }

            msgs.setInternalErrorURL(redirectUri + "?restartApp");

            return msgs;
        });
    }

    @Override
    public String getConfiguredTheme(VaadinRequest request) {
        return webConfig.getAppWindowTheme();
    }

    @Override
    protected List<RequestHandler> createRequestHandlers() throws ServiceException {
        List<RequestHandler> requestHandlers = super.createRequestHandlers();

        List<RequestHandler> cubaRequestHandlers = new ArrayList<>();

        ServletContext servletContext = getServlet().getServletContext();

        for (RequestHandler handler : requestHandlers) {
            if (handler instanceof UidlRequestHandler) {
                cubaRequestHandlers.add(new CubaUidlRequestHandler(servletContext));
            } else if (handler instanceof PublishedFileHandler) {
                // replace PublishedFileHandler with CubaPublishedFileHandler
                // for support resources from VAADIN directory
                cubaRequestHandlers.add(new CubaPublishedFileHandler());
            } else if (handler instanceof ServletBootstrapHandler) {
                // replace ServletBootstrapHandler with CubaApplicationBootstrapHandler
                cubaRequestHandlers.add(new CubaServletBootstrapHandler());
            } else if (handler instanceof HeartbeatHandler) {
                // replace HeartbeatHandler with CubaHeartbeatHandler
                cubaRequestHandlers.add(new CubaHeartbeatHandler());
            } else if (handler instanceof FileUploadHandler) {
                // add support for jquery file upload
                cubaRequestHandlers.add(handler);
                cubaRequestHandlers.add(new CubaFileUploadHandler());
            } else if (handler instanceof UnsupportedBrowserHandler) {
                cubaRequestHandlers.add(new CubaUnsupportedBrowserHandler());
            } else if (handler instanceof ServletUIInitHandler) {
                cubaRequestHandlers.add(new CubaServletUIInitHandler(servletContext));
            } else if (handler instanceof PushRequestHandler) {
                PushHandler pushHandler = ((PushRequestHandler) handler).getPushHandler();
                pushHandler.setLongPollingSuspendTimeout(webConfig.getPushLongPollingSuspendTimeoutMs());

                cubaRequestHandlers.add(handler);
            } else {
                cubaRequestHandlers.add(handler);
            }
        }

        cubaRequestHandlers.add(new CubaWebJarsHandler(servletContext));

        return cubaRequestHandlers;
    }

    @Override
    public UidlWriter createUidlWriter() {
        return new CubaUidlWriter(getServlet().getServletContext());
    }

    // Add ability to load JS and CSS resources from VAADIN directory
    protected static class CubaPublishedFileHandler extends PublishedFileHandler {
        @Override
        protected InputStream getApplicationResourceAsStream(Class<?> contextClass, String fileName) {
            ServletContext servletContext = VaadinServlet.getCurrent().getServletContext();
            return servletContext.getResourceAsStream("/VAADIN/" + fileName);
        }
    }

    // Add support for CubaFileUpload component with XHR upload mechanism
    protected static class CubaFileUploadHandler extends FileUploadHandler {

        private final Logger log = LoggerFactory.getLogger(CubaFileUploadHandler.class);

        @Override
        protected boolean isSuitableUploadComponent(ClientConnector source) {
            if (!(source instanceof CubaFileUpload)) {
                // this is not jquery upload request
                return false;
            }

            log.trace("Uploading file using jquery file upload mechanism");

            return true;
        }

        @Override
        protected void sendUploadResponse(VaadinRequest request, VaadinResponse response,
                                          String fileName, long contentLength) throws IOException {
            JsonArray json = Json.createArray();
            JsonObject fileInfo = Json.createObject();
            fileInfo.put("name", fileName);
            fileInfo.put("size", contentLength);

            // just fake addresses and parameters
            fileInfo.put("url", fileName);
            fileInfo.put("thumbnail_url", fileName);
            fileInfo.put("delete_url", fileName);
            fileInfo.put("delete_type", "POST");
            json.set(0, fileInfo);

            PrintWriter writer = response.getWriter();
            writer.write(json.toJson());
            writer.close();
        }
    }

    protected String getThemeVariants() {
        List<String> themeVariants = findAndEscapeThemeVariants();
        return StringUtils.join(themeVariants, " ");
    }

    public List<String> findAndEscapeThemeVariants() {
        if (AppBeans.containsBean(ThemeVariantsProvider.NAME)) {
            ThemeVariantsProvider themeVariantsProvider = AppBeans.get(ThemeVariantsProvider.NAME);
            List<String> themeVariants = themeVariantsProvider.getThemeVariants();
            if (!themeVariants.isEmpty()) {
                List<String> strippedVariants = new ArrayList<>(themeVariants.size());
                for (String variant : themeVariants) {
                    // XSS preventation, theme variants shouldn't contain special chars anyway.
                    // The servlet denies them via url parameter.
                    String themeVariant = VaadinServlet.stripSpecialChars(variant);
                    strippedVariants.add(themeVariant);
                }

                return strippedVariants;
            }
        }

        return Collections.emptyList();
    }

    /**
     * Add ability to redirect to base application URL if we have unparsable path tail
     */
    protected static class CubaServletBootstrapHandler extends ServletBootstrapHandler {
        @Override
        public boolean handleRequest(VaadinSession session, VaadinRequest request, VaadinResponse response)
                throws IOException {
            String requestPath = request.getPathInfo();

            // redirect to base URL if we have unparsable path tail
            if (!Objects.equals("/", requestPath)) {
                response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
                response.setHeader("Location", request.getContextPath());

                return true;
            }

            return super.handleRequest(session, request, response);
        }

        @Override
        protected String getMainDivAdditionalClassName(BootstrapContext context) {
            VaadinRequest request = context.getRequest();
            VaadinService service = request.getService();
            if (service instanceof CubaVaadinServletService) {
                return ((CubaVaadinServletService) service).getThemeVariants();
            }

            return null;
        }
    }

    // Add ability to handle heartbeats in App
    protected static class CubaHeartbeatHandler extends HeartbeatHandler {
        private final Logger log = LoggerFactory.getLogger(CubaHeartbeatHandler.class);

        @Override
        public boolean synchronizedHandleRequest(VaadinSession session, VaadinRequest request, VaadinResponse response)
                throws IOException {
            boolean result = super.synchronizedHandleRequest(session, request, response);

            if (log.isTraceEnabled()) {
                log.trace("Handle heartbeat {} {}", request.getRemoteHost(), request.getRemoteAddr());
            }

            if (result && App.isBound()) {
                App.getInstance().onHeartbeat();
            }

            return result;
        }
    }

    @Override
    protected VaadinSession createVaadinSession(VaadinRequest request) throws ServiceException {
        if (performanceTestMode) {
            return new TestVaadinSession(this);
        } else {
            return super.createVaadinSession(request);
        }
    }

    @Override
    protected void lockSession(WrappedSession wrappedSession) {
        Lock lock = getSessionLock(wrappedSession);
        if (lock == null) {
            /*
             * No lock found in the session attribute. Ensure only one lock is
             * created and used by everybody by doing double checked locking.
             * Assumes there is a memory barrier for the attribute (i.e. that
             * the CPU flushes its caches and reads the value directly from main
             * memory).
             */
            synchronized (VaadinService.class) {
                lock = getSessionLock(wrappedSession);
                if (lock == null) {
                    lock = new CubaReentrantLock();
                    setSessionLock(wrappedSession, lock);
                }
            }
        }
        lock.lock();

        try {
            // Someone might have invalidated the session between fetching the
            // lock and acquiring it. Guard for this by calling a method that's
            // specified to throw IllegalStateException if invalidated
            // (#12282)
            wrappedSession.getAttribute(getLockAttributeName());
        } catch (IllegalStateException e) {
            lock.unlock();
            throw e;
        }
    }

    /**
     * Associates the given lock with this service and the given wrapped
     * session. This method should not be called more than once when the lock is
     * initialized for the session.
     *
     * @param wrappedSession The wrapped session the lock is associated with
     * @param lock           The lock object
     * @see #getSessionLock(WrappedSession)
     */
    private void setSessionLock(WrappedSession wrappedSession, Lock lock) {
        if (wrappedSession == null) {
            throw new IllegalArgumentException(
                    "Can't set a lock for a null session");
        }
        Object currentSessionLock = wrappedSession
                .getAttribute(getLockAttributeName());
        assert (currentSessionLock == null
                || currentSessionLock == lock) : "Changing the lock for a session is not allowed";

        wrappedSession.setAttribute(getLockAttributeName(), lock);
    }

    /**
     * Returns the name used to store the lock in the HTTP session.
     *
     * @return The attribute name for the lock
     */
    private String getLockAttributeName() {
        return getServiceName() + ".lock";
    }

    /**
     * Generates non-random IDs for components, used for performance testing.
     */
    protected static class TestVaadinSession extends VaadinSession {
        public TestVaadinSession(VaadinService service) {
            super(service);
        }

        @Override
        public String createConnectorId(ClientConnector connector) {
            if (connector instanceof Component) {
                Component component = (Component) connector;
                String id = component.getId() == null ? super.createConnectorId(connector) : component.getId();
                UserSession session = getAttribute(UserSession.class);

                String login = null;
                String locale = null;

                if (session != null) {
                    login = session.getCurrentOrSubstitutedUser().getLogin();
                    if (session.getLocale() != null) {
                        locale = session.getLocale().toLanguageTag();
                    }
                }

                StringBuilder idParts = new StringBuilder();
                if (login != null) {
                    idParts.append(login);
                }
                if (locale != null) {
                    idParts.append(locale);
                }
                idParts.append(id);

                return toLongNumberString(idParts.toString());
            }
            return super.createConnectorId(connector);
        }

        protected String toLongNumberString(String data) {
            HashCode hashCode = md5().hashString(data, StandardCharsets.UTF_8);
            byte[] hashBytes = hashCode.asBytes();
            byte[] shortBytes = new byte[Long.BYTES];

            System.arraycopy(hashBytes, 0, shortBytes, 0, Long.BYTES);

            ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES);
            buffer.put(shortBytes);
            buffer.flip();
            return Long.toString(Math.abs(buffer.getLong()));
        }
    }

    /*
     * Uses CubaUidlWriter instead of default UidlWriter to support reloading screens that contain components
     * that use web resources from WebJars
     */
    protected static class CubaServletUIInitHandler extends ServletUIInitHandler {
        protected final ServletContext servletContext;

        public CubaServletUIInitHandler(ServletContext servletContext) {
            this.servletContext = servletContext;
        }

        @Override
        protected UidlWriter createUidlWriter() {
            return new CubaUidlWriter(servletContext);
        }
    }

    /*
     * Uses CubaUidlWriter instead of default UidlWriter to support reloading screens that contain components
     * that use web resources from WebJars
     */
    protected static class CubaUidlRequestHandler extends UidlRequestHandler {
        protected final ServletContext servletContext;

        public CubaUidlRequestHandler(ServletContext servletContext) {
            this.servletContext = servletContext;
        }

        @Override
        protected UidlWriter createUidlWriter() {
            return new CubaUidlWriter(servletContext);
        }
    }

    protected static class CubaReentrantLock extends ReentrantLock {
        public CubaReentrantLock() {
            super();
        }

        @Override
        public void lock() {
            super.lock();
            setupLogMdc();
        }

        @Override
        public void lockInterruptibly() throws InterruptedException {
            super.lockInterruptibly();
            setupLogMdc();
        }

        @Override
        public boolean tryLock() {
            boolean result = super.tryLock();
            if (result) {
                setupLogMdc();
            }
            return result;
        }

        @Override
        public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
            boolean result = super.tryLock(timeout, unit);
            if (result) {
                setupLogMdc();
            }
            return result;
        }

        @Override
        public void unlock() {
            super.unlock();
            clearLogMdc();
        }

        protected void setupLogMdc() {
            try {
                SecurityContext securityContext = AppContext.getSecurityContext();
                if (securityContext != null) {
                    LogMdc.setup(securityContext);
                }
            } catch (Exception e) {
                log.debug("Unable to set MDC", e);
            }
        }

        protected void clearLogMdc() {
            try {
                LogMdc.setup(null);
            } catch (Exception e) {
                log.debug("Unable to clear MDC", e);
            }
        }
    }
}