/* 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 collectors that are then used by developer tools in
// order to find information about events affecting an HTML element.

"use strict";

const Debugger = require("Debugger");
const {
  EXCLUDED_LISTENER,
} = require("resource://devtools/server/actors/inspector/constants.js");
loader.lazyRequireGetter(
  this,
  "isUserDefinedEventName",
  "resource://devtools/server/actors/events/events.js",
  true
);

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

const REACT_EVENT_NAMES = [
  "onAbort",
  "onAnimationEnd",
  "onAnimationIteration",
  "onAnimationStart",
  "onAuxClick",
  "onBeforeInput",
  "onBlur",
  "onCanPlay",
  "onCanPlayThrough",
  "onCancel",
  "onChange",
  "onClick",
  "onClose",
  "onCompositionEnd",
  "onCompositionStart",
  "onCompositionUpdate",
  "onContextMenu",
  "onCopy",
  "onCut",
  "onDoubleClick",
  "onDrag",
  "onDragEnd",
  "onDragEnter",
  "onDragExit",
  "onDragLeave",
  "onDragOver",
  "onDragStart",
  "onDrop",
  "onDurationChange",
  "onEmptied",
  "onEncrypted",
  "onEnded",
  "onError",
  "onFocus",
  "onGotPointerCapture",
  "onInput",
  "onInvalid",
  "onKeyDown",
  "onKeyPress",
  "onKeyUp",
  "onLoad",
  "onLoadStart",
  "onLoadedData",
  "onLoadedMetadata",
  "onLostPointerCapture",
  "onMouseDown",
  "onMouseEnter",
  "onMouseLeave",
  "onMouseMove",
  "onMouseOut",
  "onMouseOver",
  "onMouseUp",
  "onPaste",
  "onPause",
  "onPlay",
  "onPlaying",
  "onPointerCancel",
  "onPointerDown",
  "onPointerEnter",
  "onPointerLeave",
  "onPointerMove",
  "onPointerOut",
  "onPointerOver",
  "onPointerUp",
  "onProgress",
  "onRateChange",
  "onReset",
  "onScroll",
  "onSeeked",
  "onSeeking",
  "onSelect",
  "onStalled",
  "onSubmit",
  "onSuspend",
  "onTimeUpdate",
  "onToggle",
  "onTouchCancel",
  "onTouchEnd",
  "onTouchMove",
  "onTouchStart",
  "onTransitionEnd",
  "onVolumeChange",
  "onWaiting",
  "onWheel",
  "onAbortCapture",
  "onAnimationEndCapture",
  "onAnimationIterationCapture",
  "onAnimationStartCapture",
  "onAuxClickCapture",
  "onBeforeInputCapture",
  "onBlurCapture",
  "onCanPlayCapture",
  "onCanPlayThroughCapture",
  "onCancelCapture",
  "onChangeCapture",
  "onClickCapture",
  "onCloseCapture",
  "onCompositionEndCapture",
  "onCompositionStartCapture",
  "onCompositionUpdateCapture",
  "onContextMenuCapture",
  "onCopyCapture",
  "onCutCapture",
  "onDoubleClickCapture",
  "onDragCapture",
  "onDragEndCapture",
  "onDragEnterCapture",
  "onDragExitCapture",
  "onDragLeaveCapture",
  "onDragOverCapture",
  "onDragStartCapture",
  "onDropCapture",
  "onDurationChangeCapture",
  "onEmptiedCapture",
  "onEncryptedCapture",
  "onEndedCapture",
  "onErrorCapture",
  "onFocusCapture",
  "onGotPointerCaptureCapture",
  "onInputCapture",
  "onInvalidCapture",
  "onKeyDownCapture",
  "onKeyPressCapture",
  "onKeyUpCapture",
  "onLoadCapture",
  "onLoadStartCapture",
  "onLoadedDataCapture",
  "onLoadedMetadataCapture",
  "onLostPointerCaptureCapture",
  "onMouseDownCapture",
  "onMouseEnterCapture",
  "onMouseLeaveCapture",
  "onMouseMoveCapture",
  "onMouseOutCapture",
  "onMouseOverCapture",
  "onMouseUpCapture",
  "onPasteCapture",
  "onPauseCapture",
  "onPlayCapture",
  "onPlayingCapture",
  "onPointerCancelCapture",
  "onPointerDownCapture",
  "onPointerEnterCapture",
  "onPointerLeaveCapture",
  "onPointerMoveCapture",
  "onPointerOutCapture",
  "onPointerOverCapture",
  "onPointerUpCapture",
  "onProgressCapture",
  "onRateChangeCapture",
  "onResetCapture",
  "onScrollCapture",
  "onSeekedCapture",
  "onSeekingCapture",
  "onSelectCapture",
  "onStalledCapture",
  "onSubmitCapture",
  "onSuspendCapture",
  "onTimeUpdateCapture",
  "onToggleCapture",
  "onTouchCancelCapture",
  "onTouchEndCapture",
  "onTouchMoveCapture",
  "onTouchStartCapture",
  "onTransitionEndCapture",
  "onVolumeChangeCapture",
  "onWaitingCapture",
  "onWheelCapture",
];

/**
 * The base class that all the enent collectors should be based upon.
 */
