package org.vaadin.touchkit.gwt.client.offlinemode;

import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.APP_STARTED;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.APP_STARTING;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.BAD_RESPONSE;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.FORCE_OFFLINE;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.FORCE_ONLINE;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.NETWORK_ONLINE;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.NO_NETWORK;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.ONLINE_APP_NOT_STARTED;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.RESPONSE_TIMEOUT;
import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.SERVER_AVAILABLE;

import java.util.logging.Logger;

import org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason;
import org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.OfflineEvent;
import org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.OnlineEvent;
import org.vaadin.touchkit.gwt.client.vcom.OfflineModeConnector;

import com.google.gwt.core.client.Callback;
import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.RepeatingCommand;
import com.google.gwt.http.client.Request;
import com.google.gwt.http.client.RequestBuilder;
import com.google.gwt.http.client.RequestCallback;
import com.google.gwt.http.client.RequestTimeoutException;
import com.google.gwt.http.client.Response;
import com.google.gwt.user.client.Timer;
import com.vaadin.client.ApplicationConfiguration;
import com.vaadin.client.ApplicationConnection;
import com.vaadin.client.ApplicationConnection.CommunicationHandler;
import com.vaadin.client.ApplicationConnection.RequestStartingEvent;
import com.vaadin.client.ApplicationConnection.ResponseHandlingEndedEvent;
import com.vaadin.client.ApplicationConnection.ResponseHandlingStartedEvent;

/**
 * When this entry point starts an OfflineMode application is started.
 * 
 * When the online application goes available, it deactivates the offline
 * application.
 * 
 * It listen for HTML5 and Cordova online/off-line events
 * activating/deactivating the offline app.
 * 
 * It also observes any request to check whether the server goes unreachable,
 * and reconfigures heartbeat intervals depending on the connection status.
 */
