/* 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/. */

// This file contains event parsers that are then used by developer tools in
// order to find information about events affecting an HTML element.

"use strict";

const {Cc, Ci} = require("chrome");

// eslint-disable-next-line
const JQUERY_LIVE_REGEX = /return typeof \w+.*.event\.triggered[\s\S]*\.event\.(dispatch|handle).*arguments/;

loader.lazyGetter(this, "eventListenerService", () => {
  return Cc["@mozilla.org/eventlistenerservice;1"]
           .getService(Ci.nsIEventListenerService);
});

var parsers = [
  {
    id: "jQuery events",
    getListeners: function (node) {
      let global = node.ownerGlobal.wrappedJSObject;
      let hasJQuery = global.jQuery && global.jQuery.fn && global.jQuery.fn.jquery;

      if (!hasJQuery) {
        return undefined;
      }

      let jQuery = global.jQuery;
      let handlers = [];

      // jQuery 1.2+
      let data = jQuery._data || jQuery.data;
      if (data) {
        let eventsObj = data(node, "events");
        for (let type in eventsObj) {
          let events = eventsObj[type];
          for (let key in events) {
            let event = events[key];

            if (node.wrappedJSObject == global.document && event.selector) {
              continue;
            }

            if (typeof event === "object" || typeof event === "function") {
              let eventInfo = {
                type: type,
                handler: event.handler || event,
                tags: "jQuery",
                hide: {
                  capturing: true,
                  dom0: true
                }
              };

              handlers.push(eventInfo);
            }
          }
        }
      }

      // JQuery 1.0 & 1.1
      let entry = jQuery(node)[0];

      if (!entry) {
        return handlers;
      }

      for (let type in entry.events) {
        let events = entry.events[type];
        for (let key in events) {
          let event = events[key];

          if (node.wrappedJSObject == global.document && event.selector) {
            continue;
          }

          if (typeof events[key] === "function") {
            let eventInfo = {
              type: type,
              handler: events[key],
              tags: "jQuery",
              hide: {
                capturing: true,
                dom0: true
              }
            };

            handlers.push(eventInfo);
          }
        }
      }

      return handlers;
    }
  },
  {
    id: "jQuery live events",
    hasListeners: function (node) {
      return jQueryLiveGetListeners(node, true);
    },
    getListeners: function (node) {
      return jQueryLiveGetListeners(node, false);
    },
    normalizeListener: function (handlerDO) {
      function isFunctionInProxy(funcDO) {
        // If the anonymous function is inside the |proxy| function and the
        // function only has guessed atom, the guessed atom should starts with
        // "proxy/".
        let displayName = funcDO.displayName;
        if (displayName && displayName.startsWith("proxy/")) {
          return true;
        }

        // If the anonymous function is inside the |proxy| function and the
        // function gets name at compile time by SetFunctionName, its guessed
        // atom doesn't contain "proxy/".  In that case, check if the caller is
        // "proxy" function, as a fallback.
        let calleeDO = funcDO.environment.callee;
        if (!calleeDO) {
          return false;
        }
        let calleeName = calleeDO.displayName;
        return calleeName == "proxy";
      }

      function getFirstFunctionVariable(funcDO) {
        // The handler function inside the |proxy| function should point the
        // unwrapped function via environment variable.
        let names = funcDO.environment.names();
        for (let varName of names) {
          let varDO = handlerDO.environment.getVariable(varName);
          if (!varDO) {
            continue;
          }
          if (varDO.class == "Function") {
            return varDO;
          }
        }
        return null;
      }

      if (!isFunctionInProxy(handlerDO)) {
        return handlerDO;
      }

      const MAX_NESTED_HANDLER_COUNT = 2;
      for (let i = 0; i < MAX_NESTED_HANDLER_COUNT; i++) {
        let funcDO = getFirstFunctionVariable(handlerDO);
        if (!funcDO) {
          return handlerDO;
        }

        handlerDO = funcDO;
        if (isFunctionInProxy(handlerDO)) {
          continue;
        }
        break;
      }

      return handlerDO;
    }
  },
  {
    id: "DOM events",
    hasListeners: function (node) {
      let listeners;

      if (node.nodeName.toLowerCase() === "html") {
        let winListeners =
          eventListenerService.getListenerInfoFor(node.ownerGlobal) || [];
        let docElementListeners =
          eventListenerService.getListenerInfoFor(node) || [];
        let docListeners =
          eventListenerService.getListenerInfoFor(node.parentNode) || [];

        listeners = [...winListeners, ...docElementListeners, ...docListeners];
      } else {
        listeners = eventListenerService.getListenerInfoFor(node) || [];
      }

      for (let listener of listeners) {
        if (listener.listenerObject && listener.type) {
          return true;
        }
      }

      return false;
    },
    getListeners: function (node) {
      let handlers = [];
      let listeners = eventListenerService.getListenerInfoFor(node);

      // The Node actor's getEventListenerInfo knows that when an html tag has
      // been passed we need the window object so we don't need to account for
      // event hoisting here as we did in hasListeners.

      for (let listenerObj of listeners) {
        let listener = listenerObj.listenerObject;

        // If there is no JS event listener skip this.
        if (!listener || JQUERY_LIVE_REGEX.test(listener.toString())) {
          continue;
        }

        let eventInfo = {
          capturing: listenerObj.capturing,
          type: listenerObj.type,
          handler: listener
        };

        handlers.push(eventInfo);
      }

      return handlers;
    }
  },

  {
    id: "React events",
    hasListeners: function (node) {
      return reactGetListeners(node, true);
    },

    getListeners: function (node) {
      return reactGetListeners(node, false);
    },

    normalizeListener: function (handlerDO, listener) {
      let functionText = "";

      if (handlerDO.boundTargetFunction) {
        handlerDO = handlerDO.boundTargetFunction;
      }

      let introScript = handlerDO.script.source.introductionScript;
      let script = handlerDO.script;

      // If this is a Babel transpiled function we have no access to the
      // source location so we need to hide the filename and debugger
      // icon.
      if (introScript && introScript.displayName.endsWith("/transform.run")) {
        listener.hide.debugger = true;
        listener.hide.filename = true;

        if (!handlerDO.isArrowFunction) {
          functionText += "function (";
        } else {
          functionText += "(";
        }

        functionText += handlerDO.parameterNames.join(", ");

        functionText += ") {\n";

        let scriptSource = script.source.text;
        functionText +=
          scriptSource.substr(script.sourceStart, script.sourceLength);

        listener.override.handler = functionText;
      }

      return handlerDO;
    }
  },
];

