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

import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs";

const lazy = {};
ChromeUtils.defineESModuleGetters(
  lazy,
  {
    ContentScriptTargetWatcher:
      "resource://devtools/server/connectors/js-process-actor/target-watchers/content_script.sys.mjs",
    ProcessTargetWatcher:
      "resource://devtools/server/connectors/js-process-actor/target-watchers/process.sys.mjs",
    SessionDataHelpers:
      "resource://devtools/server/actors/watcher/SessionDataHelpers.sys.mjs",
    ServiceWorkerTargetWatcher:
      "resource://devtools/server/connectors/js-process-actor/target-watchers/service_worker.sys.mjs",
    SharedWorkerTargetWatcher:
      "resource://devtools/server/connectors/js-process-actor/target-watchers/shared_worker.sys.mjs",
    WorkerTargetWatcher:
      "resource://devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs",
    WindowGlobalTargetWatcher:
      "resource://devtools/server/connectors/js-process-actor/target-watchers/window-global.sys.mjs",
  },
  { global: "contextual" }
);

// TargetActorRegistery has to be shared between all devtools instances
// and so is loaded into the shared global.
ChromeUtils.defineESModuleGetters(
  lazy,
  {
    TargetActorRegistry:
      "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs",
  },
  { global: "shared" }
);

export class DevToolsProcessChild extends JSProcessActorChild {
  constructor() {
    super();

    // The EventEmitter interface is used for DevToolsTransport's packet-received event.
    EventEmitter.decorate(this);
  }

  #watchers = {
    // Keys are target types, which are defined in this CommonJS Module:
    // https://searchfox.org/mozilla-central/rev/0e9ea50a999420d93df0e4e27094952af48dd3b8/devtools/server/actors/targets/index.js#7-14
    // We avoid loading it as this ESM should be lightweight and avoid spawning DevTools CommonJS Loader until
    // whe know we have to instantiate a Target Actor.
    frame: {
      // Number of active watcher actors currently watching for the given target type
      activeListener: 0,

      // Instance of a target watcher class whose task is to observe new target instances
      get watcher() {
        return lazy.WindowGlobalTargetWatcher;
      },
    },

    process: {
      activeListener: 0,
      get watcher() {
        return lazy.ProcessTargetWatcher;
      },
    },

    worker: {
      activeListener: 0,
      get watcher() {
        return lazy.WorkerTargetWatcher;
      },
    },

    service_worker: {
      activeListener: 0,
      get watcher() {
        return lazy.ServiceWorkerTargetWatcher;
      },
    },

    shared_worker: {
      activeListener: 0,
      get watcher() {
        return lazy.SharedWorkerTargetWatcher;
      },
    },