class MainEventCollector {
  /**
   * We allow displaying chrome events if the page is chrome or if
   * `devtools.chrome.enabled = true`.
   */
  get chromeEnabled() {
    if (typeof this._chromeEnabled === "undefined") {
      this._chromeEnabled = Services.prefs.getBoolPref(
        "devtools.chrome.enabled"
      );
    }

    return this._chromeEnabled;
  }

  /**
   * Check if a node has any event listeners attached. Please do not override
   * this method... your getListeners() implementation needs to have the
   * following signature:
   * `getListeners(node, {checkOnly} = {})`
   *
   * @param  {DOMNode} node
   *         The not for which we want to check for event listeners.
   * @return {boolean}
   *         true if the node has event listeners, false otherwise.
   */
  hasListeners(node) {
    return this.getListeners(node, {
      checkOnly: true,
    });
  }

  /**
   * Get all listeners for a node. This method must be overridden.
   *
   * @param  {DOMNode} node
   *         The not for which we want to get event listeners.
   * @param  {object} options
   *         An object for passing in options.
   * @param  {boolean} [options.checkOnly = false]
   *         Don't get any listeners but return true when the first event is
   *         found.
   * @return {Array}
   *         An array of event handlers.
   */
  getListeners(_node, { checkOnly: _checkOnly }) {
    throw new Error("You have to implement the method getListeners()!");
  }

  /**
   * Get unfiltered DOM Event listeners for a node.
   * NOTE: These listeners may contain invalid events and events based
   *       on C++ rather than JavaScript.
   *
   * @param  {DOMNode} node
   *         The node for which we want to get unfiltered event listeners.
   * @return {Array}
   *         An array of unfiltered event listeners or an empty array
   */
  getDOMListeners(node) {
    const listeners = [];
    const listenersTargets = [];

    if (
      typeof node.nodeName !== "undefined" &&
      node.nodeName.toLowerCase() === "html"
    ) {
      listenersTargets.push(node.documentGlobal, node, node.parentNode);
    } else {
      listenersTargets.push(node);
    }

    for (const el of listenersTargets) {
      const elListeners = Services.els.getListenerInfoFor(el);
      if (!elListeners) {
        continue;
      }
      for (const listener of elListeners) {
        const obj = this.unwrap(listener.listenerObject);
        if (obj && Cu.isDeadWrapper(obj)) {
          // Bug 2042101: some listenerObjects can be dead objects.
          continue;
        }

        if (!obj || !obj[EXCLUDED_LISTENER]) {
          listeners.push(listener);
        }
      }
    }

    return listeners;
  }

  getJQuery(node) {
    if (Cu.isDeadWrapper(node)) {
      return null;
    }

    const global = this.unwrap(node.documentGlobal);
    if (!global) {
      return null;
    }

    try {
      const hasJQuery = global.jQuery?.fn?.jquery;
      if (hasJQuery) {
        return global.jQuery;
      }
    } catch (e) {}
    return null;
  }

  unwrap(obj) {
    return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj;
  }

  isChromeHandler(handler) {
    try {
      const handlerPrincipal = Cu.getObjectPrincipal(handler);

      // Chrome codebase may register listeners on the page from a frame script or
      // JSM <video> tags may also report internal listeners, but they won't be
      // coming from the system principal. Instead, they will be using an expanded
      // principal.
      return (
        handlerPrincipal.isSystemPrincipal ||
        handlerPrincipal.isExpandedPrincipal
      );
    } catch (e) {
      // Anything from a dead object to a CSP error can leave us here so let's
      // return false so that we can fail gracefully.
      return false;
    }
  }
}

