/* 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 { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};
XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "wdm",
  "@mozilla.org/dom/workers/workerdebuggermanager;1",
  Ci.nsIWorkerDebuggerManager
);

const { TYPE_DEDICATED, TYPE_SERVICE, TYPE_SHARED } = Ci.nsIWorkerDebugger;

export class WorkerTargetWatcherClass {
  constructor(workerTargetType = "worker") {
    this.#workerTargetType = workerTargetType;
    this.#workerDebuggerListener = {
      onRegister: this.#onWorkerRegister.bind(this),
      onUnregister: this.#onWorkerUnregister.bind(this),
    };
  }

  // {String}
  #workerTargetType;
  // {nsIWorkerDebuggerListener}
  #workerDebuggerListener;

  watch() {
    lazy.wdm.addListener(this.#workerDebuggerListener);
  }

  unwatch() {
    lazy.wdm.removeListener(this.#workerDebuggerListener);
  }

  createTargetsForWatcher(watcherDataObject) {
    const { sessionData } = watcherDataObject;
    for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
      if (!this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) {
        continue;
      }
      this.createWorkerTargetActor(watcherDataObject, dbg);
    }
  }

  async addOrSetSessionDataEntry(watcherDataObject, type, entries, updateType) {
    // Collect the SessionData update into `pendingWorkers` in order to notify
    // about the updates to workers which are still in process of being hooked by devtools.
    for (const concurrentSessionUpdates of watcherDataObject.pendingWorkers[
      this.#workerTargetType
    ]) {
      concurrentSessionUpdates.push({
        type,
        entries,
        updateType,
      });
    }

    const promises = [];
    for (const { dbg, workerThreadServerForwardingPrefix } of watcherDataObject
      .workers[this.#workerTargetType]) {
      promises.push(
        addOrSetSessionDataEntryInWorkerTarget({
          dbg,
          workerThreadServerForwardingPrefix,
          type,
          entries,
          updateType,
        })
      );
    }
    await Promise.all(promises);
  }

  removeSessionDataEntry(watcherDataObject, type, entries) {
    for (const { dbg, workerThreadServerForwardingPrefix } of watcherDataObject
      .workers[this.#workerTargetType]) {
      if (!isWorkerDebuggerAlive(dbg)) {
        continue;
      }

      dbg.postMessage(
        JSON.stringify({
          type: "remove-session-data-entry",
          forwardingPrefix: workerThreadServerForwardingPrefix,
          dataEntryType: type,
          entries,
        })
      );
    }
  }

  /**
   * Called whenever a new Worker is instantiated in the current process
   *
   * @param {WorkerDebugger} dbg
   */
  #onWorkerRegister(dbg) {
    // Create a Target Actor for each watcher currently watching for Workers
    for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects(
      this.#workerTargetType
    )) {
      const { sessionData } = watcherDataObject;
      if (this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) {
        this.createWorkerTargetActor(watcherDataObject, dbg);
      }
    }
  }

  /**
   * Called whenever a Worker is destroyed in the current process
   *
   * @param {WorkerDebugger} dbg
   */
  #onWorkerUnregister(dbg) {
    for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects(
      this.#workerTargetType
    )) {
      const { watcherActorID } = watcherDataObject;
      const workerList = watcherDataObject.workers[this.#workerTargetType];
      // Check if the worker registration was handled for this watcherActorID.
      const unregisteredActorIndex = workerList.findIndex(worker => {
        try {
          // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED).
          return worker.dbg.id === dbg.id;
        } catch (e) {
          return false;
        }
      });
      if (unregisteredActorIndex === -1) {
        continue;
      }

      const { workerTargetForm, transport } =
        workerList[unregisteredActorIndex];
      // Close the transport made to the worker thread
      transport.close();

      try {
        watcherDataObject.jsProcessActor.sendAsyncMessage(
          "DevToolsProcessChild:targetDestroyed",
          {
            actors: [
              {
                watcherActorID,
                targetActorForm: workerTargetForm,
              },
            ],
            options: {},
          }
        );
      } catch (e) {
        // This often throws as the JSActor is being destroyed when DevTools closes
        // and we are trying to notify about the destroyed targets.
      }

      workerList.splice(unregisteredActorIndex, 1);
    }
  }

  /**
   * Instantiate a worker target actor related to a given WorkerDebugger object
   * and for a given watcher actor.
   *
   * @param {object} watcherDataObject
   * @param {WorkerDebugger} dbg
   */
  async createWorkerTargetActor(watcherDataObject, dbg) {
    // Prevent the debuggee from executing in this worker until the client has
    // finished attaching to it. This call will throw if the debugger is already "registered"
    // (i.e. if this is called outside of the register listener)
    // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66
    try {
      dbg.setDebuggerReady(false);
    } catch (e) {
      if (!e.message.startsWith("Component returned failure code")) {
        throw e;
      }
    }

    const { watcherActorID } = watcherDataObject;
    const { connection, loader } =
      ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher(
        watcherActorID
      );

    // Compute a unique prefix for the bridge made between this content process main thread
    // and the worker thread.
    const workerThreadServerForwardingPrefix =
      connection.allocID("workerTarget");

    const { connectToWorker } = loader.require(
      "resource://devtools/server/connectors/worker-connector.js"
    );

    // Create the actual worker target actor, in the worker thread.
    const { sessionData, sessionContext } = watcherDataObject;
    const onConnectToWorker = connectToWorker(
      connection,
      dbg,
      workerThreadServerForwardingPrefix,
      {
        sessionData,
        sessionContext,
      }
    );

    // Only add data to the connection if we successfully send the
    // workerTargetAvailable message.
    const workerInfo = {
      dbg,
      workerThreadServerForwardingPrefix,
    };
    const workerList = watcherDataObject.workers[this.#workerTargetType];
    workerList.push(workerInfo);

    // The onConnectToWorker is async and we may receive new Session Data (e.g breakpoints)
    // while we are instantiating the worker targets.
    // Let cache the pending session data and flush it after the targets are being instantiated.
    const concurrentSessionUpdates = [];
    const pendingWorkers =
      watcherDataObject.pendingWorkers[this.#workerTargetType];
    pendingWorkers.add(concurrentSessionUpdates);

    try {
      await onConnectToWorker;
    } catch (e) {
      // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution.
      // But if anything goes wrong and an exception is thrown, ensure releasing its execution,
      // otherwise if devtools is broken, it will freeze the worker indefinitely.
      //
      // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to
      // resume the debugger if it is not closed (otherwise it can cause crashes).
      if (!dbg.isClosed) {
        dbg.setDebuggerReady(true);
      }
      // Also unregister the worker
      workerList.splice(workerList.indexOf(workerInfo), 1);
      pendingWorkers.delete(concurrentSessionUpdates);
      return;
    }
    pendingWorkers.delete(concurrentSessionUpdates);

    const { workerTargetForm, transport } = await onConnectToWorker;
    workerInfo.workerTargetForm = workerTargetForm;
    workerInfo.transport = transport;

    // Bail out and cleanup the actor by closing the transport,
    // if we stopped listening for workers while waiting for onConnectToWorker resolution.
    if (!workerList.includes(workerInfo)) {
      transport.close();
      return;
    }

    const { forwardingPrefix } = watcherDataObject;
    // Immediately queue a message for the parent process, before applying any SessionData
    // as it may start emitting RDP events on the target actor and be lost if the client
    // didn't get notified about the target actor first
    try {
      watcherDataObject.jsProcessActor.sendAsyncMessage(
        "DevToolsProcessChild:targetAvailable",
        {
          watcherActorID,
          forwardingPrefix,
          targetActorForm: workerTargetForm,
        }
      );
    } catch (e) {
      // If there was an error while sending the message, we are not going to use this
      // connection to communicate with the worker.
      transport.close();
      // Also unregister the worker
      workerList.splice(workerList.indexOf(workerInfo), 1);
      return;
    }

    // Dispatch to the worker thread any SessionData updates which may have been notified
    // while we were waiting for onConnectToWorker to resolve.
    const promises = [];
    for (const { type, entries, updateType } of concurrentSessionUpdates) {
      promises.push(
        addOrSetSessionDataEntryInWorkerTarget({
          dbg,
          workerThreadServerForwardingPrefix,
          type,
          entries,
          updateType,
        })
      );
    }
    await Promise.all(promises);
  }

  destroyTargetsForWatcher(watcherDataObject) {
    // Notify to all worker threads to destroy their target actor running in them
    for (const { transport } of watcherDataObject.workers[
      this.#workerTargetType
    ]) {
      // The transport may not be set if the worker is still being connected to from createWorkerTargetActor.
      if (transport) {
        // Clean the DevToolsTransport created in the main thread to bridge RDP to the worker thread.
        // This will also send a last message to the worker to clean things up in the other thread.
        transport.close();
      }
    }
    // Wipe all workers info
    watcherDataObject.workers[this.#workerTargetType] = [];
  }

  /**
   * Indicates whether or not we should handle the worker debugger
   *
   * @param {object} sessionData
   *        The session data for a given watcher, which includes metadata
   *        about the debugged context.
   * @param {WorkerDebugger} dbg
   *        The worker debugger we want to check.
   * @param {string} targetType
   *        The expected worker target type.
   * @returns {boolean}
   */
  shouldHandleWorker(sessionData, dbg, targetType) {
    if (!isWorkerDebuggerAlive(dbg)) {
      return false;
    }

    if (
      (dbg.type === TYPE_DEDICATED && targetType != "worker") ||
      (dbg.type === TYPE_SERVICE && targetType != "service_worker") ||
      (dbg.type === TYPE_SHARED && targetType != "shared_worker")
    ) {
      return false;
    }

    // subprocess workers are ignored because they take several seconds to
    // attach to when opening the browser toolbox. See bug 1594597.
    if (
      /resource:\/\/gre\/modules\/subprocess\/subprocess_.*\.worker\.js/.test(
        dbg.url
      )
    ) {
      return false;
    }

    const { type: sessionContextType } = sessionData.sessionContext;
    if (sessionContextType == "all") {
      return true;
    }
    if (sessionContextType == "content-process") {
      throw new Error(
        "Content process session type shouldn't try to spawn workers"
      );
    }
    if (sessionContextType == "worker") {
      throw new Error(
        "worker session type should spawn only one target via the WorkerDescriptor"
      );
    }

    if (dbg.type === TYPE_DEDICATED) {
      // Assume that all dedicated workers executes in the same process as the debugged document.
      const browsingContext = BrowsingContext.getCurrentTopByBrowserId(
        sessionData.sessionContext.browserId
      );
      // If we aren't executing in the same process as the worker and its BrowsingContext,
      // it will be undefined.
      if (!browsingContext) {
        return false;
      }
      for (const subBrowsingContext of browsingContext.getAllBrowsingContextsInSubtree()) {
        if (
          subBrowsingContext.currentWindowContext &&
          dbg.windowIDs.includes(
            subBrowsingContext.currentWindowContext.innerWindowId
          )
        ) {
          return true;
        }
      }
      return false;
    }

    if (dbg.type === TYPE_SERVICE) {
      // Accessing `nsIPrincipal.host` may easily throw on non-http URLs.
      // Ignore all non-HTTP as they most likely don't have any valid host name.
      if (!dbg.principal.scheme.startsWith("http")) {
        return false;
      }

      const workerHost = dbg.principal.hostPort;
      return workerHost == sessionData["browser-element-host"][0];
    }

    if (dbg.type === TYPE_SHARED) {
      // Bug 1607778 - Don't expose shared workers when debugging a tab.
      // For now, they are only exposed in the browser toolbox, when Session Context Type is set to "all".
      return false;
    }

    return false;
  }
}

/**
 * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger.
 *
 * @param {WorkerDebugger} dbg
 * @param {string} workerThreadServerForwardingPrefix
 * @param {string} type
 *        Session data type name
 * @param {Array} entries
 *        Session data entries to add or set.
 * @param {string} updateType
 *        Either "add" or "set", to control if we should only add some items,
 *        or replace the whole data set with the new entries.
 * @returns {Promise} Returns a Promise that resolves once the data entry were handled
 *                    by the worker target.
 */
function addOrSetSessionDataEntryInWorkerTarget({
  dbg,
  workerThreadServerForwardingPrefix,
  type,
  entries,
  updateType,
}) {
  if (!isWorkerDebuggerAlive(dbg)) {
    return Promise.resolve();
  }

  return new Promise(resolve => {
    // Wait until we're notified by the worker that the resources are watched.
    // This is important so we know existing resources were handled.
    const listener = {
      onMessage: message => {
        message = JSON.parse(message);
        if (message.type === "session-data-entry-added-or-set") {
          dbg.removeListener(listener);
          resolve();
        }
      },
      // Resolve if the worker is being destroyed so we don't have a dangling promise.
      onClose: () => {
        dbg.removeListener(listener);
        resolve();
      },
    };

    dbg.addListener(listener);

    dbg.postMessage(
      JSON.stringify({
        type: "add-or-set-session-data-entry",
        forwardingPrefix: workerThreadServerForwardingPrefix,
        dataEntryType: type,
        entries,
        updateType,
      })
    );
  });
}

function isWorkerDebuggerAlive(dbg) {
  if (dbg.isClosed) {
    return false;
  }
  // Some workers are zombies. `isClosed` is false, but nothing works.
  // `postMessage` is a noop, `addListener`'s `onClosed` doesn't work.
  return (
    dbg.window?.docShell ||
    // consider dbg without `window` as being alive, as they aren't related
    // to any docShell and probably do not suffer from this issue
    !dbg.window
  );
}

export const WorkerTargetWatcher = new WorkerTargetWatcherClass();
