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

const { Actor } = require("resource://devtools/shared/protocol.js");
const {
  TYPES: { DOCUMENT_EVENT, NETWORK_EVENT_STACKTRACE, CONSOLE_MESSAGE },
  getResourceWatcher,
} = require("resource://devtools/server/actors/resources/index.js");
const Targets = require("devtools/server/actors/targets/index");
const {
  ObjectActorPool,
} = require("resource://devtools/server/actors/object/ObjectActorPool.js");

const { throttle } = require("resource://devtools/shared/throttle.js");
const RESOURCES_THROTTLING_DELAY = 100;

loader.lazyRequireGetter(
  this,
  "SessionDataProcessors",
  "resource://devtools/server/actors/targets/session-data-processors/index.js",
  true
);

class BaseTargetActor extends Actor {
  constructor(conn, targetType, spec) {
    super(conn, spec);

    /**
     * Type of target, a string of Targets.TYPES.
     * @return {string}
     */
    this.targetType = targetType;

    // Lists of resources available/updated/destroyed RDP packet
    // currently queued which will be emitted after a throttle delay.
    this.#throttledResources = {
      available: [],
      updated: [],
      destroyed: [],
    };

    this.#throttledEmitResources = throttle(
      this.emitResources.bind(this),
      RESOURCES_THROTTLING_DELAY
    );
  }

  // Whenever createValueGripForTarget is used, any Object Actor will be added to this pool
  get objectsPool() {
    if (this._objectsPool) {
      return this._objectsPool;
    }
    this._objectsPool = new ObjectActorPool(this.threadActor, "target-objects");
    this.manage(this._objectsPool);
    return this._objectsPool;
  }

  #throttledResources;
  #throttledEmitResources;

  /**
   * Process a new data entry, which can be watched resources, breakpoints, ...
   *
   * @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 Boolean isDocumentCreation
   *        Set to true if this function is called just after a new document (and its
   *        associated target) is created.
   * @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(
    type,
    entries,
    isDocumentCreation = false,
    updateType
  ) {
    const processor = SessionDataProcessors[type];
    if (processor) {
      await processor.addOrSetSessionDataEntry(
        this,
        entries,
        isDocumentCreation,
        updateType
      );
    }
  }

  /**
   * Remove data entries that have been previously added via addOrSetSessionDataEntry
   *
   * See addOrSetSessionDataEntry for argument description.
   */
  removeSessionDataEntry(type, entries) {
    const processor = SessionDataProcessors[type];
    if (processor) {
      processor.removeSessionDataEntry(this, entries);
    }
  }

  /**
   * Called by Resource Watchers, when new resources are available, updated or destroyed.
   * This will only accumulate resource update packets into throttledResources object.
   * The actualy sending of resources will happen from emitResources.
   *
   * @param String updateType
   *        Can be "available", "updated" or "destroyed"
   * @param String resourceType
   *        The type of resources to be notified about.
   * @param Array<json|string> resources
   *        For "available", the array will be a list of new resource JSON objects sent as-is to the client.
   *        It can contain actor IDs, actor forms, to be manually marshalled by the client.
   *        For "updated", the array will contain a list of objects with the attributes documented in
   *        `ResourceCommand._onResourceUpdated` jsdoc.
   *        For "destroyed", the array will contain a list of resource IDs (strings).
   */
  notifyResources(updateType, resourceType, resources) {
    if (resources.length === 0 || this.isDestroyed()) {
      // Don't try to emit if the resources array is empty or the actor was
      // destroyed.
      return;
    }

    if (this.devtoolsSpawnedBrowsingContextForWebExtension) {
      this.overrideResourceBrowsingContextForWebExtension(resources);
    }

    const shouldEmitSynchronously =
      resourceType == NETWORK_EVENT_STACKTRACE ||
      (resourceType == DOCUMENT_EVENT &&
        resources.some(resource => resource.name == "will-navigate"));

    // If the last throttled resources were of the same resource type,
    // augment the resources array with the new resources
    const lastResourceInThrottleCache =
      this.#throttledResources[updateType].at(-1);
    if (
      lastResourceInThrottleCache &&
      lastResourceInThrottleCache[0] === resourceType
    ) {
      lastResourceInThrottleCache[1].push.apply(
        lastResourceInThrottleCache[1],
        resources
      );
    } else {
      // Otherwise, add a new item in the throttle queue with the resource type
      this.#throttledResources[updateType].push([resourceType, resources]);
    }

    // Force firing resources immediately when:
    // * we receive DOCUMENT_EVENT's will-navigate
    // This will force clearing resources on the client side ASAP.
    // Otherwise we might emit some other RDP event (outside of resources),
    // which will be cleared by the throttled/delayed will-navigate.
    // * we receive NETWOR_EVENT_STACKTRACE which are meant to be dispatched *before*
    // the related NETWORK_EVENT fired from the parent process. (we aren't throttling
    // resources from the parent process, so it is even more likely to be dispatched
    // in the wrong order)
    if (shouldEmitSynchronously) {
      this.emitResources();
    } else {
      this.#throttledEmitResources();
    }
  }

  /**
   * Flush resources to DevTools transport layer, actually sending all resource update packets
   */
  emitResources() {
    if (this.isDestroyed()) {
      return;
    }
    for (const updateType of ["available", "updated", "destroyed"]) {
      const resources = this.#throttledResources[updateType];
      if (!resources.length) {
        continue;
      }
      this.#throttledResources[updateType] = [];
      this.emit(`resources-${updateType}-array`, resources);
    }
  }

  /**
   * For WebExtension, we have to hack all resource's browsingContextID
   * in order to ensure emitting them with the fixed, original browsingContextID
   * related to the fallback document created by devtools which always exists.
   * The target's form will always be relating to that BrowsingContext IDs (browsing context ID and inner window id).
   * Even if the target switches internally to another document via WindowGlobalTargetActor._setWindow.
   *
   * @param {Array<Objects>} List of resources
   */
  overrideResourceBrowsingContextForWebExtension(resources) {
    const browsingContextID =
      this.devtoolsSpawnedBrowsingContextForWebExtension.id;
    resources.forEach(
      resource => (resource.browsingContextID = browsingContextID)
    );
  }

  // List of actor prefixes (string) which have already been instantiated via getTargetScopedActor method.
  #instantiatedTargetScopedActors = new Set();

  /**
   * Try to return any target scoped actor instance, if it exists.
   * They are lazily instantiated and so will only be available
   * if the client called at least one of their method.
   *
   * @param {String} prefix
   *        Prefix for the actor we would like to retrieve.
   *        Defined in devtools/server/actors/utils/actor-registry.js
   */
  getTargetScopedActor(prefix) {
    if (this.isDestroyed()) {
      return null;
    }
    const form = this.form();
    this.#instantiatedTargetScopedActors.add(prefix);
    return this.conn._getOrCreateActor(form[prefix + "Actor"]);
  }

  /**
   * Returns true, if the related target scoped actor has already been queried
   * and instantiated via `getTargetScopedActor` method.
   *
   * @param {String} prefix
   *        See getTargetScopedActor definition
   * @return Boolean
   *         True, if the actor has already been instantiated.
   */
  hasTargetScopedActor(prefix) {
    return this.#instantiatedTargetScopedActors.has(prefix);
  }

  /**
   * Server side boolean to know if the tracer has been enabled by the user.
   *
   * By enabled, we mean the feature has been exposed to the user,
   * not that the tracer is actively tracing executions.
   */
  isTracerFeatureEnabled = false;

  /**
   * Apply target-specific options.
   *
   * This will be called by the watcher when the DevTools target-configuration
   * is updated, or when a target is created via JSWindowActors.
   *
   * @param {JSON} options
   *        Configuration object provided by the client.
   *        See target-configuration actor.
   * @param {Boolean} calledFromDocumentCreate
   *        True, when this is called with initial configuration when the related target
   *        actor is instantiated.
   */
  updateTargetConfiguration(options = {}, calledFromDocumentCreation = false) {
    if (typeof options.isTracerFeatureEnabled === "boolean") {
      this.isTracerFeatureEnabled = options.isTracerFeatureEnabled;
    }
    // If there is some tracer options, we should start tracing, otherwise we should stop (if we were)
    if (options.tracerOptions) {
      // Ignore the SessionData update if the user requested to start the tracer on next page load and:
      //   - we apply it to an already loaded WindowGlobal,
      //   - the target isn't the top level one.
      if (
        options.tracerOptions.traceOnNextLoad &&
        (!calledFromDocumentCreation || !this.isTopLevelTarget)
      ) {
        if (this.isTopLevelTarget) {
          const consoleMessageWatcher = getResourceWatcher(
            this,
            CONSOLE_MESSAGE
          );
          if (consoleMessageWatcher) {
            consoleMessageWatcher.emitMessages([
              {
                arguments: [
                  "Waiting for next navigation or page reload before starting tracing",
                ],
                styles: [],
                level: "jstracer",
                chromeContext: false,
                timeStamp: ChromeUtils.dateNow(),
              },
            ]);
          }
        }
        return;
      }
      // Bug 1874204: For now, in the browser toolbox, only frame and workers are traced.
      // Content process targets are ignored as they would also include each document/frame target.
      // This would require some work to ignore FRAME targets from here, only in case of browser toolbox,
      // and also handle all content process documents for DOM Event logging.
      //
      // Bug 1874219: Also ignore extensions for now as they are all running in the same process,
      // whereas we can only spawn one tracer per thread.
      if (
        this.targetType == Targets.TYPES.PROCESS ||
        this.url?.startsWith("moz-extension://")
      ) {
        return;
      }
      // In the browser toolbox, when debugging the parent process, we should only toggle the tracer in the Parent Process Target Actor.
      // We have to ignore any frame target which may run in the parent process.
      // For example DevTools documents or a tab running in the parent process.
      // (PROCESS_TYPE_DEFAULT refers to the parent process)
      if (
        this.sessionContext.type == "all" &&
        this.targetType === Targets.TYPES.FRAME &&
        this.typeName != "parentProcessTarget" &&
        Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT
      ) {
        return;
      }
      const tracerActor = this.getTargetScopedActor("tracer");
      tracerActor.startTracing(options.tracerOptions);
    } else if (this.hasTargetScopedActor("tracer")) {
      const tracerActor = this.getTargetScopedActor("tracer");
      tracerActor.stopTracing();
    }
  }
}
exports.BaseTargetActor = BaseTargetActor;