    content_script: {
      activeListener: 0,
      get watcher() {
        return lazy.ContentScriptTargetWatcher;
      },
    },
  };

  #initialized = false;

  /**
   * Called when this JSProcess Actor instantiate either when we start observing for first target types,
   * or when the process just started.
   */
  instantiate() {
    if (this.#initialized) {
      return;
    }
    this.#initialized = true;
    // Create and watch for future target actors for each watcher currently watching some target types
    for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects()) {
      this.#watchInitialTargetsForWatcher(watcherDataObject);
    }
  }

  /**
   * Instantiate and watch future target actors based on the already watched targets.
   *
   * @param Object watcherDataObject
   *        See ContentProcessWatcherRegistry.
   */
  #watchInitialTargetsForWatcher(watcherDataObject) {
    const { sessionData, sessionContext } = watcherDataObject;

    // About WebExtension, see note in addOrSetSessionDataEntry.
    // Their target actor aren't created by this class, but session data is still managed by it
    // and we need to pass the initial session data coming to already instantiated target actor.
    if (sessionContext.type == "webextension") {
      const { watcherActorID } = watcherDataObject;
      const connectionPrefix = watcherActorID.replace(/watcher\d+$/, "");
      const targetActors = lazy.TargetActorRegistry.getTargetActors(
        sessionContext,
        connectionPrefix
      );
      if (targetActors.length) {
        // Pass initialization data to the target actor
        for (const type in sessionData) {
          // `sessionData` will also contain `browserId` as well as entries with empty arrays,
          // which shouldn't be processed.
          const entries = sessionData[type];
          if (!Array.isArray(entries) || !entries.length) {
            continue;
          }
          targetActors[0].addOrSetSessionDataEntry(type, entries, false, "set");
        }
      }
    }

    // Ignore the call if the watched targets property isn't populated yet.
    // This typically happens when instantiating the JS Process Actor on toolbox opening,
    // where the actor is spawn early and a watchTarget message comes later with the `targets` array set.
    if (!sessionData.targets) {
      return;
    }

    for (const targetType of sessionData.targets) {
      this.#watchNewTargetTypeForWatcher(watcherDataObject, targetType, true);
    }
  }

  /**
   * Instantiate and watch future target actors based on the already watched targets.
   *
   * @param Object watcherDataObject
   *        See ContentProcessWatcherRegistry.
   * @param String targetType
   *        New typeof target to start watching.
   * @param Boolean isProcessActorStartup
   *        True when we are watching for targets during this JS Process actor instantiation.
   *        It shouldn't be the case on toolbox opening, but only when a new process starts.
   *        On toolbox opening, the Actor will receive an explicit watchTargets query.
   */
  #watchNewTargetTypeForWatcher(
    watcherDataObject,
    targetType,
    isProcessActorStartup
  ) {
    const { watchingTargetTypes } = watcherDataObject;
    // Ensure creating and watching only once per target type and watcher actor.
    if (watchingTargetTypes.includes(targetType)) {
      return;
    }
    watchingTargetTypes.push(targetType);

    // Update sessionData as watched target types are a Session Data
    // used later for example by worker target watcher
    lazy.SessionDataHelpers.addOrSetSessionDataEntry(
      watcherDataObject.sessionData,
      "targets",
      [targetType],
      "add"
    );

    this.#watchers[targetType].activeListener++;

    // Start listening for platform events when we are observing this type for the first time
    if (this.#watchers[targetType].activeListener === 1) {
      this.#watchers[targetType].watcher.watch();
    }

    // And instantiate targets for the already existing instances
    this.#watchers[targetType].watcher.createTargetsForWatcher(
      watcherDataObject,
      isProcessActorStartup
    );
  }

  /**
   * Stop watching for all target types and destroy all existing targets actor
   * related to a given watcher actor.
   *
   * @param {object} watcherDataObject
   * @param {string} targetType
   * @param {object} options
   */
  #unwatchTargetsForWatcher(watcherDataObject, targetType, options) {
    const { watchingTargetTypes } = watcherDataObject;
    const targetTypeIndex = watchingTargetTypes.indexOf(targetType);
    // Ignore targetTypes which were not observed
    if (targetTypeIndex === -1) {
      return;
    }
    // Update to the new list of currently watched target types
    watchingTargetTypes.splice(targetTypeIndex, 1);

    // Update sessionData as watched target types are a Session Data
    // used later for example by worker target watcher
    lazy.SessionDataHelpers.removeSessionDataEntry(
      watcherDataObject.sessionData,
      "targets",
      [targetType]
    );

    this.#watchers[targetType].activeListener--;

    // Stop observing for platform events
    if (this.#watchers[targetType].activeListener === 0) {
      this.#watchers[targetType].watcher.unwatch();
    }

    // Destroy all targets which are still instantiated for this type
    this.#watchers[targetType].watcher.destroyTargetsForWatcher(
      watcherDataObject,
      options
    );

    // Unregister the watcher if we stopped watching for all target types
    if (!watchingTargetTypes.length) {
      ContentProcessWatcherRegistry.remove(watcherDataObject);
    }

    // If we removed the last watcher, clean the internal state of this class.
    if (ContentProcessWatcherRegistry.isEmpty()) {
      this.didDestroy(options);
    }
  }

  /**
   * Cleanup everything around a given watcher actor
   *
   * @param {object} watcherDataObject
   */
  #destroyWatcher(watcherDataObject) {
    const { watchingTargetTypes } = watcherDataObject;
    // Clone the array as it will be modified during the loop execution
    for (const targetType of [...watchingTargetTypes]) {
      this.#unwatchTargetsForWatcher(watcherDataObject, targetType);
    }
  }

  /**
   * Used by DevTools Transport to send packets to the content process.
   *
   * @param {JSON} packet
   * @param {string} prefix
   */
  sendPacket(packet, prefix) {
    this.sendAsyncMessage("DevToolsProcessChild:packet", { packet, prefix });
  }

  /**
   * JsWindowActor API
   */

  async sendQuery(msg, args) {
    try {
      const res = await super.sendQuery(msg, args);
      return res;
    } catch (e) {
      console.error("Failed to sendQuery in DevToolsProcessChild", msg);
      console.error(e.toString());
      throw e;
    }
  }

  /**
   * Called by the JSProcessActor API when the process process sent us a message.
   */
  async receiveMessage(message) {
    try {
      switch (message.name) {
        case "DevToolsProcessParent:watchTargets": {
          const { watcherActorID, targetType } = message.data;
          const watcherDataObject =
            ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID);
          this.#watchNewTargetTypeForWatcher(watcherDataObject, targetType);
          break;
        }
        case "DevToolsProcessParent:unwatchTargets": {
          const { watcherActorID, targetType, options } = message.data;
          const watcherDataObject =
            ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID);
          this.#unwatchTargetsForWatcher(
            watcherDataObject,
            targetType,
            options
          );
          break;
        }
        case "DevToolsProcessParent:addOrSetSessionDataEntry": {
          const { watcherActorID, type, entries, updateType } = message.data;
          await this.#addOrSetSessionDataEntry(
            watcherActorID,
            type,
            entries,
            updateType
          );
          break;
        }
        case "DevToolsProcessParent:removeSessionDataEntry": {
          const { watcherActorID, type, entries } = message.data;
          this.#removeSessionDataEntry(watcherActorID, type, entries);
          break;
        }
        case "DevToolsProcessParent:destroyWatcher": {
          const { watcherActorID } = message.data;
          const watcherDataObject =
            ContentProcessWatcherRegistry.getWatcherDataObject(
              watcherActorID,
              true
            );
          // The watcher may already be destroyed if the client unwatched for all target types.
          if (watcherDataObject) {
            this.#destroyWatcher(watcherDataObject);
          }
          break;
        }
        case "DevToolsProcessParent:packet":
          this.emit("packet-received", message);
          break;
        default:
          throw new Error(
            "Unsupported message in DevToolsProcessParent: " + message.name
          );
      }
    } catch (e) {
      const stack =
        e == "out of memory" ? ChromeUtils.getLastOOMStackTrace() : e.stack;
      return {
        // Follows the same logic as protocol.js's `Actor.writeError`
        error: e.error || e.name || String(e),
        message: e.message,
        stack,
      };
    }
    return null;
  }

  /**
   * The parent process requested that some session data have been added or set.
   *
   * @param {string} watcherActorID
   *        The Watcher Actor ID requesting to add new session data
   * @param {string} type
   *        The type of data to be added
   * @param {Array<object>} entries
   *        The values to be added to this type of data
   * @param {string} updateType
   *        "add" will only add the new entries in the existing data set.
   *        "set" will update the data set with the new entries.
   */
  async #addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
    const watcherDataObject =
      ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID);

    // Maintain the copy of `sessionData` so that it is up-to-date when
    // a new worker target needs to be instantiated
    const { sessionData } = watcherDataObject;
    lazy.SessionDataHelpers.addOrSetSessionDataEntry(
      sessionData,
      type,
      entries,
      updateType
    );

    // This type is really specific to Service Workers and doesn't need to be transferred to any target.
    // We only need to instantiate and destroy the target actors based on this new host.
    const { watchingTargetTypes } = watcherDataObject;
    if (type == "browser-element-host") {
      if (watchingTargetTypes.includes("service_worker")) {
        this.#watchers.service_worker.watcher.updateBrowserElementHost(
          watcherDataObject
        );
      }
      return;
    }

    const promises = [];
    for (const targetActor of watcherDataObject.actors) {
      promises.push(
        targetActor.addOrSetSessionDataEntry(type, entries, false, updateType)
      );
    }

    // Very special codepath for Web Extensions.
    // Their WebExtension Target Actor is still created manually by WebExtensionDescritpor.getTarget,
    // via a message manager. That, instead of being instantiated via the WatcherActor.watchTargets and this JSProcess actor.
    // The Watcher Actor will still instantiate a JS Actor for the WebExt DOM Content Process
    // and send the addOrSetSessionDataEntry query. But as the target actor isn't managed by the JS Actor,
    // we have to manually retrieve it via the TargetActorRegistry.
    if (sessionData.sessionContext.type == "webextension") {
      const connectionPrefix = watcherActorID.replace(/watcher\d+$/, "");
      const targetActors = lazy.TargetActorRegistry.getTargetActors(
        sessionData.sessionContext,
        connectionPrefix
      );
      // We will have a single match only in the DOM Process where the add-on runs
      if (targetActors.length) {
        promises.push(
          targetActors[0].addOrSetSessionDataEntry(
            type,
            entries,
            false,
            updateType
          )
        );
      }
    }
    await Promise.all(promises);

    if (watchingTargetTypes.includes("worker")) {
      await this.#watchers.worker.watcher.addOrSetSessionDataEntry(
        watcherDataObject,
        type,
        entries,
        updateType
      );
    }
    if (watchingTargetTypes.includes("service_worker")) {
      await this.#watchers.service_worker.watcher.addOrSetSessionDataEntry(
        watcherDataObject,
        type,
        entries,
        updateType
      );
    }
    if (watchingTargetTypes.includes("shared_worker")) {
      await this.#watchers.shared_worker.watcher.addOrSetSessionDataEntry(
        watcherDataObject,
        type,
        entries,
        updateType
      );
    }
  }

  /**
   * The parent process requested that some session data have been removed.
   *
   * @param {string} watcherActorID
   *        The Watcher Actor ID requesting to remove session data
   * @param {string}} type
   *        The type of data to be removed
   * @param {Array<object>} entries
   *        The values to be removed to this type of data
   */
  #removeSessionDataEntry(watcherActorID, type, entries) {
    const watcherDataObject =
      ContentProcessWatcherRegistry.getWatcherDataObject(watcherActorID, true);

    // When we unwatch resources after targets during the devtools shutdown,
    // the watcher will be removed on last target type unwatch.
    if (!watcherDataObject) {
      return;
    }
    const { actors, sessionData, watchingTargetTypes } = watcherDataObject;

    // Maintain the copy of `sessionData` so that it is up-to-date when
    // a new worker target needs to be instantiated
    lazy.SessionDataHelpers.removeSessionDataEntry(sessionData, type, entries);

    for (const targetActor of actors) {
      targetActor.removeSessionDataEntry(type, entries);
    }

    // Special code paths for webextension toolboxes and worker targets
    // See addOrSetSessionDataEntry for more details.

    if (sessionData.sessionContext.type == "webextension") {
      const connectionPrefix = watcherActorID.replace(/watcher\d+$/, "");
      const targetActors = lazy.TargetActorRegistry.getTargetActors(
        sessionData.sessionContext,
        connectionPrefix
      );
      if (targetActors.length) {
        targetActors[0].removeSessionDataEntry(type, entries);
      }
    }

    if (watchingTargetTypes.includes("worker")) {
      this.#watchers.worker.watcher.removeSessionDataEntry(
        watcherDataObject,
        type,
        entries
      );
    }
    if (watchingTargetTypes.includes("service_worker")) {
      this.#watchers.service_worker.watcher.removeSessionDataEntry(
        watcherDataObject,
        type,
        entries
      );
    }
    if (watchingTargetTypes.includes("shared_worker")) {
      this.#watchers.shared_worker.watcher.removeSessionDataEntry(
        watcherDataObject,
        type,
        entries
      );
    }
  }

  /**
   * Observer service notification handler.
   *
   * @param {DOMWindow|Document} subject
   *        A window for *-document-global-created
   *        A document for *-page-{shown|hide}
   * @param {string} topic
   */
  observe = (subject, topic) => {
    if (topic === "init-devtools-content-process-actor") {
      // This is triggered by the process actor registration and some code in process-helper.js
      // which defines a unique topic to be observed
      this.instantiate();
    }
  };

  /**
   * Called by JS Process Actor API when the current process is destroyed,
   * but also within this class when the last watcher stopped watching for targets.
   */
  didDestroy() {
    // Unregister all the active watchers.
    // This will destroy all the active target actors and unregister the target observers.
    for (const watcherDataObject of ContentProcessWatcherRegistry.getAllExistingWatchersDataObjects()) {
      this.#destroyWatcher(watcherDataObject);
    }

    // The previous for loop should have removed all the elements,
    // but just to be safe, wipe all stored data to avoid any possible leak.
    ContentProcessWatcherRegistry.clear();
  }
}

export class BrowserToolboxDevToolsProcessChild extends DevToolsProcessChild {}