public class OfflineModeEntrypoint implements EntryPoint, CommunicationHandler,
        RequestCallback {

    /**
     * We maintain three flags for defining application statuses.
     * 
     * To know whether the app is online anywhere, use:
     * <code>
     * OfflineModeEntrypoint.get().getNetworkStatus().isAppOnline()
     * </code>
     */
    public class NetworkStatus {
        private boolean forcedOffline = false;
        private boolean serverReachable = false;
        private boolean networkOnline = true;
        private boolean paused = false;

        public boolean isAppOnline() {
            return !forcedOffline && networkOnline && serverReachable;
        }

        public boolean isNetworkOnline() {
            return !forcedOffline && networkOnline;
        }

        public boolean isServerReachable() {
            return !forcedOffline && serverReachable;
        }

        public boolean isAppRunning() {
            return isAppOnline() && !paused;
        }
    }

    private static OfflineModeEntrypoint instance = null;
    private static OfflineMode offlineModeApp;

    private static JavaScriptObject appConf = null;

    private NetworkStatus status = new NetworkStatus();

    private OfflineModeConnector offlineModeConnector = null;
    private final Logger logger = Logger.getLogger(this.getClass().getName());

    private ActivationReason lastReason = null;
    private ApplicationConnection applicationConnection = null;

    // TODO: make more parameters configurable from server-side.
    private int onlinePingInterval = 60000;
    private int offlinePingInterval = 10000;

    /*
     * We use a ping request instead of core heartbeat when the app is not
     * available. Normally when starting it with network off-line.
     */
    private final Timer pingToServer = new Timer() {
        String url;

        @Override
        public void run() {
            RequestBuilder rq = new RequestBuilder(RequestBuilder.POST, getPingUrl());
            rq.setTimeoutMillis(offlinePingInterval);
            rq.setCallback(OfflineModeEntrypoint.this);
            try {
                logger.info("Sending a ping request to the server.");
                rq.send();
            } catch (Exception e) {
                onError(null, e);
            }
        }

        private String getPingUrl() {
            if (url == null) {
                // Try to find the serviceUrl.
                // Only needed when widgetset is local or it is in a CDN.
                url = getVaadinConfValue("serviceUrl");
                if (url == null) {
                    url = GWT.getHostPageBaseURL();
                }
                url += "/PING";
                logger.info("Ping URL " + url);
            }
            return url;
        }
    };

    /**
     * @return the singletone instance of the OfflineModeEntrypoint
     */
    public static OfflineModeEntrypoint get() {
        // Shouldn't happen unless someone does not inherits TK module
        if (instance == null) {
            new OfflineModeEntrypoint().onModuleLoad();
        }
        return instance;
    }

    @Override
    public void onModuleLoad() {
        // Do not run twice.
        if (instance != null) {
            return;
        }
        instance = this;

        // Read the Javascript object with the vaadin root configuration
        appConf = getVaadinConf();

        // If application is not extending TK servlet offline-mode is not reliable
        if (!isTouchKitServlet()) {
            logger.severe("OfflineMode disabled because Servlet is not extending TouchKitServlet.");
            return;
        }

        // Application can disable offline.
        if (!isOfflineModeEnabled()) {
            logger.info("OfflineMode disabled because of server configuration.");
            return;
        }

        // We always go off-line at the beginning until we receive
        // a Vaadin online response
        dispatch(APP_STARTING);

        // Configure HTML5 and Cordova off-line listeners
        configureApplicationOfflineEvents();

        // Connection takes a while to be available
        waitForConnectionAvailable();

        // Realize soon that server is offline.
        pingToServer.schedule(2000);
    }

    /*
     * Loop until vaadin application connection is loaded.
     * Normally it should be done when the OfflineModeConnector is
     * instantiated, but there could be applications not using it
     * in server side. It seems there is not other way to do this.
     */
    private void waitForConnectionAvailable() {
        Scheduler.get().scheduleFixedDelay(new RepeatingCommand() {
            int counter = 0;
            @Override
            public boolean execute() {
                if (!ApplicationConfiguration.getRunningApplications()
                        .isEmpty()) {
                    configureHandlers(ApplicationConfiguration
                            .getRunningApplications().iterator().next());
                }
                return counter++ < 50 && applicationConnection == null;
            }
        }, 100);
    }

    /**
     * Return a network status object, so as other part of the app could have
     * info about whether the device network is online and the Vaadin server is
     * reachable.
     */
    public NetworkStatus getNetworkStatus() {
        return status;
    }

    /**
     * Set the offlineModeConnector. It's optional to use it in server side.
     */
    public void setOfflineModeConnector(OfflineModeConnector oc) {
        logger.info("Vaadin OfflineModeConnector has been started.");
        offlineModeConnector = oc;
        configureHandlers(oc.getConnection());
        onResponseHandlingEnded(null);
    }

    /*
     * When applicationConnection is available we listen to certain handlers.
     * This never happens if the app starts in offline-mode.
     */
    private void configureHandlers(ApplicationConnection conn) {
        if (applicationConnection == null) {
            logger.info("Vaadin ApplicationConnection has been started.");
            applicationConnection = conn;
            applicationConnection.addHandler(RequestStartingEvent.TYPE, this);
            applicationConnection.addHandler(ResponseHandlingStartedEvent.TYPE,
                    this);
            applicationConnection.addHandler(ResponseHandlingEndedEvent.TYPE,
                    this);
            dispatch(APP_STARTED);
        }
    }

    /**
     * Receive any activation or deactivation reason, setting the appropriate
     * flags and going Off-line or On-line in case.
     */
    public void dispatch(ActivationReason reason) {
        // If server failed when returning the widgetset configuration, we do nothing
        if (getRootResponseStatus() >= 400) {
            logger.severe("OfflineMode disabled because a bad response when fetching root configuration.");
            return;
        }
        // Only dispatch events when something changes
        if (lastReason != reason) {
            if (reason == NETWORK_ONLINE && status.isNetworkOnline()) {
                // Avoid logging a frequent case.
                return;
            }

            logger.info("Dispatching: " + lastReason + " -> " + reason
                    + " flags=" + status.forcedOffline + " "
                    + status.networkOnline + " " + status.serverReachable);

            if (reason == NETWORK_ONLINE) {
                status.networkOnline = true;
                configureHeartBeat();
                ping();
            } else if (reason == NO_NETWORK) {
                status.networkOnline = false;
                if (status.isServerReachable() || lastReason == APP_STARTING) {
                    goOffline(reason);
                }
                status.serverReachable = false;
            } else if (reason == SERVER_AVAILABLE || reason == APP_STARTED) {
                status.serverReachable = true;
                status.networkOnline = true;
                goOnline(reason);
            } else if (reason == FORCE_OFFLINE) {
                status.forcedOffline = true;
                goOffline(reason);
            } else if (reason == FORCE_ONLINE) {
                status.forcedOffline = false;
                ping();
            } else if (CacheManifestStatusIndicator.isUpdating()) {
                // When network is slow and a new version of the app is being downloaded
                // we could have unreachable responses, hence it's better to ignore it.
            } else {
                // Offline cases
                status.serverReachable = false;
                goOffline(reason);
            }
            lastReason = reason;
        }
    }

    /*
     * Configure application heartbeat depending on the status. If application
     * is not ready we use a timer instead.
     */
    private void configureHeartBeat() {
        if (status.isAppOnline() || !isOfflineModeEnabled()) {
            setHeartBeatInterval(onlinePingInterval);
        } else if (!status.isNetworkOnline()) {
            stopHeartBeat();
        } else {
            if (offlineModeConnector != null
                    && offlineModeConnector.getOfflineModeTimeout() > -1) {
                // This parameter is configurable from server via connector
                offlinePingInterval = offlineModeConnector
                        .getOfflineModeTimeout();
            }
            setHeartBeatInterval(offlinePingInterval);
        }
    }

    private void stopHeartBeat() {
        setHeartBeatInterval(-1);
    }

    private void resume() {
        status.paused = false;
        configureHeartBeat();
    }

    private void pause() {
        status.paused = true;
        stopHeartBeat();
    }

    /*
     * Set application heartbeat, When application is not ready we use a timer
     * instead.
     */
    private void setHeartBeatInterval(int ms) {
        pingToServer.cancel();
        if (applicationConnection != null) {
            applicationConnection.getHeartbeat().setInterval(ms > 0 ? ms / 1000 : -1);
        } else if (ms > 0) {
            pingToServer.scheduleRepeating(ms);
        }
    }

    /**
     * @return the OfflineMode application.
     */
    public static OfflineMode getOfflineMode() {
        if (offlineModeApp == null) {
            offlineModeApp = GWT.create(OfflineMode.class);
        }
        return offlineModeApp;
    }

    /*
     * Go online if we were not, deactivating off-line UI and reactivating the
     * online one.
     */
    private void goOnline(ActivationReason reason) {
        if (status.isAppOnline()) {
            logger.info("Network Back ONLINE (" + reason + ")");
            if (applicationConnection != null) {
                if (getOfflineMode().isActive()) {
                    getOfflineMode().deactivate();
                }
                configureHeartBeat();
                applicationConnection.fireEvent(new OnlineEvent());
            } else {
                retryApplicationConnection();
            }
        }
    }

    /*
     * This method is called when the device becomes online but the
     * app was started from cache. We rerun vaadinBootstrap in order to
     * fetch root configuration.
     */
    private void retryApplicationConnection() {
        fetchRootConfiguration(new Callback<JavaScriptObject, JavaScriptObject>() {
            @Override
            public void onSuccess(JavaScriptObject result) {
                waitForConnectionAvailable();
            }
            @Override
            public void onFailure(JavaScriptObject reason) {
                goOffline(ONLINE_APP_NOT_STARTED);
            }
        });
    }

    /*
     * Go off-line showing the off-line UI, or notify it with the last off-line
     * reason.
     */
    private void goOffline(ActivationReason reason) {
        logger.info("Network OFFLINE (" + reason + ")");
        if (!isOfflineModeEnabled()) {
            return;
        }
        getOfflineMode().activate(reason);
        if (applicationConnection != null) {
            applicationConnection.getLoadingIndicator().hide();
            applicationConnection.fireEvent(new OfflineEvent(reason));
        }
        configureHeartBeat();
    }

    @Override
    public void onResponseReceived(Request request, Response response) {
        if (response != null && response.getStatusCode() == Response.SC_OK) {
            dispatch(SERVER_AVAILABLE);
        } else {
            dispatch(RESPONSE_TIMEOUT);
        }
    }

    @Override
    public void onError(Request request, Throwable exception) {
        dispatch(exception instanceof RequestTimeoutException ? RESPONSE_TIMEOUT
                : BAD_RESPONSE);
    }

    @Override
    public void onRequestStarting(RequestStartingEvent e) {
    }

    @Override
    public void onResponseHandlingStarted(ResponseHandlingStartedEvent e) {
        dispatch(SERVER_AVAILABLE);
    }

    @Override
    public void onResponseHandlingEnded(ResponseHandlingEndedEvent e) {
        if (lastReason == APP_STARTING) {
            dispatch(SERVER_AVAILABLE);
        }
    }

    /**
     * Check whether the server is reachable setting the status on the response.
     */
    public void ping() {
        if (applicationConnection != null) {
            applicationConnection.getHeartbeat().send();
        } else {
            pingToServer.run();
        }
    }

    /*
     * Using this JSNI block in order to listen to certain DOM events not
     * available in GWT: HTML-5 and Cordova online/offline.
     * 
     * We also listen to hash fragment changes and window post-messages, so as
     * the app is notified with offline events from the parent when it is
     * embedded in an iframe.
     * 
     * This block has a couple of hacks to make the app or network go off-line:
     * tkGoOffline() tkGoOnline() tkServerDown() tkServerUp()
     * 
     * NOTE: Most code here is for fixing android bugs firing wrong events and
     * setting erroneously online flags when it is inside webview.
     */
    private native void configureApplicationOfflineEvents()
    /*-{
        var _this = this;
        // Cordova installed in current window
        var hasCordovaEvents = $wnd.navigator.network && $wnd.navigator.network.connection && $wnd.Connection;
        // Cordova installed in parent window sending network events via postMessage
        var maybeCordova = !hasCordovaEvents && $wnd.parent !== $wnd && $wnd.postMessage;
        // Use html5 online events as the last resource because of bugs in
        // android webview which sends unexpected online/offline events when actually
        // there weren't real network changes we have detected these issues when rotating
        // the screen, hiding the keyboard, focusing an input text, etc.
        var useHtml5Events = !hasCordovaEvents && $wnd.navigator.onLine != undefined;

        function offline() {
          var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::NO_NETWORK;
          [email protected]rypoint::dispatch(*)(ev);
        }
        function online() {
          var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::NETWORK_ONLINE;
          [email protected]rypoint::dispatch(*)(ev);
        }
        function pause() {
          [email protected]rypoint::pause()();
        }
        function resume() {
          [email protected]rypoint::resume()();
        }

        // Export some functions for allowing developer to switch network and server on/off from JS console
        var forceFailure = false;
        $wnd.tkServerDown = function() {
          forceFailure = true;
        }
        $wnd.tkServerUp = function() {
          forceFailure = false;
        }
        $wnd.tkGoOffline = function() {
          // We only set the server Down if we force off-line from console, because setting off-line from
          // server needs connection for setting on-line back.
          $wnd.tkServerDown();
          var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::FORCE_OFFLINE;
          [email protected]rypoint::dispatch(*)(ev);
        }
        $wnd.tkGoOnline = function() {
          $wnd.tkServerUp();
          var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::FORCE_ONLINE;
          [email protected]rypoint::dispatch(*)(ev);
        }
        // When offline is forced make any XHR fail
        var realSend = $wnd.XMLHttpRequest.prototype.send;
        $wnd.XMLHttpRequest.prototype.send = function() {
          if (forceFailure) {
            throw "NETWORK_FAILURE_FORCED";
          } else {
            realSend.apply(this, arguments);
          }
        }

        // Listen to HTML5 offline-online events.
        if (useHtml5Events) {
          $wnd.addEventListener("offline", offline, false);
          $wnd.addEventListener("online", online, false);
          // use HTML5 to test whether connection is available when the app starts
          if (!$wnd.navigator.onLine) {
            offline();
          }

          // Redefine the HTML-5 onLine indicator.
          // This fixes the issue of android inside phonegap returning erroneus values.
          // It allows old vaadin apps based on testing 'onLine' flag continuing working.
          // Note: Safari disallows changing the 'online' property of the $wnd.
          if (!$wnd.navigator.hasOwnProperty('onLine') || $wnd.Object.getOwnPropertyDescriptor($wnd.navigator, 'onLine').configurable) {
            Object.defineProperty($wnd.navigator, 'onLine', {
              set: function() {},
              get: function() {
                var sts = [email protected]rypoint::getNetworkStatus()();
                return [email protected]point.NetworkStatus::isAppOnline()();
              }
            });
          }
        }

        // Listen to Cordova specific online/off-line stuff
        // this needs cordova.js to be loaded in the current page.
        if (hasCordovaEvents) {
          $doc.addEventListener("offline", offline, false);
          $doc.addEventListener("online", online, false);
          $doc.addEventListener("pause", pause, false);
          $doc.addEventListener("resume", resume, false);
          // use Cordova to test whether connection is available when the app starts
          if ($wnd.navigator.network.connection.type == $wnd.Connection.NONE) {
            offline();
          }
        }

        // Use postMessage approach to go online-offline, useful when the
        // application is embedded in a Cordova iframe.
        if (maybeCordova) {
          $wnd.addEventListener("message", function(ev) {
            var msg = ev.data;
            console.log(">>> received window message " + msg);
            if (/^(cordova-.+)$/.test(msg)) {
              // Remove HTML5 events to avoid android devices sending erroneous events
              if (!hasCordovaEvents) {
                console.log(">>> Cordova is present, removing HTML5 events.");
                $wnd.removeEventListener("offline", offline, false);
                $wnd.removeEventListener("online", online, false);
              }
              hasCordovaEvents = true;
              // Take an action depending on the message
              if (msg == 'cordova-offline') {
                offline();
              } else if (msg == 'cordova-online') {
                online();
              } else if (msg == 'cordova-pause') {
                pause();
              } else if (msg == 'cordova-resume') {
                resume();
              }
            }
          }, false);
          // Notify parent cordova container about the app was loaded.
          $wnd.parent.window.postMessage("touchkit-ready", "*");
        }
    }-*/;

    // Try to find the vaadin config js-object when the app is
    // off-line and it has not been initialized yet
    private static native JavaScriptObject getVaadinConf()
    /*-{
      return $wnd.vaadin.getApp($wnd.vaadin.getAppIds()[0]);
    }-*/;

    // Get a vaadin config value
    private static native String getVaadinConfValue(String key)
    /*-{
      var app = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::appConf;
      var r = app && app.getConfig(key);
      // return null only in the case the value does not exist
      // otherwise return the string representation of the value.
      return r == null ? null : ('' + r);
    }-*/;

    // Make vaadinBootstrap rerun the fetchRootConfiguration request.
    private static native void fetchRootConfiguration(Callback<JavaScriptObject, JavaScriptObject> callback)
    /*-{
      var app = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::appConf;
      app && app.fetchRootConfig(function(r) {
          if (callback) {
            if (r && r.status == 200)
              [email protected]::onSuccess(*)(r);
            else
              [email protected]::onFailure(*)(r);
          }
        });
    }-*/;

    // Return true if offline mode is enabled in this app.
    // When true we never show the offline UI when the server is unreachable.
    public boolean isOfflineModeEnabled() {
        return Boolean.valueOf(getVaadinConfValue("offlineEnabled"));
    }

    // Return true if servlet is extending TouchKitServlet.
    // the 'widgetsetUrl'  attribute is set by TK servlet.
    private static boolean isTouchKitServlet() {
        return getVaadinConfValue("widgetsetUrl") != null;
    }

    // Return the http status of the fetchRootConfiguration call
    private static int getRootResponseStatus() {
        String code = getVaadinConfValue("rootResponseStatus");
        return code == null ? -1 : Integer.valueOf(code);
    }
}