/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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";

var { ExtensionParent } = ChromeUtils.importESModule(
  "resource://gre/modules/ExtensionParent.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs",
});

var { watchExtensionProxyContextLoad } = ExtensionParent;

const WEBEXT_PANELS_URL = "chrome://browser/content/webext-panels.xhtml";

class BaseDevToolsPanel {
  constructor(context, panelOptions) {
    const toolbox = context.devToolsToolbox;
    if (!toolbox) {
      // This should never happen when this constructor is called with a valid
      // devtools extension context.
      throw Error("Missing mandatory toolbox");
    }

    this.context = context;
    this.extension = context.extension;
    this.toolbox = toolbox;
    this.viewType = "devtools_panel";
    this.panelOptions = panelOptions;
    this.id = panelOptions.id;

    this.unwatchExtensionProxyContextLoad = null;

    // References to the panel browser XUL element and the toolbox window global which
    // contains the devtools panel UI.
    this.browser = null;
    this.browserContainerWindow = null;
  }

  async createBrowserElement(window) {
    const { toolbox } = this;
    const { extension } = this.context;
    const { url } = this.panelOptions || { url: "about:blank" };

    this.browser = await window.getBrowser({
      extension,
      extensionUrl: url,
      browserStyle: false,
      viewType: "devtools_panel",
      browserInsertedData: {
        devtoolsToolboxInfo: {
          toolboxPanelId: this.id,
          inspectedWindowTabId: getTargetTabIdForToolbox(toolbox),
        },
      },
    });

    let hasTopLevelContext = false;

    // Listening to new proxy contexts.
    this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad(
      this,
      context => {
        // Keep track of the toolbox and target associated to the context, which is
        // needed by the API methods implementation.
        context.devToolsToolbox = toolbox;

        if (!hasTopLevelContext) {
          hasTopLevelContext = true;

          // Resolve the promise when the root devtools_panel context has been created.
          if (this._resolveTopLevelContext) {
            this._resolveTopLevelContext(context);
          }
        }
      }
    );

    this.syncToolboxZoom();
    this.toolbox.win.browsingContext.embedderElement.addEventListener(
      "FullZoomChange",
      this
    );

    this.browser.fixupAndLoadURIString(url, {
      triggeringPrincipal: this.context.principal,
    });
  }

  handleEvent(event) {
    switch (event.type) {
      case "FullZoomChange": {
        this.syncToolboxZoom();
        break;
      }
    }
  }

  /**
   * The remote `<browser>` that loads the panel content does not inherit
   * the zoom level of the `<browser>` it's nested inside of.
   *
   *   about:devtools-toolbox <browser> (zoom level applied here)
   *     - ...
   *     - webext-panels.xhtml <iframe> (inherits zoom)
   *       - <browser remote="true"> (doesn't inherit zoom)
   *
   * To work around this, we manually synchronize the zoom levels.
   */
  syncToolboxZoom() {
    if (!this.browser) {
      return;
    }

    this.browser.fullZoom = this.toolbox.win.browsingContext.fullZoom;
  }

  destroyBrowserElement() {
    const { browser, unwatchExtensionProxyContextLoad } = this;
    if (unwatchExtensionProxyContextLoad) {
      this.unwatchExtensionProxyContextLoad = null;
      unwatchExtensionProxyContextLoad();
    }

    if (this.toolbox) {
      this.toolbox.win.browsingContext.embedderElement.removeEventListener(
        "FullZoomChange",
        this
      );
    }

    if (browser) {
      browser.remove();
      this.browser = null;
    }
  }
}

/**
 * Represents an addon devtools panel in the main process.
 */
