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

const lazy = {};
ChromeUtils.defineLazyGetter(lazy, "DevToolsStartup", () => {
  return Cc["@mozilla.org/devtools/startup-clh;1"].getService(
    Ci.nsICommandLineHandler
  ).wrappedJSObject;
});

// We don't want to spend time initializing the full loader here so we create
// our own lazy require.
ChromeUtils.defineLazyGetter(lazy, "Telemetry", function () {
  const { require } = ChromeUtils.importESModule(
    "resource://devtools/shared/loader/Loader.sys.mjs"
  );
  // eslint-disable-next-line no-shadow
  const Telemetry = require("devtools/client/shared/telemetry");

  return Telemetry;
});

const DEVTOOLS_POLICY_DISABLED_PREF = "devtools.policy.disabled";

function removeItem(array, callback) {
  const index = array.findIndex(callback);
  if (index >= 0) {
    array.splice(index, 1);
  }
}

/**
 * DevToolsShim is a singleton that provides a set of helpers to interact with DevTools,
 * that work whether Devtools are enabled or not.
 *
 * It can be used to start listening to devtools events before DevTools are ready. As soon
 * as DevTools are ready, the DevToolsShim will forward all the requests received until
 * then to the real DevTools instance.
 */
export const DevToolsShim = {
  _gDevTools: null,
  listeners: [],

  get telemetry() {
    if (!this._telemetry) {
      this._telemetry = new lazy.Telemetry();
    }
    return this._telemetry;
  },

  /**
   * Returns true if DevTools are enabled. This now only depends on the policy.
   * TODO: Merge isEnabled and isDisabledByPolicy.
   *
   * @returns {boolean}
   */
  isEnabled() {
    return !this.isDisabledByPolicy();
  },

  /**
   * Returns true if the devtools are completely disabled and can not be enabled. All
   * entry points should return without throwing, initDevTools should never be called.
   *
   * @returns {boolean}
   */
  isDisabledByPolicy() {
    return Services.prefs.getBoolPref(DEVTOOLS_POLICY_DISABLED_PREF, false);
  },

  /**
   * Check if DevTools have already been initialized.
   *
   * @returns {boolean} true if DevTools are initialized.
   */
  isInitialized() {
    return !!this._gDevTools;
  },

  /**
   * Returns the array of the existing toolboxes. This method is part of the compatibility
   * layer for webextensions.
   *
   * @returns {Array<Toolbox>}
   *   An array of toolboxes.
   */
  getToolboxes() {
    if (this.isInitialized()) {
      return this._gDevTools.getToolboxes();
    }

    return [];
  },

  /**
   * Register an instance of gDevTools. Should be called by DevTools during startup.
   *
   * @param {DevTools} gDevTools - A DevTools instance (from client/framework/devtools)
   */
  register(gDevTools) {
    this._gDevTools = gDevTools;
    this._onDevToolsRegistered();
    this._gDevTools.emit("devtools-registered");
  },

  /**
   * Unregister the current instance of gDevTools. Should be called by DevTools during
   * shutdown.
   */
  unregister() {
    if (this.isInitialized()) {
      this._gDevTools.emit("devtools-unregistered");
      this._gDevTools = null;
    }
  },

  /**
   * The following methods can be called before DevTools are initialized:
   * - on
   * - off
   *
   * If DevTools are not initialized when calling the method, DevToolsShim will call the
   * appropriate method as soon as a gDevTools instance is registered.
   */

  /**
   * This method is used by browser/components/extensions/ext-devtools.js for the events:
   * - toolbox-ready
   * - toolbox-destroyed
   *
   * @param {string} event
   * @param {Function} listener
   */
  on(event, listener) {
    if (this.isInitialized()) {
      this._gDevTools.on(event, listener);
    } else {
      this.listeners.push([event, listener]);
    }
  },

  /**
   * This method is currently only used by devtools code, but is kept here for consistency
   * with on().
   *
   * @param {string} event
   * @param {Function} listener
   */
  off(event, listener) {
    if (this.isInitialized()) {
      this._gDevTools.off(event, listener);
    } else {
      removeItem(this.listeners, ([e, l]) => e === event && l === listener);
    }
  },

  /**
   * Called from SessionStore.sys.mjs in mozilla-central when saving the current state.
   *
   * @param {object} state - A SessionStore state object that gets modified by reference
   */
  saveDevToolsSession(state) {
    if (!this.isInitialized()) {
      return;
    }

    this._gDevTools.saveDevToolsSession(state);
  },

  /**
   * Called from SessionStore.sys.mjs in mozilla-central when restoring a previous session.
   * Will always be called, even if the session does not contain DevTools related items.
   *
   * @param {object} session
   */
  restoreDevToolsSession(session) {
    if (!this.isEnabled()) {
      return;
    }

    const { browserConsole, browserToolbox } = session;
    const hasDevToolsData = browserConsole || browserToolbox;
    if (!hasDevToolsData) {
      // Do not initialize DevTools unless there is DevTools specific data in the session.
      return;
    }

    this.initDevTools("SessionRestore");
    this._gDevTools.restoreDevToolsSession(session);
  },

  isDevToolsUser() {
    return lazy.DevToolsStartup.isDevToolsUser();
  },

  /**
   * Called from nsContextMenu.js in mozilla-central when using the Inspect Accessibility
   * context menu item.
   *
   * @param {XULTab} tab
   *        The browser tab on which inspect accessibility was used.
   * @param {ElementIdentifier} domReference
   *        Identifier generated by ContentDOMReference. It is a unique pair of
   *        BrowsingContext ID and a numeric ID.
   * @returns {Promise} a promise that resolves when the accessible node is selected in the
   *         accessibility inspector or that resolves immediately if DevTools are not
   *         enabled.
   */
  inspectA11Y(tab, domReference) {
    if (!this.isEnabled()) {
      return Promise.resolve();
    }

    // Record the timing at which this event started in order to compute later in
    // gDevTools.showToolbox, the complete time it takes to open the toolbox.
    // i.e. especially take `DevToolsStartup.initDevTools` into account.
    const startTime = ChromeUtils.now();

    this.initDevTools("ContextMenu");

    return this._gDevTools.inspectA11Y(tab, domReference, startTime);
  },

  /**
   * Called from nsContextMenu.js in mozilla-central when using the Inspect Element
   * context menu item.
   *
   * @param {XULTab} tab
   *        The browser tab on which inspect node was used.
   * @param {ElementIdentifier} domReference
   *        Identifier generated by ContentDOMReference. It is a unique pair of
   *        BrowsingContext ID and a numeric ID.
   * @returns {Promise} a promise that resolves when the node is selected in the inspector
   *         markup view or that resolves immediately if DevTools are not enabled.
   */
  inspectNode(tab, domReference) {
    if (!this.isEnabled()) {
      return Promise.resolve();
    }

    // Record the timing at which this event started in order to compute later in
    // gDevTools.showToolbox, the complete time it takes to open the toolbox.
    // i.e. especially take `DevToolsStartup.initDevTools` into account.
    const startTime = ChromeUtils.now();

    this.initDevTools("ContextMenu");

    return this._gDevTools.inspectNode(tab, domReference, startTime);
  },

  _onDevToolsRegistered() {
    // Register all pending event listeners on the real gDevTools object.
    for (const [event, listener] of this.listeners) {
      this._gDevTools.on(event, listener);
    }

    this.listeners = [];
  },

  /**
   * Initialize DevTools via DevToolsStartup if needed. This method throws if DevTools are
   * not enabled.
   *
   * @param {string} [reason]
   *        optional, if provided should be a valid entry point for DEVTOOLS_ENTRY_POINT
   *        in toolkit/components/telemetry/Histograms.json
   *        and devtools:entry_point in devtools/client/shared/metrics.yaml
   */
  initDevTools(reason) {
    if (!this.isEnabled()) {
      throw new Error("DevTools are not enabled and can not be initialized.");
    }

    if (reason) {
      const window = Services.wm.getMostRecentBrowserWindow();

      this.telemetry.addEventProperty(
        window,
        "open",
        "tools",
        null,
        "shortcut",
        ""
      );
      this.telemetry.addEventProperty(
        window,
        "open",
        "tools",
        null,
        "entrypoint",
        reason
      );
    }

    if (!this.isInitialized()) {
      lazy.DevToolsStartup.initDevTools(reason);
    }
  },
};

/**
 * Compatibility layer for webextensions.
 *
 * Those methods are called only after a DevTools webextension was loaded in DevTools,
 * therefore DevTools should always be available when they are called.
 */
const webExtensionsMethods = [
  "createCommandsForTabForWebExtension",
  "getTheme",
  "openBrowserConsole",
];

/**
 * Compatibility layer for other third parties.
 */
const otherToolMethods = [
  // gDevTools.showToolboxForTab is used by wptrunner to start devtools
  // https://github.com/web-platform-tests/wpt
  // And also, Quick Actions on URL bar.
  "showToolboxForTab",
  // Used for Quick Actions on URL bar.
  "hasToolboxForTab",
  // Used for Quick Actions test.
  "getToolboxForTab",
];

for (const method of [...webExtensionsMethods, ...otherToolMethods]) {
  DevToolsShim[method] = function () {
    if (!this.isEnabled()) {
      throw new Error(
        "Could not call a DevToolsShim webextension method ('" +
          method +
          "'): DevTools are not initialized."
      );
    }

    this.initDevTools();
    return this._gDevTools[method].apply(this._gDevTools, arguments);
  };
}