/**
 * Get or detect DOM events. These may include DOM events created by libraries
 * that enable their custom events to work. At this point we are unable to
 * effectively filter them as they may be proxied or wrapped. Although we know
 * there is an event, we may not know the true contents until it goes
 * through `processHandlerForEvent()`.
 */
class DOMEventCollector extends MainEventCollector {
  getListeners(node, { checkOnly } = {}) {
    const handlers = [];
    const listeners = this.getDOMListeners(node);

    for (const listener of listeners) {
      // Ignore listeners without a type, e.g.
      // node.addEventListener("", function() {})
      if (!listener.type) {
        continue;
      }

      // Get the listener object, either a Function or an Object.
      const obj = listener.listenerObject;

      // Ignore listeners without any listener, e.g.
      // node.addEventListener("mouseover", null);
      if (!obj) {
        continue;
      }

      let handler = null;

      // An object without a valid handleEvent is not a valid listener.
      if (typeof obj === "object") {
        const unwrapped = this.unwrap(obj);
        if (typeof unwrapped.handleEvent === "function") {
          handler = Cu.unwaiveXrays(unwrapped.handleEvent);
        }
      } else if (typeof obj === "function") {
        // Ignore DOM events used to trigger jQuery events as they are only
        // useful to the developers of the jQuery library.
        if (JQUERY_LIVE_REGEX.test(obj.toString())) {
          continue;
        }
        // Otherwise, the other valid listener type is function.
        handler = obj;
      }

      // Ignore listeners that have no handler.
      if (!handler) {
        continue;
      }

      // If we shouldn't be showing chrome events due to context and this is a
      // chrome handler we can ignore it.
      if (!this.chromeEnabled && this.isChromeHandler(handler)) {
        continue;
      }

      // If this is checking if a node has any listeners then we have found one
      // so return now.
      if (checkOnly) {
        return true;
      }

      const eventInfo = {
        nsIEventListenerInfo: listener,
        capturing: listener.capturing,
        type: listener.type,
        handler,
        enabled: listener.enabled,
      };

      handlers.push(eventInfo);
    }

    // If this is checking if a node has any listeners then none were found so
    // return false.
    if (checkOnly) {
      return false;
    }

    return handlers;
  }
}

/**
 * Get or detect jQuery events.
 */