class ParentDevToolsPanel extends BaseDevToolsPanel {
  /**
   * @param {DevToolsExtensionPageContextParent} context
   *        A devtools extension proxy context running in a main process.
   * @param {object} panelOptions
   * @param {string} panelOptions.id
   *        The id of the addon devtools panel.
   * @param {string} panelOptions.icon
   *        The icon of the addon devtools panel.
   * @param {string} panelOptions.title
   *        The title of the addon devtools panel.
   * @param {string} panelOptions.url
   *        The url of the addon devtools panel, relative to the extension base URL.
   */
  constructor(context, panelOptions) {
    super(context, panelOptions);

    this.visible = false;
    this.destroyed = false;

    this.context.callOnClose(this);

    this.conduit = new BroadcastConduit(this, {
      id: `${this.id}-parent`,
      send: ["PanelHidden", "PanelShown"],
    });

    this.onToolboxPanelSelect = this.onToolboxPanelSelect.bind(this);
    this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this);
    this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this);

    this.waitTopLevelContext = new Promise(resolve => {
      this._resolveTopLevelContext = resolve;
    });

    this.panelAdded = false;
    this.addPanel();
  }

  addPanel() {
    const { icon, title } = this.panelOptions;
    const extensionName = this.context.extension.name;

    this.toolbox.addAdditionalTool({
      id: this.id,
      extensionId: this.context.extension.id,
      url: WEBEXT_PANELS_URL,
      icon: icon,
      label: title,
      // panelLabel is used to set the aria-label attribute (See Bug 1570645).
      panelLabel: title,
      tooltip: `DevTools Panel added by "${extensionName}" add-on.`,
      isToolSupported: toolbox => toolbox.commands.descriptorFront.isLocalTab,
      build: (window, toolbox) => {
        if (toolbox !== this.toolbox) {
          throw new Error(
            "Unexpected toolbox received on addAdditionalTool build property"
          );
        }

        const destroy = this.buildPanel(window);

        return { toolbox, destroy };
      },
    });

    this.panelAdded = true;
  }

  buildPanel(window) {
    const { toolbox } = this;

    this.createBrowserElement(window);

    // Store the last panel's container element (used to restore it when the toolbox
    // host is switched between docked and undocked).
    this.browserContainerWindow = window;

    toolbox.on("select", this.onToolboxPanelSelect);
    toolbox.on("host-will-change", this.onToolboxHostWillChange);
    toolbox.on("host-changed", this.onToolboxHostChanged);

    // Return a cleanup method that is when the panel is destroyed, e.g.
    // - when addon devtool panel has been disabled by the user from the toolbox preferences,
    //   its ParentDevToolsPanel instance is still valid, but the built devtools panel is removed from
    //   the toolbox (and re-built again if the user re-enables it from the toolbox preferences panel)
    // - when the creator context has been destroyed, the ParentDevToolsPanel close method is called,
    //   it removes the tool definition from the toolbox, which will call this destroy method.
    return () => {
      this.destroyBrowserElement();
      this.browserContainerWindow = null;
      toolbox.off("select", this.onToolboxPanelSelect);
      toolbox.off("host-will-change", this.onToolboxHostWillChange);
      toolbox.off("host-changed", this.onToolboxHostChanged);
    };
  }

  onToolboxHostWillChange() {
    // NOTE: Using a content iframe here breaks the devtools panel
    // switching between docked and undocked mode,
    // because of a swapFrameLoader exception (see bug 1075490),
    // destroy the browser and recreate it after the toolbox host has been
    // switched is a reasonable workaround to fix the issue on release and beta
    // Firefox versions (at least until the underlying bug can be fixed).
    if (this.browser) {
      // Fires a panel.onHidden event before destroying the browser element because
      // the toolbox hosts is changing.
      if (this.visible) {
        this.conduit.sendPanelHidden(this.id);
      }

      this.destroyBrowserElement();
    }
  }

  async onToolboxHostChanged() {
    if (this.browserContainerWindow) {
      this.createBrowserElement(this.browserContainerWindow);

      // Fires a panel.onShown event once the browser element has been recreated
      // after the toolbox hosts has been changed (needed to provide the new window
      // object to the extension page that has created the devtools panel).
      if (this.visible) {
        await this.waitTopLevelContext;
        this.conduit.sendPanelShown(this.id);
      }
    }
  }

  async onToolboxPanelSelect(id) {
    if (!this.waitTopLevelContext || !this.panelAdded) {
      return;
    }

    // Wait that the panel is fully loaded and emit show.
    await this.waitTopLevelContext;

    if (!this.visible && id === this.id) {
      this.visible = true;
      this.conduit.sendPanelShown(this.id);
    } else if (this.visible && id !== this.id) {
      this.visible = false;
      this.conduit.sendPanelHidden(this.id);
    }
  }

  close() {
    const { toolbox } = this;

    if (!toolbox) {
      throw new Error("Unable to destroy a closed devtools panel");
    }

    this.conduit.close();

    // Explicitly remove the panel if it is registered and the toolbox is not
    // closing itself.
    if (this.panelAdded && toolbox.isToolRegistered(this.id)) {
      this.destroyBrowserElement();
      toolbox.removeAdditionalTool(this.id);
    }

    this.waitTopLevelContext = null;
    this._resolveTopLevelContext = null;
    this.context = null;
    this.toolbox = null;
    this.browser = null;
    this.browserContainerWindow = null;
  }

  destroyBrowserElement() {
    super.destroyBrowserElement();

    // If the panel has been removed or disabled (e.g. from the toolbox preferences
    // or during the toolbox switching between docked and undocked),
    // we need to re-initialize the waitTopLevelContext Promise.
    this.waitTopLevelContext = new Promise(resolve => {
      this._resolveTopLevelContext = resolve;
    });
  }
}