function reactGetListeners(node, boolOnEventFound) {
  function getProps() {
    for (let key in node) {
      if (key.startsWith("__reactInternalInstance$")) {
        return node[key]._currentElement.props;
      }
    }
    return null;
  }

  node = node.wrappedJSObject || node;

  let handlers = [];
  let props = getProps();

  if (props) {
    for (let name in props) {
      if (name.startsWith("on")) {
        let prop = props[name];
        let listener = prop.__reactBoundMethod || prop;

        if (typeof listener !== "function") {
          continue;
        }

        if (boolOnEventFound) {
          return true;
        }

        let handler = {
          type: name,
          handler: listener,
          tags: "React",
          hide: {
            dom0: true
          },
          override: {
            capturing: name.endsWith("Capture")
          }
        };

        handlers.push(handler);
      }
    }
  }

  if (boolOnEventFound) {
    return false;
  }

  return handlers;
}

function jQueryLiveGetListeners(node, boolOnEventFound) {
  let global = node.ownerGlobal.wrappedJSObject;
  let hasJQuery = global.jQuery && global.jQuery.fn && global.jQuery.fn.jquery;

  if (!hasJQuery) {
    return undefined;
  }

  let jQuery = global.jQuery;
  let handlers = [];
  let data = jQuery._data || jQuery.data;

  if (data) {
    // Live events are added to the document and bubble up to all elements.
    // Any element matching the specified selector will trigger the live
    // event.
    let events = data(global.document, "events");

    for (let type in events) {
      let eventHolder = events[type];

      for (let idx in eventHolder) {
        if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) {
          continue;
        }

        let event = eventHolder[idx];
        let selector = event.selector;

        if (!selector && event.data) {
          selector = event.data.selector || event.data || event.selector;
        }

        if (!selector || !node.ownerDocument) {
          continue;
        }

        let matches;
        try {
          matches = node.matches && node.matches(selector);
        } catch (e) {
          // Invalid selector, do nothing.
        }

        if (boolOnEventFound && matches) {
          return true;
        }

        if (!matches) {
          continue;
        }

        if (!boolOnEventFound &&
            (typeof event === "object" || typeof event === "function")) {
          let eventInfo = {
            type: event.origType || event.type.substr(selector.length + 1),
            handler: event.handler || event,
            tags: "jQuery,Live",
            hide: {
              dom0: true,
              capturing: true
            }
          };

          if (!eventInfo.type && event.data && event.data.live) {
            eventInfo.type = event.data.live;
          }

          handlers.push(eventInfo);
        }
      }
    }
  }

  if (boolOnEventFound) {
    return false;
  }
  return handlers;
}