class JQueryEventCollector extends MainEventCollector {
  // eslint-disable-next-line complexity
  getListeners(node, { checkOnly } = {}) {
    const jQuery = this.getJQuery(node);
    const handlers = [];

    if (!jQuery || node.isNativeAnonymous) {
      if (checkOnly) {
        return false;
      }
      return handlers;
    }

    let eventsObj = null;
    const data = jQuery._data || jQuery.data;

    if (data) {
      // jQuery 1.2+
      try {
        eventsObj = data(node, "events");
      } catch (e) {
        // We have no access to a JS object. This is probably due to a CORS
        // violation. Using try / catch is the only way to avoid this error.
      }
    } else {
      // JQuery 1.0 & 1.1
      let entry;
      try {
        entry = entry = jQuery(node)[0];
      } catch (e) {
        // We have no access to a JS object. This is probably due to a CORS
        // violation. Using try / catch is the only way to avoid this error.
      }

      if (!entry || !entry.events) {
        if (checkOnly) {
          return false;
        }
        return handlers;
      }

      eventsObj = entry.events;
    }

    if (eventsObj) {
      for (const type in eventsObj) {
        let events = eventsObj[type];
        // We can get arrays or objects. When we get the latter,
        // the events are the object values.
        if (!Array.isArray(events)) {
          events = Object.values(events);
        }
        for (const event of events) {
          // Skip events that are part of jQueries internals.
          if (node.nodeType == node.DOCUMENT_NODE && event.selector) {
            continue;
          }

          if (typeof event === "function" || typeof event === "object") {
            // If we shouldn't be showing chrome events due to context and this
            // is a chrome handler we can ignore it.
            const handler = event.handler || event;
            if (!this.chromeEnabled && this.isChromeHandler(handler)) {
              continue;
            }

            if (checkOnly) {
              return true;
            }

            const eventInfo = {
              type,
              handler,
              tags: "jQuery",
              hide: {
                capturing: true,
              },
            };

            handlers.push(eventInfo);
          }
        }
      }
    }

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

/**
 * Get or detect jQuery live events.
 */
class JQueryLiveEventCollector extends MainEventCollector {
  // eslint-disable-next-line complexity
  getListeners(node, { checkOnly } = {}) {
    const jQuery = this.getJQuery(node);
    const handlers = [];

    if (!jQuery) {
      if (checkOnly) {
        return false;
      }
      return handlers;
    }

    const jqueryData = jQuery._data || jQuery.data;

    if (jqueryData) {
      // Live events are added to the document and bubble up to all elements.
      // Any element matching the specified selector will trigger the live
      // event.
      const win = this.unwrap(node.documentGlobal);
      let events = null;

      try {
        events = jqueryData(win.document, "events");
      } catch (e) {
        // We have no access to a JS object. This is probably due to a CORS
        // violation. Using try / catch is the only way to avoid this error.
      }

      if (events && node.ownerDocument && node.matches) {
        for (const eventName in events) {
          const eventHolder = events[eventName];
          for (const idx in eventHolder) {
            if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) {
              continue;
            }

            const event = eventHolder[idx];
            let { selector, data } = event;

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

            if (!selector) {
              continue;
            }

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

            if (!matches) {
              continue;
            }

            if (typeof event === "function" || typeof event === "object") {
              // If we shouldn't be showing chrome events due to context and this
              // is a chrome handler we can ignore it.
              const handler = event.handler || event;
              if (!this.chromeEnabled && this.isChromeHandler(handler)) {
                continue;
              }

              if (checkOnly) {
                return true;
              }
              const eventInfo = {
                type: event.origType || event.type.substr(selector.length + 1),
                handler,
                tags: "jQuery,Live",
                hide: {
                  capturing: true,
                },
              };

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

              handlers.push(eventInfo);
            }
          }
        }
      }
    }

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

  normalizeListener(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/".
      const 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.
      const calleeDS = funcDO.environment?.calleeScript;
      if (!calleeDS) {
        return false;
      }
      const calleeName = calleeDS.displayName;
      return calleeName == "proxy";
    }

    function getFirstFunctionVariable(funcDO) {
      // The handler function inside the |proxy| function should point the
      // unwrapped function via environment variable.
      const names = funcDO.environment ? funcDO.environment.names() : [];
      for (const varName of names) {
        const varDO = handlerDO.environment
          ? handlerDO.environment.getVariable(varName)
          : null;
        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++) {
      const funcDO = getFirstFunctionVariable(handlerDO);
      if (!funcDO) {
        return handlerDO;
      }

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

    return handlerDO;
  }
}

/**
 * Get or detect React events.
 */
class ReactEventCollector extends MainEventCollector {
  getListeners(node, { checkOnly } = {}) {
    const handlers = [];
    const props = this.getProps(node);

    if (props) {
      for (const [name, prop] of Object.entries(props)) {
        if (REACT_EVENT_NAMES.includes(name)) {
          const listener = prop?.__reactBoundMethod || prop;

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

          if (!this.chromeEnabled && this.isChromeHandler(listener)) {
            continue;
          }

          if (checkOnly) {
            return true;
          }

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

          handlers.push(handler);
        }
      }
    }

    if (checkOnly) {
      return false;
    }

    return handlers;
  }

  getProps(node) {
    node = this.unwrap(node);

    for (const key of Object.keys(node)) {
      if (key.startsWith("__reactInternalInstance$")) {
        const value = node[key];
        if (value.memoizedProps) {
          return value.memoizedProps; // React 16
        }
        return value?._currentElement?.props; // React 15
      }
    }
    return null;
  }

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

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

    const script = handlerDO.script;
    // Script might be undefined (eg for methods bound several times, see
    // https://bugzilla.mozilla.org/show_bug.cgi?id=1589658)
    const introScript = script?.source.introductionScript;

    // 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";

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

      listener.override.handler = functionText;
    }

    return handlerDO;
  }
}

/**
 * The exposed class responsible for gathering events.
 */
class EventCollector {
  constructor(targetActor) {
    this.targetActor = targetActor;

    // The event collector array. Please preserve the order otherwise there will
    // be multiple failing tests.
    this.eventCollectors = [
      new ReactEventCollector(),
      new JQueryLiveEventCollector(),
      new JQueryEventCollector(),
      new DOMEventCollector(),
    ];
  }

