/* -*- 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 { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);

XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);

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

var { promiseDocumentLoaded } = ExtensionUtils;

/**
 * Represents an addon devtools panel in the child process.
 *
 * @param {DevtoolsExtensionContext}
 *   A devtools extension context running in a child process.
 * @param {object} panelOptions
 * @param {string} panelOptions.id
 *   The id of the addon devtools panel registered in the main process.
 */
class ChildDevToolsPanel extends ExtensionCommon.EventEmitter {
  constructor(context, { id }) {
    super();

    this.context = context;
    this.context.callOnClose(this);

    this.id = id;
    this._panelContext = null;

    this.conduit = context.openConduit(this, {
      recv: ["PanelHidden", "PanelShown"],
    });
  }

  get panelContext() {
    if (this._panelContext) {
      return this._panelContext;
    }

    for (let view of this.context.extension.devtoolsViews) {
      if (
        view.viewType === "devtools_panel" &&
        view.devtoolsToolboxInfo.toolboxPanelId === this.id
      ) {
        this._panelContext = view;

        // Reset the cached _panelContext property when the view is closed.
        view.callOnClose({
          close: () => {
            this._panelContext = null;
          },
        });
        return view;
      }
    }

    return null;
  }

  recvPanelShown() {
    // Ignore received call before the panel context exist.
    if (!this.panelContext || !this.panelContext.contentWindow) {
      return;
    }
    const { document } = this.panelContext.contentWindow;

    // Ensure that the onShown event is fired when the panel document has
    // been fully loaded.
    promiseDocumentLoaded(document).then(() => {
      this.emit("shown", this.panelContext.contentWindow);
    });
  }

  recvPanelHidden() {
    this.emit("hidden");
  }

  api() {
    return {
      onShown: new EventManager({
        context: this.context,
        name: "devtoolsPanel.onShown",
        register: fire => {
          const listener = (eventName, panelContentWindow) => {
            fire.asyncWithoutClone(panelContentWindow);
          };
          this.on("shown", listener);
          return () => {
            this.off("shown", listener);
          };
        },
      }).api(),

      onHidden: new EventManager({
        context: this.context,
        name: "devtoolsPanel.onHidden",
        register: fire => {
          const listener = () => {
            fire.async();
          };
          this.on("hidden", listener);
          return () => {
            this.off("hidden", listener);
          };
        },
      }).api(),

      // TODO(rpl): onSearch event and createStatusBarButton method
    };
  }

  close() {
    this._panelContext = null;
    this.context = null;
  }
}

/**
 * Represents an addon devtools inspector sidebar in the child process.
 *
 * @param {DevtoolsExtensionContext}
 *   A devtools extension context running in a child process.
 * @param {object} sidebarOptions
 * @param {string} sidebarOptions.id
 *   The id of the addon devtools sidebar registered in the main process.
 */
class ChildDevToolsInspectorSidebar extends ExtensionCommon.EventEmitter {
  constructor(context, { id }) {
    super();

    this.context = context;
    this.context.callOnClose(this);

    this.id = id;

    this.conduit = context.openConduit(this, {
      recv: ["InspectorSidebarHidden", "InspectorSidebarShown"],
    });
  }

  close() {
    this.context = null;
  }

  recvInspectorSidebarShown() {
    // TODO: wait and emit sidebar contentWindow once sidebar.setPage is supported.
    this.emit("shown");
  }

  recvInspectorSidebarHidden() {
    this.emit("hidden");
  }

