/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

/**
 * About the types of objects in this file:
 *
 * - ReflowActor: the actor class used for protocol purposes.
 *   Mostly empty, just gets an instance of LayoutChangesObserver and forwards
 *   its "reflows" events to clients.
 *
 * - LayoutChangesObserver: extends Observable and uses the ReflowObserver, to
 *   track reflows on the page.
 *   Used by the LayoutActor, but is also exported on the module, so can be used
 *   by any other actor that needs it.
 *
 * - Observable: A utility parent class, meant at being extended by classes that
 *   need a to observe something on the tabActor's windows.
 *
 * - Dedicated observers: There's only one of them for now: ReflowObserver which
 *   listens to reflow events via the docshell,
 *   These dedicated classes are used by the LayoutChangesObserver.
 */

const {Ci} = require("chrome");
const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm");
const protocol = require("devtools/shared/protocol");
const {method, Arg} = protocol;
const events = require("sdk/event/core");
const Heritage = require("sdk/core/heritage");
const EventEmitter = require("devtools/shared/event-emitter");
const {reflowSpec} = require("devtools/shared/specs/layout");

/**
 * The reflow actor tracks reflows and emits events about them.
 */
var ReflowActor = exports.ReflowActor = protocol.ActorClassWithSpec(reflowSpec, {
  initialize: function (conn, tabActor) {
    protocol.Actor.prototype.initialize.call(this, conn);

    this.tabActor = tabActor;
    this._onReflow = this._onReflow.bind(this);
    this.observer = getLayoutChangesObserver(tabActor);
    this._isStarted = false;
  },

  /**
   * The reflow actor is the first (and last) in its hierarchy to use
   * protocol.js so it doesn't have a parent protocol actor that takes care of
   * its lifetime. So it needs a disconnect method to cleanup.
   */
  disconnect: function () {
    this.destroy();
  },

  destroy: function () {
    this.stop();
    releaseLayoutChangesObserver(this.tabActor);
    this.observer = null;
    this.tabActor = null;

    protocol.Actor.prototype.destroy.call(this);
  },

  /**
   * Start tracking reflows and sending events to clients about them.
   * This is a oneway method, do not expect a response and it won't return a
   * promise.
   */
  start: function () {
    if (!this._isStarted) {
      this.observer.on("reflows", this._onReflow);
      this._isStarted = true;
    }
  },

  /**
   * Stop tracking reflows and sending events to clients about them.
   * This is a oneway method, do not expect a response and it won't return a
   * promise.
   */
  stop: function () {
    if (this._isStarted) {
      this.observer.off("reflows", this._onReflow);
      this._isStarted = false;
    }
  },

  _onReflow: function (event, reflows) {
    if (this._isStarted) {
      events.emit(this, "reflows", reflows);
    }
  }
});

/**
 * Base class for all sorts of observers that need to listen to events on the
 * tabActor's windows.
 * @param {TabActor} tabActor
 * @param {Function} callback Executed everytime the observer observes something
 */
function Observable(tabActor, callback) {
  this.tabActor = tabActor;
  this.callback = callback;

  this._onWindowReady = this._onWindowReady.bind(this);
  this._onWindowDestroyed = this._onWindowDestroyed.bind(this);

  events.on(this.tabActor, "window-ready", this._onWindowReady);
  events.on(this.tabActor, "window-destroyed", this._onWindowDestroyed);
}

Observable.prototype = {
  /**
   * Is the observer currently observing
   */
  isObserving: false,

  /**
   * Stop observing and detroy this observer instance
   */
  destroy: function () {
    if (this.isDestroyed) {
      return;
    }
    this.isDestroyed = true;

    this.stop();

    events.off(this.tabActor, "window-ready", this._onWindowReady);
    events.off(this.tabActor, "window-destroyed", this._onWindowDestroyed);

    this.callback = null;
    this.tabActor = null;
  },

  /**
   * Start observing whatever it is this observer is supposed to observe
   */
  start: function () {
    if (this.isObserving) {
      return;
    }
    this.isObserving = true;

    this._startListeners(this.tabActor.windows);
  },

  /**
   * Stop observing
   */
  stop: function () {
    if (!this.isObserving) {
      return;
    }
    this.isObserving = false;

    if (this.tabActor.attached && this.tabActor.docShell) {
      // It's only worth stopping if the tabActor is still attached
      this._stopListeners(this.tabActor.windows);
    }
  },

  _onWindowReady: function ({window}) {
    if (this.isObserving) {
      this._startListeners([window]);
    }
  },

  _onWindowDestroyed: function ({window}) {
    if (this.isObserving) {
      this._stopListeners([window]);
    }
  },

  _startListeners: function (windows) {
    // To be implemented by sub-classes.
  },

  _stopListeners: function (windows) {
    // To be implemented by sub-classes.
  },

  /**
   * To be called by sub-classes when something has been observed
   */
  notifyCallback: function (...args) {
    this.isObserving && this.callback && this.callback.apply(null, args);
  }
};