  /**
   * Destructor (must be called manually).
   */
  destroy() {
    this.eventCollectors = null;
  }

  /**
   * Iterate through all event collectors returning on the first found event.
   *
   * @param  {DOMNode} node
   *         The node to be checked for events.
   * @return {boolean}
   *         True if the node has event listeners, false otherwise.
   */
  hasEventListeners(node) {
    for (const collector of this.eventCollectors) {
      if (collector.hasListeners(node)) {
        return true;
      }
    }

    return false;
  }

  /**
   * We allow displaying chrome events if the page is chrome or if
   * `devtools.chrome.enabled = true`.
   */
  get chromeEnabled() {
    if (typeof this._chromeEnabled === "undefined") {
      this._chromeEnabled = Services.prefs.getBoolPref(
        "devtools.chrome.enabled"
      );
    }

    return this._chromeEnabled;
  }

  /**
   *
   * @param  {DOMNode} node
   *         The node for which events are to be gathered.
   * @return {Array<object>}
   *         An array containing objects in the following format:
   *         {
   *           {String} type: The event type, e.g. "click"
   *           {Function} handler: The function called when event is triggered.
   *           {Boolean} enabled: Whether the listener is enabled or not (event listeners can
   *                     be disabled via the inspector)
   *           {String} tags: Comma separated list of tags displayed inside event bubble (e.g. "JQuery")
   *           {Object} hide: Flags for hiding certain properties.
   *             {Boolean} capturing
   *           }
   *           {Boolean} native
   *           {String|undefined} sourceActor: The sourceActor id of the event listener
   *           {nsIEventListenerInfo|undefined} nsIEventListenerInfo
   *         }
   */
  getEventListeners(node) {
    const listenerArray = [];
    let dbg;
    if (!this.chromeEnabled) {
      dbg = new Debugger();
    } else {
      // When the chrome pref is turned on, we may inspect DOM Elements from
      // privileged documents.
      // But since bug 1517210, the DevTools Server is also loaded in the shared
      // privileged global.
      // As the Debugger API requires to be used from distinct compartments
      // between the debuggee and the debugger modules,
      // we have to ensure spawning it from a distinct compartment.
      // That's what ChromeDebugger builtin module does.
      const ChromeDebugger = require("ChromeDebugger");
      dbg = new ChromeDebugger();
    }

    for (const collector of this.eventCollectors) {
      const listeners = collector.getListeners(node);

      if (!listeners) {
        continue;
      }

      for (const listener of listeners) {
        const eventObj = this.processHandlerForEvent(
          listener,
          dbg,
          collector.normalizeListener
        );
        if (eventObj) {
          listenerArray.push(eventObj);
        }
      }
    }

    listenerArray.sort((a, b) => {
      return a.type.localeCompare(b.type);
    });

    return listenerArray;
  }

