// Copyright 2012 Google Inc. All Rights Reserved.
//
// 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.google.collide.client.xhrmonitor;

import com.google.collide.client.util.logging.Log;
import com.google.collide.json.shared.JsonStringMap;
import com.google.collide.json.shared.JsonStringMap.IterationCallback;
import com.google.collide.shared.util.JsonCollections;
import com.google.gwt.core.client.JavaScriptObject;

import elemental.xml.XMLHttpRequest;


/**
 * The warden watches XMLHttpRequests so we can monitor how many are going out
 * and in and log it when it passes some threshold. The request kill feature
 * should not be used in production as it could degrade the user experience.
 */
public class XhrWarden {
  /**
   * If more than {@code WARDEN_WARNING_THRESHOLD} xhr requests are opened the
   * warden will trigger a warning to the warden listener.
   */
  public static final int WARDEN_WARNING_THRESHOLD = 7;
  /**
   * If {@code WARDEN_REQUEST_LIMIT} xhr's are already opened the oldest one
   * gets logged and is killed automatically.
   */
  /*
   * After switching to SPDY, we don't have a hard-limit on simultaneous XHRs so
   * there's no need to kill. Leaving this in just in-case anyone needs to use it
   * for debug purposes.
   */
  public static final int WARDEN_REQUEST_LIMIT = Integer.MAX_VALUE;

  /**
   * The underlying singleton used by the warden implementation.
   */
  private static WardenImpl wardenManager;

  /**
   * Initializes the warden.
   */
  public static WardenRequestManager watch() {
    if (wardenManager == null) {
      wardenManager = new WardenImpl(WARDEN_WARNING_THRESHOLD, WARDEN_REQUEST_LIMIT);
      createWarden(wardenManager);
    }
    return wardenManager;
  }

  /**
   * Listener for warden events
   */
  public interface WardenListener {
    /**
     * Called when the warning threshold of XHR requests is reached.
     */
    public void onWarning(WardenRequestManager manager);

    /**
     * Called when the hard request limit is reached and the oldest XHR request has been killed.
     *
     * @param request The request that was killed.
     */
    public void onEmergency(WardenRequestManager manager, WardenXhrRequest request);
  }

  /**
   * Defines public facing methods of a warden request manager.
   */
  public interface WardenRequestManager {
    public int getRequestCount();

    /**
     * Dumps all requests to the console.
     */
    void dumpRequestsToConsole();

    /**
     * Iterates over all open requests objects.
     */
    public void iterate(IterationCallback<WardenXhrRequest> callback);

    /**
     * Adds a custom header to the list of headers to be added by the XhrWarden.
     * This is meant for debugging purposes since this will get added to every
     * XHR request made by the client.
     */
    public void addCustomHeader(String header, String value);
  }

  interface WardenReadyStateHandler {
    public void onRequestOpen(WardenXhrRequest request);

    public void onRequestDone(WardenXhrRequest request);

    public void onRequestOpening(WardenXhrRequest request);

    public void doListRequests();
  }

  /**
   * Receives javascript events from the warden and decides which XHR requests
   * may need to die. Also deals with logging to counselor if things start going
   * awry.
   */
  static class WardenImpl implements WardenReadyStateHandler, WardenRequestManager {
    private final JsonStringMap<WardenXhrRequest> openXhrRequests;
    private final JsonStringMap<String> customHeaders;
    private boolean alreadyLoggedError;
    private final WardenListener eventListener;
    private final int requestWarningLimit;
    private final int requestErrorLimit;

    /**
     * The default event handler for warden events.
     */
    private static class WardenEventHandler implements WardenListener {
      @Override
      public void onEmergency(WardenRequestManager manager, WardenXhrRequest request) {
        String message =
            "The Warden killed an xhr request due to capacity issues: " + request.getUrl();
        Log.info(XhrWarden.class, message);
      }

      @Override
      public void onWarning(WardenRequestManager manager) {
        Log.info(XhrWarden.class, "Warden Warning -- Too Many Open Requests.");
        manager.dumpRequestsToConsole();
      }
    }