/**
 * The LayouChangesObserver will observe reflows as soon as it is started.
 * Some devtools actors may cause reflows and it may be wanted to "hide" these
 * reflows from the LayouChangesObserver consumers.
 * If this is the case, such actors should require this module and use this
 * global function to turn the ignore mode on and off temporarily.
 *
 * Note that if a node is provided, it will be used to force a sync reflow to
 * make sure all reflows which occurred before switching the mode on or off are
 * either observed or ignored depending on the current mode.
 *
 * @param {Boolean} ignore
 * @param {DOMNode} syncReflowNode The node to use to force a sync reflow
 */
var gIgnoreLayoutChanges = false;
exports.setIgnoreLayoutChanges = function (ignore, syncReflowNode) {
  if (syncReflowNode) {
    let forceSyncReflow = syncReflowNode.offsetWidth;
  }
  gIgnoreLayoutChanges = ignore;
};

/**
 * The LayoutChangesObserver class is instantiated only once per given tab
 * and is used to track reflows and dom and style changes in that tab.
 * The LayoutActor uses this class to send reflow events to its clients.
 *
 * This class isn't exported on the module because it shouldn't be instantiated
 * to avoid creating several instances per tabs.
 * Use `getLayoutChangesObserver(tabActor)`
 * and `releaseLayoutChangesObserver(tabActor)`
 * which are exported to get and release instances.
 *
 * The observer loops every EVENT_BATCHING_DELAY ms and checks if layout changes
 * have happened since the last loop iteration. If there are, it sends the
 * corresponding events:
 *
 * - "reflows", with an array of all the reflows that occured,
 * - "resizes", with an array of all the resizes that occured,
 *
 * @param {TabActor} tabActor
 */
function LayoutChangesObserver(tabActor) {
  this.tabActor = tabActor;

  this._startEventLoop = this._startEventLoop.bind(this);
  this._onReflow = this._onReflow.bind(this);
  this._onResize = this._onResize.bind(this);

  // Creating the various observers we're going to need
  // For now, just the reflow observer, but later we can add markupMutation,
  // styleSheetChanges and styleRuleChanges
  this.reflowObserver = new ReflowObserver(this.tabActor, this._onReflow);
  this.resizeObserver = new WindowResizeObserver(this.tabActor, this._onResize);

  EventEmitter.decorate(this);
}

exports.LayoutChangesObserver = LayoutChangesObserver;

LayoutChangesObserver.prototype = {
  /**
   * How long does this observer waits before emitting batched events.
   * The lower the value, the more event packets will be sent to clients,
   * potentially impacting performance.
   * The higher the value, the more time we'll wait, this is better for
   * performance but has an effect on how soon changes are shown in the toolbox.
   */
  EVENT_BATCHING_DELAY: 300,

  /**
   * Destroying this instance of LayoutChangesObserver will stop the batched
   * events from being sent.
   */
  destroy: function () {
    this.isObserving = false;

    this.reflowObserver.destroy();
    this.reflows = null;

    this.resizeObserver.destroy();
    this.hasResized = false;

    this.tabActor = null;
  },

  start: function () {
    if (this.isObserving) {
      return;
    }
    this.isObserving = true;

    this.reflows = [];
    this.hasResized = false;

    this._startEventLoop();

    this.reflowObserver.start();
    this.resizeObserver.start();
  },

  stop: function () {
    if (!this.isObserving) {
      return;
    }
    this.isObserving = false;

    this._stopEventLoop();

    this.reflows = [];
    this.hasResized = false;

    this.reflowObserver.stop();
    this.resizeObserver.stop();
  },

  /**
   * Start the event loop, which regularly checks if there are any observer
   * events to be sent as batched events
   * Calls itself in a loop.
   */
  _startEventLoop: function () {
    // Avoid emitting events if the tabActor has been detached (may happen
    // during shutdown)
    if (!this.tabActor || !this.tabActor.attached) {
      return;
    }

    // Send any reflows we have
    if (this.reflows && this.reflows.length) {
      this.emit("reflows", this.reflows);
      this.reflows = [];
    }

    // Send any resizes we have
    if (this.hasResized) {
      this.emit("resize");
      this.hasResized = false;
    }

    this.eventLoopTimer = this._setTimeout(this._startEventLoop,
      this.EVENT_BATCHING_DELAY);
  },

  _stopEventLoop: function () {
    this._clearTimeout(this.eventLoopTimer);
  },

  // Exposing set/clearTimeout here to let tests override them if needed
  _setTimeout: function (cb, ms) {
    return setTimeout(cb, ms);
  },
  _clearTimeout: function (t) {
    return clearTimeout(t);
  },

  /**
   * Executed whenever a reflow is observed. Only stacks the reflow in the
   * reflows array.
   * The EVENT_BATCHING_DELAY loop will take care of it later.
   * @param {Number} start When the reflow started
   * @param {Number} end When the reflow ended
   * @param {Boolean} isInterruptible
   */
  _onReflow: function (start, end, isInterruptible) {
    if (gIgnoreLayoutChanges) {
      return;
    }

    // XXX: when/if bug 997092 gets fixed, we will be able to know which
    // elements have been reflowed, which would be a nice thing to add here.
    this.reflows.push({
      start: start,
      end: end,
      isInterruptible: isInterruptible
    });
  },

  /**
   * Executed whenever a resize is observed. Only store a flag saying that a
   * resize occured.
   * The EVENT_BATCHING_DELAY loop will take care of it later.
   */
  _onResize: function () {
    if (gIgnoreLayoutChanges) {
      return;
    }

    this.hasResized = true;
  }
};