  /**
   * Process an event listener.
   *
   * @param  {EventListener} listener
   *         The event listener to process.
   * @param  {Debugger} dbg
   *         Debugger instance.
   * @param  {Function|null} normalizeListener
   *         An optional function that will be called to retrieve data about the listener.
   *         It should be a *Collector method.
   *
   * @return {Array}
   *         An array of objects where a typical object looks like this:
   *           {
   *             type: "click",
   *             handler: function() { doSomething() },
   *             origin: "http://www.mozilla.com",
   *             tags: tags,
   *             capturing: true,
   *             hide: {
   *               capturing: true
   *             },
   *             native: false,
   *             enabled: true
   *             sourceActor: "sourceActor.1234",
   *             nsIEventListenerInfo: nsIEventListenerInfo {…},
   *             isUserDefined: false,
   *           }
   */
  // eslint-disable-next-line complexity
  processHandlerForEvent(listener, dbg, normalizeListener) {
    let globalDO;
    let eventObj;

    try {
      const { capturing, handler } = listener;

      const global = Cu.getGlobalForObject(handler);

      // It is important that we recreate the globalDO for each handler because
      // their global object can vary e.g. resource:// URLs on a video control. If
      // we don't do this then all chrome listeners simply display "native code."
      globalDO = dbg.addDebuggee(global);
      let listenerDO = globalDO.makeDebuggeeValue(handler);

      if (normalizeListener) {
        listenerDO = normalizeListener(listenerDO, listener);
      }

      const hide = listener.hide || {};
      const override = listener.override || {};
      const tags = listener.tags || "";
      const type = override.type || listener.type || "";
      const enabled = !!listener.enabled;
      let functionSource = handler.toString();
      let line = 0;
      let column = null;
      let native = false;
      let url = "";
      let sourceActor = "";

      // If the listener is an object with a 'handleEvent' method, use that.
      if (
        listenerDO.class === "Object" ||
        /^XUL\w*Element$/.test(listenerDO.class)
      ) {
        let desc;

        while (!desc && listenerDO) {
          desc = listenerDO.getOwnPropertyDescriptor("handleEvent");
          listenerDO = listenerDO.proto;
        }

        if (desc?.value) {
          listenerDO = desc.value;
        }
      }

      // If the listener is bound to a different context then we need to switch
      // to the bound function.
      if (listenerDO.isBoundFunction) {
        listenerDO = listenerDO.boundTargetFunction;
      }

      const { isArrowFunction, name, script, parameterNames } = listenerDO;

      if (script) {
        const scriptSource = script.source.text;

        // NOTE: Debugger.Script.prototype.startColumn is 1-based.
        //       Convert to 0-based, while keeping the wasm's column (1) as is.
        //       (bug 1863878)
        const columnBase = script.format === "wasm" ? 0 : 1;

        line = script.startLine;
        column = script.startColumn - columnBase;
        url = script.url;
        const actor = this.targetActor.sourcesManager.getOrCreateSourceActor(
          script.source
        );
        sourceActor = actor ? actor.actorID : null;

        // Checking for the string "[native code]" is the only way at this point
        // to check for native code. Even if this provides a false positive then
        // grabbing the source code a second time is harmless.
        if (
          functionSource === "[object Object]" ||
          functionSource === "[object XULElement]" ||
          functionSource.includes("[native code]")
        ) {
          functionSource = scriptSource.substr(
            script.sourceStart,
            script.sourceLength
          );

          // At this point the script looks like this:
          // () { ... }
          // We prefix this with "function" if it is not a fat arrow function.
          if (!isArrowFunction) {
            functionSource = "function " + functionSource;
          }
        }
      } else {
        // If the listener is a native one (provided by C++ code) then we have no
        // access to the script. We use the native flag to prevent showing the
        // debugger button because the script is not available.
        native = true;
      }

      // Arrow function text always contains the parameters. Function
      // parameters are often missing e.g. if Array.sort is used as a handler.
      // If they are missing we provide the parameters ourselves.
      if (parameterNames && parameterNames.length) {
        const prefix = "function " + name + "()";
        const paramString = parameterNames.join(", ");

        if (functionSource.startsWith(prefix)) {
          functionSource = functionSource.substr(prefix.length);

          functionSource = `function ${name} (${paramString})${functionSource}`;
        }
      }

      // If the listener is native code we display the filename "[native code]."
      // This is the official string and should *not* be translated.
      let origin;
      if (native) {
        origin = "[native code]";
      } else {
        origin =
          url +
          (line ? ":" + line + (column === null ? "" : ":" + column) : "");
      }

      eventObj = {
        type,
        handler: override.handler || functionSource.trim(),
        origin: override.origin || origin,
        tags: override.tags || tags,
        capturing:
          typeof override.capturing !== "undefined"
            ? override.capturing
            : capturing,
        hide: typeof override.hide !== "undefined" ? override.hide : hide,
        native,
        sourceActor,
        nsIEventListenerInfo: listener.nsIEventListenerInfo,
        enabled,
        isUserDefined: isUserDefinedEventName(type),
      };

      // Hide the debugger icon for DOM0 and native listeners. DOM0 listeners are
      // generated dynamically from e.g. an onclick="" attribute so the script
      // doesn't actually exist.
      if (!sourceActor) {
        eventObj.hide.debugger = true;
      }
    } finally {
      // Ensure that we always remove the debuggee.
      if (globalDO) {
        dbg.removeDebuggee(globalDO);
      }
    }

    return eventObj;
  }
}

exports.EventCollector = EventCollector;