  api() {
    const { context, id } = this;

    let extensionURL = new URL("/", context.uri.spec);

    // This is currently needed by sidebar.setPage because API objects are not automatically wrapped
    // by the API Schema validations and so the ExtensionURL type used in the JSON schema
    // doesn't have any effect on the parameter received by the setPage API method.
    function resolveExtensionURL(url) {
      let sidebarPageURL = new URL(url, context.uri.spec);

      if (
        extensionURL.protocol !== sidebarPageURL.protocol ||
        extensionURL.host !== sidebarPageURL.host
      ) {
        throw new context.cloneScope.Error(
          `Invalid sidebar URL: ${sidebarPageURL.href} is not a valid extension URL`
        );
      }

      return sidebarPageURL.href;
    }

    return {
      onShown: new EventManager({
        context,
        name: "devtoolsInspectorSidebar.onShown",
        register: fire => {
          const listener = (eventName, panelContentWindow) => {
            fire.asyncWithoutClone(panelContentWindow);
          };
          this.on("shown", listener);
          return () => {
            this.off("shown", listener);
          };
        },
      }).api(),

      onHidden: new EventManager({
        context,
        name: "devtoolsInspectorSidebar.onHidden",
        register: fire => {
          const listener = () => {
            fire.async();
          };
          this.on("hidden", listener);
          return () => {
            this.off("hidden", listener);
          };
        },
      }).api(),

      setPage(extensionPageURL) {
        let resolvedSidebarURL = resolveExtensionURL(extensionPageURL);

        return context.childManager.callParentAsyncFunction(
          "devtools.panels.elements.Sidebar.setPage",
          [id, resolvedSidebarURL]
        );
      },

      setObject(jsonObject, rootTitle) {
        return context.cloneScope.Promise.resolve().then(() => {
          return context.childManager.callParentAsyncFunction(
            "devtools.panels.elements.Sidebar.setObject",
            [id, jsonObject, rootTitle]
          );
        });
      },

      setExpression(evalExpression, rootTitle) {
        return context.cloneScope.Promise.resolve().then(() => {
          return context.childManager.callParentAsyncFunction(
            "devtools.panels.elements.Sidebar.setExpression",
            [id, evalExpression, rootTitle]
          );
        });
      },
    };
  }
}

this.devtools_panels = class extends ExtensionAPI {
  getAPI(context) {
    const themeChangeObserver =
      ExtensionChildDevToolsUtils.getThemeChangeObserver();

    return {
      devtools: {
        panels: {
          elements: {
            createSidebarPane(title) {
              // NOTE: this is needed to be able to return to the caller (the extension)
              // a promise object that it had the privileges to use (e.g. by marking this
              // method async we will return a promise object which can only be used by
              // chrome privileged code).
              return context.cloneScope.Promise.resolve().then(async () => {
                const sidebarId =
                  await context.childManager.callParentAsyncFunction(
                    "devtools.panels.elements.createSidebarPane",
                    [title]
                  );

                const sidebar = new ChildDevToolsInspectorSidebar(context, {
                  id: sidebarId,
                });

                const sidebarAPI = Cu.cloneInto(
                  sidebar.api(),
                  context.cloneScope,
                  { cloneFunctions: true }
                );

                return sidebarAPI;
              });
            },
          },
          create(title, icon, url) {
            // NOTE: this is needed to be able to return to the caller (the extension)
            // a promise object that it had the privileges to use (e.g. by marking this
            // method async we will return a promise object which can only be used by
            // chrome privileged code).
            return context.cloneScope.Promise.resolve().then(async () => {
              const panelId =
                await context.childManager.callParentAsyncFunction(
                  "devtools.panels.create",
                  [title, icon, url]
                );

              const devtoolsPanel = new ChildDevToolsPanel(context, {
                id: panelId,
              });

              const devtoolsPanelAPI = Cu.cloneInto(
                devtoolsPanel.api(),
                context.cloneScope,
                { cloneFunctions: true }
              );
              return devtoolsPanelAPI;
            });
          },
          get themeName() {
            return themeChangeObserver.themeName;
          },
          onThemeChanged: new EventManager({
            context,
            name: "devtools.panels.onThemeChanged",
            register: fire => {
              const listener = (eventName, themeName) => {
                fire.async(themeName);
              };
              themeChangeObserver.on("themeChanged", listener);
              return () => {
                themeChangeObserver.off("themeChanged", listener);
              };
            },
          }).api(),
        },
      },
    };
  }
};