class DevToolsSelectionObserver extends EventEmitter {
  constructor(context) {
    if (!context.devToolsToolbox) {
      // This should never happen when this constructor is called with a valid
      // devtools extension context.
      throw Error("Missing mandatory toolbox");
    }

    super();
    context.callOnClose(this);

    this.toolbox = context.devToolsToolbox;
    this.onSelected = this.onSelected.bind(this);
    this.initialized = false;
  }

  on(...args) {
    this.lazyInit();
    super.on.apply(this, args);
  }

  once(...args) {
    this.lazyInit();
    super.once.apply(this, args);
  }

  async lazyInit() {
    if (!this.initialized) {
      this.initialized = true;
      this.toolbox.on("selection-changed", this.onSelected);
    }
  }

  close() {
    if (this.destroyed) {
      throw new Error("Unable to close a destroyed DevToolsSelectionObserver");
    }

    if (this.initialized) {
      this.toolbox.off("selection-changed", this.onSelected);
    }

    this.toolbox = null;
    this.destroyed = true;
  }

  onSelected() {
    this.emit("selectionChanged");
  }
}

/**
 * Represents an addon devtools inspector sidebar in the main process.
 */
class ParentDevToolsInspectorSidebar extends BaseDevToolsPanel {
  /**
   * @param {DevToolsExtensionPageContextParent} context
   *        A devtools extension proxy context running in a main process.
   * @param {object} panelOptions
   * @param {string} panelOptions.id
   *        The id of the addon devtools sidebar.
   * @param {string} panelOptions.title
   *        The title of the addon devtools sidebar.
   */
  constructor(context, panelOptions) {
    super(context, panelOptions);

    this.visible = false;
    this.destroyed = false;

    this.context.callOnClose(this);

    this.conduit = new BroadcastConduit(this, {
      id: `${this.id}-parent`,
      send: ["InspectorSidebarHidden", "InspectorSidebarShown"],
    });

    this.onSidebarSelect = this.onSidebarSelect.bind(this);
    this.onSidebarCreated = this.onSidebarCreated.bind(this);
    this.onExtensionPageMount = this.onExtensionPageMount.bind(this);
    this.onExtensionPageUnmount = this.onExtensionPageUnmount.bind(this);
    this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this);
    this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this);

    this.toolbox.once(
      `extension-sidebar-created-${this.id}`,
      this.onSidebarCreated
    );
    this.toolbox.on("inspector-sidebar-select", this.onSidebarSelect);
    this.toolbox.on("host-will-change", this.onToolboxHostWillChange);
    this.toolbox.on("host-changed", this.onToolboxHostChanged);

    // Set by setObject if the sidebar has not been created yet.
    this._initializeSidebar = null;

    // Set by _updateLastExpressionResult to keep track of the last
    // object value grip (to release the previous selected actor
    // on the remote debugging server when the actor changes).
    this._lastExpressionResult = null;

    this.toolbox.registerInspectorExtensionSidebar(this.id, {
      title: panelOptions.title,
    });
  }

  close() {
    if (this.destroyed) {
      throw new Error("Unable to close a destroyed DevToolsSelectionObserver");
    }

    this.conduit.close();

    if (this.extensionSidebar) {
      this.extensionSidebar.off(
        "extension-page-mount",
        this.onExtensionPageMount
      );
      this.extensionSidebar.off(
        "extension-page-unmount",
        this.onExtensionPageUnmount
      );
    }

    if (this.browser) {
      this.destroyBrowserElement();
      this.browser = null;
      this.containerEl = null;
    }

    this.toolbox.off(
      `extension-sidebar-created-${this.id}`,
      this.onSidebarCreated
    );
    this.toolbox.off("inspector-sidebar-select", this.onSidebarSelect);
    this.toolbox.off("host-changed", this.onToolboxHostChanged);
    this.toolbox.off("host-will-change", this.onToolboxHostWillChange);

    this.toolbox.unregisterInspectorExtensionSidebar(this.id);
    this.extensionSidebar = null;
    this._lazySidebarInit = null;

    this.destroyed = true;
  }

  onToolboxHostWillChange() {
    if (this.browser) {
      this.destroyBrowserElement();
    }
  }

  onToolboxHostChanged() {
    if (this.containerEl && this.panelOptions.url) {
      this.createBrowserElement(this.containerEl.contentWindow);
    }
  }

  onExtensionPageMount(containerEl) {
    this.containerEl = containerEl;

    // Wait the webext-panel.xhtml page to have been loaded in the
    // inspector sidebar panel.
    const onLoaded = () => {
      this.createBrowserElement(containerEl.contentWindow);
    };
    // ExtensionUtils.promiseDocumentLoaded would attach a load listener to the
    // container window, which will be replaced during the load (Bug 1955324).
    const doc = containerEl.contentDocument;
    if (doc.readyState == "complete" && doc.location.href != "about:blank") {
      onLoaded();
    } else {
      containerEl.addEventListener("load", onLoaded, { once: true });
    }
  }

  onExtensionPageUnmount() {
    this.containerEl = null;
    this.destroyBrowserElement();
  }

  onSidebarCreated(sidebar) {
    this.extensionSidebar = sidebar;

    sidebar.on("extension-page-mount", this.onExtensionPageMount);
    sidebar.on("extension-page-unmount", this.onExtensionPageUnmount);

    const { _lazySidebarInit } = this;
    this._lazySidebarInit = null;

    if (typeof _lazySidebarInit === "function") {
      _lazySidebarInit();
    }
  }

  onSidebarSelect(id) {
    if (!this.extensionSidebar) {
      return;
    }

    if (!this.visible && id === this.id) {
      this.visible = true;
      this.conduit.sendInspectorSidebarShown(this.id);
    } else if (this.visible && id !== this.id) {
      this.visible = false;
      this.conduit.sendInspectorSidebarHidden(this.id);
    }
  }

  setPage(extensionPageURL) {
    this.panelOptions.url = extensionPageURL;

    if (this.extensionSidebar) {
      if (this.browser) {
        // Just load the new extension page url in the existing browser, if
        // it already exists.
        this.browser.fixupAndLoadURIString(this.panelOptions.url, {
          triggeringPrincipal: this.context.extension.principal,
        });
      } else {
        // The browser element doesn't exist yet, but the sidebar has been
        // already created (e.g. because the inspector was already selected
        // in a open toolbox and the extension has been installed/reloaded/updated).
        this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL);
      }
    } else {
      // Defer the sidebar.setExtensionPage call.
      this._setLazySidebarInit(() =>
        this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL)
      );
    }
  }

  setObject(object, rootTitle) {
    delete this.panelOptions.url;

    this._updateLastExpressionResult(null);

    // Nest the object inside an object, as the value of the `rootTitle` property.
    if (rootTitle) {
      object = { [rootTitle]: object };
    }

    if (this.extensionSidebar) {
      this.extensionSidebar.setObject(object);
    } else {
      // Defer the sidebar.setObject call.
      this._setLazySidebarInit(() => this.extensionSidebar.setObject(object));
    }
  }

  _setLazySidebarInit(cb) {
    this._lazySidebarInit = cb;
  }

  setExpressionResult(expressionResult, rootTitle) {
    delete this.panelOptions.url;

    this._updateLastExpressionResult(expressionResult);

    if (this.extensionSidebar) {
      this.extensionSidebar.setExpressionResult(expressionResult, rootTitle);
    } else {
      // Defer the sidebar.setExpressionResult call.
      this._setLazySidebarInit(() => {
        this.extensionSidebar.setExpressionResult(expressionResult, rootTitle);
      });
    }
  }

  _updateLastExpressionResult(newExpressionResult = null) {
    const { _lastExpressionResult } = this;

    this._lastExpressionResult = newExpressionResult;

    const oldActor = _lastExpressionResult && _lastExpressionResult.actorID;
    const newActor = newExpressionResult && newExpressionResult.actorID;

    // Release the previously active actor on the remote debugging server.
    if (
      oldActor &&
      oldActor !== newActor &&
      typeof _lastExpressionResult.release === "function"
    ) {
      _lastExpressionResult.release();
    }
  }
}