    public WardenImpl(int requestWarningLimit, int requestErrorLimit) {
      this(requestWarningLimit, requestErrorLimit, new WardenEventHandler());
    }

    public WardenImpl(int requestWarningLimit, int requestErrorLimit, WardenListener listener) {
      this.requestWarningLimit = requestWarningLimit;
      this.requestErrorLimit = requestErrorLimit;
      openXhrRequests = JsonCollections.createMap();
      customHeaders = JsonCollections.createMap();
      alreadyLoggedError = false;
      eventListener = listener;
    }

    @Override
    public int getRequestCount() {
      return openXhrRequests.getKeys().size();
    }

    @Override
    public void iterate(IterationCallback<WardenXhrRequest> callback) {
      openXhrRequests.iterate(callback);
    }

    public WardenXhrRequest getLongestIdleRequest() {
      WardenXhrRequest oldest = null;
      for (int i = 0; i < openXhrRequests.getKeys().size(); i++) {
        WardenXhrRequest wrapper = openXhrRequests.get(openXhrRequests.getKeys().get(i));
        if (oldest == null || oldest.getTime() > wrapper.getTime()) {
          oldest = wrapper;
        }
      }
      return oldest;
    }

    @Override
    public void dumpRequestsToConsole() {
      final StringBuilder builder = new StringBuilder();
      builder.append("\n -- ");
      builder.append(getRequestCount());
      builder.append(" Open XHR Request(s) --\n");
      iterate(new IterationCallback<WardenXhrRequest>() {
        @Override
        public void onIteration(String key, WardenXhrRequest value) {
          builder.append('(');
          builder.append(key);
          builder.append(") ");
          builder.append(value.getUrl());
          builder.append(" -- last activity on ");
          builder.append(value.getDateString());
          builder.append('\n');
        }
      });

      Log.info(getClass(), builder.toString());
    }

    @Override
    public void onRequestOpen(WardenXhrRequest request) {
      if (openXhrRequests.get(request.getId()) != null) {
        // strange state to be in
        return;
      }
      openXhrRequests.put(request.getId(), request);

      /*
       * If we haven't notified the server of our state we will let them know
       * now. In an effort to not flood ourselves we will only re-notify them
       * once we go back below the warning threshold.
       */
      if (getRequestCount() >= requestWarningLimit && !alreadyLoggedError) {
        eventListener.onWarning(this);
        alreadyLoggedError = true;
      }

      final XMLHttpRequest xhr = request.getRequest();
      customHeaders.iterate(new IterationCallback<String>() {
        @Override
        public void onIteration(String header, String value) {
          xhr.setRequestHeader(header, value);
        }
      });
    }

    @Override
    public void onRequestOpening(WardenXhrRequest request) {
      /*
       * We are trying to open up a new xhr request but are at the limit we will
       * kill the oldest inactive request so that we can make room
       */
      if (openXhrRequests.get(request.getId()) == null && getRequestCount() >= requestErrorLimit) {
        WardenXhrRequest oldest = getLongestIdleRequest();
        oldest.kill();
        openXhrRequests.remove(oldest.getId());

        eventListener.onEmergency(this, oldest);
      }
    }

    @Override
    public void onRequestDone(WardenXhrRequest request) {
      if (openXhrRequests.get(request.getId()) != null) {
        openXhrRequests.remove(request.getId());
      }

      if (getRequestCount() < requestWarningLimit) {
        alreadyLoggedError = false;
      }
    }

    @Override
    public void doListRequests() {
      dumpRequestsToConsole();
    }

    @Override
    public void addCustomHeader(String header, String value) {
      customHeaders.put(header, value);
    }
  }

  /**
   * Models a warden HTTP request which effectively wraps a XMLHttpRequest
   */
  public interface WardenXhrRequest {
    /**
     * Kills the request immediately.
     */
    public void kill();

    /**
     * Retrieves the time of the last activity for this request.
     */
    public double getTime();

