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

this.EXPORTED_SYMBOLS = ["LegacyExtensionsUtils"];

/* exported LegacyExtensionsUtils, LegacyExtensionContext */

/**
 * This file exports helpers for Legacy Extensions that want to embed a webextensions
 * and exchange messages with the embedded WebExtension.
 */

const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                  "resource://gre/modules/Extension.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                  "resource://gre/modules/Services.jsm");

Cu.import("resource://gre/modules/ExtensionChild.jsm");
Cu.import("resource://gre/modules/ExtensionCommon.jsm");

var {
  BaseContext,
} = ExtensionCommon;

var {
  Messenger,
} = ExtensionChild;

/**
 * Instances created from this class provide to a legacy extension
 * a simple API to exchange messages with a webextension.
 */
var LegacyExtensionContext = class extends BaseContext {
  /**
   * Create a new LegacyExtensionContext given a target Extension instance.
   *
   * @param {Extension} targetExtension
   *   The webextension instance associated with this context. This will be the
   *   instance of the newly created embedded webextension when this class is
   *   used through the EmbeddedWebExtensionsUtils.
   */
  constructor(targetExtension) {
    super("legacy_extension", targetExtension);

    // Legacy Extensions (xul overlays, bootstrap restartless and Addon SDK)
    // runs with a systemPrincipal.
    let addonPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
    Object.defineProperty(
      this, "principal",
      {value: addonPrincipal, enumerable: true, configurable: true}
    );

    let cloneScope = Cu.Sandbox(this.principal, {});
    Cu.setSandboxMetadata(cloneScope, {addonId: targetExtension.id});
    Object.defineProperty(
      this, "cloneScope",
      {value: cloneScope, enumerable: true, configurable: true, writable: true}
    );

    let sender = {id: targetExtension.id};
    let filter = {extensionId: targetExtension.id};
    // Legacy addons live in the main process. Messages from other addons are
    // Messages from WebExtensions are sent to the main process and forwarded via
    // the parent process manager to the legacy extension.
    this.messenger = new Messenger(this, [Services.cpmm], sender, filter);

    this.api = {
      browser: {
        runtime: {
          onConnect: this.messenger.onConnect("runtime.onConnect"),
          onMessage: this.messenger.onMessage("runtime.onMessage"),
        },
      },
    };
  }

  /**
   * This method is called when the extension shuts down or is unloaded,
   * and it nukes the cloneScope sandbox, if any.
   */
  unload() {
    if (this.unloaded) {
      throw new Error("Error trying to unload LegacyExtensionContext twice.");
    }
    super.unload();
    Cu.nukeSandbox(this.cloneScope);
    this.cloneScope = null;
  }
};

var EmbeddedExtensionManager;

/**
 * Instances of this class are used internally by the exported EmbeddedWebExtensionsUtils
 * to manage the embedded webextension instance and the related LegacyExtensionContext
 * instance used to exchange messages with it.
 */
class EmbeddedExtension {
  /**
   * Create a new EmbeddedExtension given the add-on id and the base resource URI of the
   * container add-on (the webextension resources will be loaded from the "webextension/"
   * subdir of the base resource URI for the legacy extension add-on).
   *
   * @param {Object} containerAddonParams
   *   An object with the following properties:
   * @param {string} containerAddonParams.id
   *   The Add-on id of the Legacy Extension which will contain the embedded webextension.
   * @param {nsIURI} containerAddonParams.resourceURI
   *   The nsIURI of the Legacy Extension container add-on.
   */
  constructor({id, resourceURI}) {
    this.addonId = id;
    this.resourceURI = resourceURI;

    // Setup status flag.
    this.started = false;
  }

  /**
   * Start the embedded webextension.
   *
   * @returns {Promise<LegacyContextAPI>} A promise which resolve to the API exposed to the
   *   legacy context.
   */
  startup() {
    if (this.started) {
      return Promise.reject(new Error("This embedded extension has already been started"));
    }

    // Setup the startup promise.
    this.startupPromise = new Promise((resolve, reject) => {
      let embeddedExtensionURI = Services.io.newURI("webextension/", null, this.resourceURI);

      // This is the instance of the WebExtension embedded in the hybrid add-on.
      this.extension = new Extension({
        id: this.addonId,
        resourceURI: embeddedExtensionURI,
      });

      // This callback is register to the "startup" event, emitted by the Extension instance
      // after the extension manifest.json has been loaded without any errors, but before
      // starting any of the defined contexts (which give the legacy part a chance to subscribe
      // runtime.onMessage/onConnect listener before the background page has been loaded).
      const onBeforeStarted = () => {
        this.extension.off("startup", onBeforeStarted);

        // Resolve the startup promise and reset the startupError.
        this.started = true;
        this.startupPromise = null;

        // Create the legacy extension context, the legacy container addon
        // needs to use it before the embedded webextension startup,
        // because it is supposed to be used during the legacy container startup
        // to subscribe its message listeners (which are supposed to be able to
        // receive any message that the embedded part can try to send to it
        // during its startup).
        this.context = new LegacyExtensionContext(this.extension);

        // Destroy the LegacyExtensionContext cloneScope when
        // the embedded webextensions is unloaded.
        this.extension.callOnClose({
          close: () => {
            this.context.unload();
          },
        });

        // resolve startupPromise to execute any pending shutdown that has been
        // chained to it.
        resolve(this.context.api);
      };

      this.extension.on("startup", onBeforeStarted);

      // Run ambedded extension startup and catch any error during embedded extension
      // startup.
      this.extension.startup().catch((err) => {
        this.started = false;
        this.startupPromise = null;
        this.extension.off("startup", onBeforeStarted);

        reject(err);
      });
    });

    return this.startupPromise;
  }

  /**
   * Shuts down the embedded webextension.
   *
   * @returns {Promise<void>} a promise that is resolved when the shutdown has been done
   */
  shutdown() {
    EmbeddedExtensionManager.untrackEmbeddedExtension(this);

    // If there is a pending startup,  wait to be completed and then shutdown.
    if (this.startupPromise) {
      return this.startupPromise.then(() => {
        this.extension.shutdown();
      });
    }

    // Run shutdown now if the embedded webextension has been correctly started
    if (this.extension && this.started && !this.extension.hasShutdown) {
      this.extension.shutdown();
    }

    return Promise.resolve();
  }
}

// Keep track on the created EmbeddedExtension instances and destroy
// them when their container addon is going to be disabled or uninstalled.
EmbeddedExtensionManager = {
  // Map of the existent EmbeddedExtensions instances by addon id.
  embeddedExtensionsByAddonId: new Map(),

  untrackEmbeddedExtension(embeddedExtensionInstance) {
    // Remove this instance from the tracked embedded extensions
    let id = embeddedExtensionInstance.addonId;
    if (this.embeddedExtensionsByAddonId.get(id) == embeddedExtensionInstance) {
      this.embeddedExtensionsByAddonId.delete(id);
    }
  },

  getEmbeddedExtensionFor({id, resourceURI}) {
    let embeddedExtension = this.embeddedExtensionsByAddonId.get(id);

    if (!embeddedExtension) {
      embeddedExtension = new EmbeddedExtension({id, resourceURI});
      // Keep track of the embedded extension instance.
      this.embeddedExtensionsByAddonId.set(id, embeddedExtension);
    }

    return embeddedExtension;
  },
};

this.LegacyExtensionsUtils = {
  getEmbeddedExtensionFor: (addon) => {
    return EmbeddedExtensionManager.getEmbeddedExtensionFor(addon);
  },
};