this.EventParsers = function EventParsers() {
  if (this._eventParsers.size === 0) {
    for (let parserObj of parsers) {
      this.registerEventParser(parserObj);
    }
  }
};

exports.EventParsers = EventParsers;

EventParsers.prototype = {
  // NOTE: This is shared amongst all instances.
  _eventParsers: new Map(),

  get parsers() {
    return this._eventParsers;
  },

  /**
   * Register a new event parser to be used in the processing of event info.
   *
   * @param {Object} parserObj
   *        Each parser must contain the following properties:
   *        - parser, which must take the following form:
   *   {
   *     id {String}: "jQuery events",          // Unique id.
   *     getListeners: function(node) { },      // Function that takes a node
   *                                            // and returns an array of
   *                                            // eventInfo objects (see
   *                                            // below).
   *
   *     hasListeners: function(node) { },      // Optional function that takes
   *                                            // a node and returns a boolean
   *                                            // indicating whether a node has
   *                                            // listeners attached.
   *
   *     normalizeListener:
   *        function(fnDO, listener) { },       // Optional function that takes
   *                                            // a Debugger.Object instance
   *                                            // and an optional listener
   *                                            // object. Both objects can be
   *                                            // manipulated to display the
   *                                            // correct information in the
   *                                            // event bubble.
   *   }
   *
   * An eventInfo object should take the following form:
   *   {
   *     type {String}:      "click",
   *     handler {Function}: event handler,
   *     tags {String}:      "jQuery,Live", // These tags will be displayed as
   *                                        // attributes in the events popup.
   *     hide: {               // Hide or show fields:
   *       debugger: false,    // Debugger icon
   *       type: false,        // Event type e.g. click
   *       filename: false,    // Filename
   *       capturing: false,   // Capturing
   *       dom0: false         // DOM 0
   *     },
   *
   *     override: {                        // The following can be overridden:
   *       handler: someEventHandler,
   *       type: "click",
   *       origin: "http://www.mozilla.com",
   *       searchString: 'onclick="doSomething()"',
   *       dom0: true,
   *       capturing: true
   *     }
   *   }
   */
  registerEventParser: function (parserObj) {
    let parserId = parserObj.id;

    if (!parserId) {
      throw new Error("Cannot register new event parser with id " + parserId);
    }
    if (this._eventParsers.has(parserId)) {
      throw new Error("Duplicate event parser id " + parserId);
    }

    this._eventParsers.set(parserId, {
      getListeners: parserObj.getListeners,
      hasListeners: parserObj.hasListeners,
      normalizeListener: parserObj.normalizeListener
    });
  },

  /**
   * Removes parser that matches a given parserId.
   *
   * @param {String} parserId
   *        id of the event parser to unregister.
   */
  unregisterEventParser: function (parserId) {
    this._eventParsers.delete(parserId);
  },

  /**
   * Tidy up parsers.
   */
  destroy: function () {
    for (let [id] of this._eventParsers) {
      this.unregisterEventParser(id, true);
    }
  }
};