    /**
     * Returns the date and time of this requests last activity as a String.
     */
    public String getDateString();

    /**
     * Gets the id for this request that was assigned by the warden.
     */
    public String getId();

    /**
     * Gets the URL this request tried to open.
     */
    public String getUrl();

    /**
     * Retrieves the underlying XMLHttpRequest.
     */
    public XMLHttpRequest getRequest();
  }

  /**
   * Wraps a warden jso which is essentially an XMLHttpRequest plus a few
   * special properties.
   *
   */
  static class WardenXhrRequestImpl extends JavaScriptObject implements WardenXhrRequest {
    protected WardenXhrRequestImpl() {
    }

    public final native void kill() /*-{
      this.abort();
    }-*/;

    public final native double getTime() /*-{
      return this.wardenTime;
    }-*/;

    public final native String getDateString() /*-{
      return new Date(this.wardenTime).toString();
    }-*/;

    public final native String getId() /*-{
      return "" + this.wardenId;
    }-*/;

    public final native String getUrl() /*-{
      // Devmode optimization (Browser Channel Weirdness)
      if (typeof this.wardenUrl != "string") {
        return "Browser Channel";
      }
      return this.wardenUrl;
    }-*/;

    @Override
    public final native XMLHttpRequest getRequest() /*-{
      return this;
    }-*/;
  }

  static WardenRequestManager getInstance() {
    return wardenManager;
  }

  static void setInstance(WardenImpl manager) {
    wardenManager = manager;
  }

  /**
   * If the warden is currently enabled, it will disable the warden; otherwise,
   * this is a no-op.
   */
  public static void stopWatching() {
    wardenManager = null;
    removeWarden();
  }

  /**
   * Dumps a list of open requests to the console.
   */
  public static void dumpRequestsToConsole() {
    if (getInstance() != null) {
      getInstance().dumpRequestsToConsole();
    }
  }

  /**
   * Creates the warden and substitutes it for the XMLHttpRequest object.
   * Basically creates an XMLHttpRequest factory with an automatic event
   * listener for onreadystatechange and an overridden open function. This
   * proved one of the few fully working ways to override the native object.
   */
  private static native void createWarden(WardenReadyStateHandler handler) /*-{

    $wnd.XMLHttpRequest = (function(handler) {
      var requestid = 0;
      var xmlhttp = $wnd.xmlhttp = $wnd.XMLHttpRequest;

      return function() {
        var request = new xmlhttp();
        request.wardenId = requestid++;

        request.addEventListener("readystatechange", function() {
          // On Open
          if (this.readyState == 1) {
            handler.
              @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::onRequestOpen(Lcom/google/collide/client/xhrmonitor/XhrWarden$WardenXhrRequest;)
              (this);
          } else if (this.readyState == 3) {
            // this indicates progress so a send or a receive
            this.wardenTime = (new Date()).getTime();
          } else if (this.readyState == 4) {
            // indicates we ended due to failure or otherwise
            handler.
              @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::onRequestDone(Lcom/google/collide/client/xhrmonitor/XhrWarden$WardenXhrRequest;)
              (this);
          }
        }, true);

        // Override the xml http request open command
        request.xhrOpen = request.open;
        request.open = function(method, url) {
          this.wardenUrl = url;
          this.wardenTime = (new Date()).getTime();
          handler.
            @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::onRequestOpening(Lcom/google/collide/client/xhrmonitor/XhrWarden$WardenXhrRequest;)
            (this);
          this.xhrOpen.apply(this,arguments);
        };

        return request;
      }
    })(handler);

    // Calls the warden to list any open xhr requests
    $wnd.XMLHttpRequest.list = function() {
      handler.
        @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::doListRequests()();
    };

    if ($wnd.console && $wnd.console.info) {
      $wnd.console.info("The warden is watching.");
    }
  }-*/;

  private static native void removeWarden() /*-{
    $wnd.XMLHttpRequest = $wnd.xmlhttp || $wnd.XMLHttpRequest;
  }-*/;
}