/**
 * Get a LayoutChangesObserver instance for a given window. This function makes
 * sure there is only one instance per window.
 * @param {TabActor} tabActor
 * @return {LayoutChangesObserver}
 */
var observedWindows = new Map();
function getLayoutChangesObserver(tabActor) {
  let observerData = observedWindows.get(tabActor);
  if (observerData) {
    observerData.refCounting ++;
    return observerData.observer;
  }

  let obs = new LayoutChangesObserver(tabActor);
  observedWindows.set(tabActor, {
    observer: obs,
    // counting references allows to stop the observer when no tabActor owns an
    // instance.
    refCounting: 1
  });
  obs.start();
  return obs;
}
exports.getLayoutChangesObserver = getLayoutChangesObserver;

/**
 * Release a LayoutChangesObserver instance that was retrieved by
 * getLayoutChangesObserver. This is required to ensure the tabActor reference
 * is removed and the observer is eventually stopped and destroyed.
 * @param {TabActor} tabActor
 */
function releaseLayoutChangesObserver(tabActor) {
  let observerData = observedWindows.get(tabActor);
  if (!observerData) {
    return;
  }

  observerData.refCounting --;
  if (!observerData.refCounting) {
    observerData.observer.destroy();
    observedWindows.delete(tabActor);
  }
}
exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver;

/**
 * Reports any reflow that occurs in the tabActor's docshells.
 * @extends Observable
 * @param {TabActor} tabActor
 * @param {Function} callback Executed everytime a reflow occurs
 */
function ReflowObserver(tabActor, callback) {
  Observable.call(this, tabActor, callback);
}

ReflowObserver.prototype = Heritage.extend(Observable.prototype, {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver,
    Ci.nsISupportsWeakReference]),

  _startListeners: function (windows) {
    for (let window of windows) {
      let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor)
                     .getInterface(Ci.nsIWebNavigation)
                     .QueryInterface(Ci.nsIDocShell);
      docshell.addWeakReflowObserver(this);
    }
  },

  _stopListeners: function (windows) {
    for (let window of windows) {
      try {
        let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor)
                       .getInterface(Ci.nsIWebNavigation)
                       .QueryInterface(Ci.nsIDocShell);
        docshell.removeWeakReflowObserver(this);
      } catch (e) {
        // Corner cases where a global has already been freed may happen, in
        // which case, no need to remove the observer.
      }
    }
  },

  reflow: function (start, end) {
    this.notifyCallback(start, end, false);
  },

  reflowInterruptible: function (start, end) {
    this.notifyCallback(start, end, true);
  }
});

/**
 * Reports window resize events on the tabActor's windows.
 * @extends Observable
 * @param {TabActor} tabActor
 * @param {Function} callback Executed everytime a resize occurs
 */
function WindowResizeObserver(tabActor, callback) {
  Observable.call(this, tabActor, callback);
  this.onResize = this.onResize.bind(this);
}

WindowResizeObserver.prototype = Heritage.extend(Observable.prototype, {
  _startListeners: function () {
    this.listenerTarget.addEventListener("resize", this.onResize);
  },

  _stopListeners: function () {
    this.listenerTarget.removeEventListener("resize", this.onResize);
  },

  onResize: function () {
    this.notifyCallback();
  },

  get listenerTarget() {
    // For the rootActor, return its window.
    if (this.tabActor.isRootActor) {
      return this.tabActor.window;
    }

    // Otherwise, get the tabActor's chromeEventHandler.
    return this.tabActor.window.QueryInterface(Ci.nsIInterfaceRequestor)
                               .getInterface(Ci.nsIWebNavigation)
                               .QueryInterface(Ci.nsIDocShell)
                               .chromeEventHandler;
  }
});