const sidebarsById = new Map();

this.devtools_panels = class extends ExtensionAPI {
  getAPI(context) {
    // TODO - Bug 1448878: retrieve a more detailed callerInfo object,
    // like the filename and lineNumber of the actual extension called
    // in the child process.
    const callerInfo = {
      addonId: context.extension.id,
      url: context.extension.baseURI.spec,
    };

    // An incremental "per context" id used in the generated devtools panel id.
    let nextPanelId = 0;

    const toolboxSelectionObserver = new DevToolsSelectionObserver(context);

    function newBasePanelId() {
      return `${context.extension.id}-${context.contextId}-${nextPanelId++}`;
    }

    return {
      devtools: {
        panels: {
          elements: {
            onSelectionChanged: new EventManager({
              context,
              name: "devtools.panels.elements.onSelectionChanged",
              register: fire => {
                const listener = () => {
                  fire.async();
                };
                toolboxSelectionObserver.on("selectionChanged", listener);
                return () => {
                  toolboxSelectionObserver.off("selectionChanged", listener);
                };
              },
            }).api(),
            createSidebarPane(title) {
              const id = `devtools-inspector-sidebar-${makeWidgetId(
                newBasePanelId()
              )}`;

              const parentSidebar = new ParentDevToolsInspectorSidebar(
                context,
                { title, id }
              );
              sidebarsById.set(id, parentSidebar);

              context.callOnClose({
                close() {
                  sidebarsById.delete(id);
                },
              });

              // Resolved to the devtools sidebar id into the child addon process,
              // where it will be used to identify the messages related
              // to the panel API onShown/onHidden events.
              return Promise.resolve(id);
            },
            // The following methods are used internally to allow the sidebar API
            // piece that is running in the child process to asks the parent process
            // to execute the sidebar methods.
            Sidebar: {
              setPage(sidebarId, extensionPageURL) {
                const sidebar = sidebarsById.get(sidebarId);
                return sidebar.setPage(extensionPageURL);
              },
              setObject(sidebarId, jsonObject, rootTitle) {
                const sidebar = sidebarsById.get(sidebarId);
                return sidebar.setObject(jsonObject, rootTitle);
              },
              async setExpression(sidebarId, evalExpression, rootTitle) {
                const sidebar = sidebarsById.get(sidebarId);

                const toolboxEvalOptions = await getToolboxEvalOptions(context);

                const commands = await context.getDevToolsCommands();
                const target = commands.targetCommand.targetFront;
                const consoleFront = await target.getFront("console");
                toolboxEvalOptions.consoleFront = consoleFront;

                const evalResult = await commands.inspectedWindowCommand.eval(
                  callerInfo,
                  evalExpression,
                  toolboxEvalOptions
                );

                let jsonObject;

                if (evalResult.exceptionInfo) {
                  jsonObject = evalResult.exceptionInfo;

                  return sidebar.setObject(jsonObject, rootTitle);
                }

                return sidebar.setExpressionResult(evalResult, rootTitle);
              },
            },
          },
          create(title, icon, url) {
            // Get a fallback icon from the manifest data.
            if (icon === "") {
              icon = context.extension.getPreferredIcon(128);
            }

            icon = context.extension.baseURI.resolve(icon);
            url = context.extension.baseURI.resolve(url);

            const id = `webext-devtools-panel-${makeWidgetId(
              newBasePanelId()
            )}`;

            new ParentDevToolsPanel(context, { title, icon, url, id });

            // Resolved to the devtools panel id into the child addon process,
            // where it will be used to identify the messages related
            // to the panel API onShown/onHidden events.
            return Promise.resolve(id);
          },
        },
      },
    };
  }
};
