/* 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 {
  targetConfigurationSpec,
} = require("resource://devtools/shared/specs/target-configuration.js");

const { SessionDataHelpers } = ChromeUtils.importESModule(
  "resource://devtools/server/actors/watcher/SessionDataHelpers.sys.mjs",
  { global: "contextual" }
);
const { isBrowsingContextPartOfContext } = ChromeUtils.importESModule(
  "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
  { global: "contextual" }
);
loader.lazyRequireGetter(
  this,
  "TRACER_LOG_METHODS",
  "resource://devtools/shared/specs/tracer.js",
  true
);
const { SUPPORTED_DATA } = SessionDataHelpers;
const { TARGET_CONFIGURATION } = SUPPORTED_DATA;
const LOG_DISABLED = -1;

// List of options supported by this target configuration actor.
/* eslint sort-keys: "error" */
const SUPPORTED_OPTIONS = {
  // Disable network request caching.
  cacheDisabled: true,
  // Enable color scheme simulation.
  colorSchemeSimulation: true,
  // Enable custom formatters
  customFormatters: true,
  // Set a custom user agent
  customUserAgent: true,
  // Is the tracer experimental feature manually enabled by the user?
  isTracerFeatureEnabled: true,
  // Enable JavaScript
  javascriptEnabled: true,
  // Force a custom device pixel ratio (used in RDM). Set to null to restore origin ratio.
  overrideDPPX: true,
  // Enable print simulation mode.
  printSimulationEnabled: true,
  // Override navigator.maxTouchPoints (used in RDM and doesn't apply if RDM isn't enabled)
  rdmPaneMaxTouchPoints: true,
  // Page orientation (used in RDM and doesn't apply if RDM isn't enabled)
  rdmPaneOrientation: true,
  // Enable allocation tracking, if set, contains an object defining the tracking configurations
  recordAllocations: true,
  // Reload the page when the touch simulation state changes (only works alongside touchEventsOverride)
  reloadOnTouchSimulationToggle: true,
  // Restore focus in the page after closing DevTools.
  restoreFocus: true,
  // Enable service worker testing over HTTP (instead of HTTPS only).
  serviceWorkersTestingEnabled: true,
  // Set the current tab offline
  setTabOffline: true,
  // Enable touch events simulation
  touchEventsOverride: true,
  // Used to configure and start/stop the JavaScript tracer
  tracerOptions: true,
  // Use simplified highlighters when prefers-reduced-motion is enabled.
  useSimpleHighlightersForReducedMotion: true,
};
/* eslint-disable sort-keys */

/**
 * This actor manages the configuration flags which apply to DevTools targets.
 *
 * Configuration flags should be applied to all concerned targets when the
 * configuration is updated, and new targets should also be able to read the
 * flags when they are created. The flags will be forwarded to the WatcherActor
 * and stored as TARGET_CONFIGURATION data entries.
 * Some flags will be set directly set from this actor, in the parent process
 * (see _updateParentProcessConfiguration), and others will be set from the target actor,
 * in the content process.
 *
 * @class
 */
class TargetConfigurationActor extends Actor {
  constructor(watcherActor) {
    super(watcherActor.conn, targetConfigurationSpec);
    this.watcherActor = watcherActor;

    this._onBrowsingContextAttached =
      this._onBrowsingContextAttached.bind(this);
    // We need to be notified of new browsing context being created so we can re-set flags
    // we already set on the "previous" browsing context. We're using this event  as it's
    // emitted very early in the document lifecycle (i.e. before any script on the page is
    // executed), which is not the case for "window-global-created" for example.
    Services.obs.addObserver(
      this._onBrowsingContextAttached,
      "browsing-context-attached"
    );

    // When we perform a bfcache navigation, the current browsing context gets
    // replaced with a browsing which was previously stored in bfcache and we
    // should update our reference accordingly.
    this._onBfCacheNavigation = this._onBfCacheNavigation.bind(this);
    this.watcherActor.on(
      "bf-cache-navigation-pageshow",
      this._onBfCacheNavigation
    );

    this._browsingContext = this.watcherActor.browserElement?.browsingContext;
  }

  // Value of `logging.console` pref, before starting recording JS Traces
  #consolePrefValue;
  // Value of `logging.PageMessages` pref, before starting recording JS Traces
  #pageMessagesPrefValue;

  form() {
    return {
      actor: this.actorID,
      configuration: this._getConfiguration(),
      traits: { supportedOptions: SUPPORTED_OPTIONS },
    };
  }

  /**
   * Returns whether or not this actor should handle the flag that should be set on the
   * BrowsingContext in the parent process.
   *
   * @returns {boolean}
   */
  _shouldHandleConfigurationInParentProcess() {
    // Only handle parent process configuration if the watcherActor is tied to a
    // browser element.
    // For now, the Browser Toolbox and Web Extension are having a unique target
    // which applies the configuration by itself on new documents.
    return this.watcherActor.sessionContext.type == "browser-element";
  }

  /**
   * Event handler for attached browsing context. This will be called when
   * a new browsing context is created that we might want to handle
   * (e.g. when navigating to a page with Cross-Origin-Opener-Policy header)
   */
  _onBrowsingContextAttached(browsingContext) {
    if (!this._shouldHandleConfigurationInParentProcess()) {
      return;
    }

    // We only want to set flags on top-level browsing context. The platform
    // will take care of propagating it to the entire browsing contexts tree.
    if (browsingContext.parent) {
      return;
    }

    // Only process BrowsingContexts which are related to the debugged scope.
    // As this callback fires very early, the BrowsingContext may not have
    // any WindowGlobal yet and so we ignore all checks dones against the WindowGlobal
    // if there is none. Meaning we might accept more BrowsingContext than expected.
    if (
      !isBrowsingContextPartOfContext(
        browsingContext,
        this.watcherActor.sessionContext,
        { acceptNoWindowGlobal: true, forceAcceptTopLevelTarget: true }
      )
    ) {
      return;
    }

    const rdmEnabledInPreviousBrowsingContext = this._browsingContext.inRDMPane;

    // Before replacing the target browsing context, restore the configuration
    // on the previous one if they share the same browser.
    if (
      this._browsingContext &&
      this._browsingContext.browserId === browsingContext.browserId &&
      !this._browsingContext.isDiscarded
    ) {
      // For now this should always be true as long as we already had a browsing
      // context set, but the same logic should be used when supporting EFT on
      // toolboxes with several top level browsing contexts: when a new browsing
      // context attaches, only reset the browsing context with the same browserId
      this._restoreParentProcessConfiguration();
    }

    // We need to store the browsing context as this.watcherActor.browserElement.browsingContext
    // can still refer to the previous browsing context at this point.
    this._browsingContext = browsingContext;

    // If `inRDMPane` was set in the previous browsing context, set it again on the new one,
    // otherwise some RDM-related configuration won't be applied (e.g. orientation).
    if (rdmEnabledInPreviousBrowsingContext) {
      this._browsingContext.inRDMPane = true;
    }
    this._updateParentProcessConfiguration(this._getConfiguration());
  }

  _onBfCacheNavigation({ windowGlobal } = {}) {
    if (windowGlobal) {
      this._onBrowsingContextAttached(windowGlobal.browsingContext);
    }
  }

  _getConfiguration() {
    const targetConfigurationData =
      this.watcherActor.getSessionDataForType(TARGET_CONFIGURATION);
    if (!targetConfigurationData) {
      return {};
    }

    const cfgMap = {};
    for (const { key, value } of targetConfigurationData) {
      cfgMap[key] = value;
    }
    return cfgMap;
  }

  /**
   *
   * @param {object} configuration
   * @returns Promise<Object> Applied configuration object
   */
  async updateConfiguration(configuration) {
    const cfgArray = Object.keys(configuration)
      .filter(key => {
        if (!SUPPORTED_OPTIONS[key]) {
          console.warn(`Unsupported option for TargetConfiguration: ${key}`);
          return false;
        }
        return true;
      })
      .map(key => ({ key, value: configuration[key] }));

    this._updateParentProcessConfiguration(configuration);
    await this.watcherActor.addOrSetDataEntry(
      TARGET_CONFIGURATION,
      cfgArray,
      "add"
    );
    return this._getConfiguration();
  }

  /**
   *
   * @param {object} configuration: See `updateConfiguration`
   */
  _updateParentProcessConfiguration(configuration) {
    // Process "tracerOptions" for all session types, as this isn't specific to tab debugging
    if ("tracerOptions" in configuration) {
      this._setTracerOptions(configuration.tracerOptions);
    }

    if (!this._shouldHandleConfigurationInParentProcess()) {
      return;
    }

    let shouldReload = false;
    for (const [key, value] of Object.entries(configuration)) {
      switch (key) {
        case "colorSchemeSimulation":
          this._setColorSchemeSimulation(value);
          break;
        case "customUserAgent":
          this._setCustomUserAgent(value);
          break;
        case "javascriptEnabled":
          if (value !== undefined) {
            // This flag requires a reload in order to take full effect,
            // so reload if it has changed.
            if (value != this.isJavascriptEnabled()) {
              shouldReload = true;
            }
            this._setJavascriptEnabled(value);
          }
          break;
        case "overrideDPPX":
          this._setDPPXOverride(value);
          break;
        case "printSimulationEnabled":
          this._setPrintSimulationEnabled(value);
          break;
        case "rdmPaneMaxTouchPoints":
          this._setRDMPaneMaxTouchPoints(value);
          break;
        case "rdmPaneOrientation":
          this._setRDMPaneOrientation(value);
          break;
        case "serviceWorkersTestingEnabled":
          this._setServiceWorkersTestingEnabled(value);
          break;
        case "touchEventsOverride":
          this._setTouchEventsOverride(value);
          break;
        case "cacheDisabled":
          this._setCacheDisabled(value);
          break;
        case "setTabOffline":
          this._setTabOffline(value);
          break;
      }
    }

    if (shouldReload) {
      this._browsingContext.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE);
    }
  }

  _restoreParentProcessConfiguration() {
    // Always process tracer options as this isn't specific to tab debugging
    if (this.#consolePrefValue !== undefined) {
      this._setTracerOptions();
    }

    if (!this._shouldHandleConfigurationInParentProcess()) {
      return;
    }

    this._setServiceWorkersTestingEnabled(false);
    this._setPrintSimulationEnabled(false);
    if (this._resetCacheDisabledOnDestroy) {
      this._setCacheDisabled(false);
    }
    this._setTabOffline(false);

    // Restore the color scheme simulation only if it was explicitly updated
    // by this actor. This will avoid side effects caused when destroying additional
    // targets (e.g. RDM target, WebExtension target, …).
    // TODO: We may want to review other configuration values to see if we should use
    // the same pattern (Bug 1701553).
    if (this._resetColorSchemeSimulationOnDestroy) {
      this._setColorSchemeSimulation(null);
    }

    // Restore the user agent only if it was explicitly updated by this specific actor.
    if (this._initialUserAgent !== undefined) {
      this._setCustomUserAgent(this._initialUserAgent);
    }

    // Restore the origin device pixel ratio only if it was explicitly updated by this
    // specific actor.
    if (this._initialDPPXOverride !== undefined) {
      this._setDPPXOverride(this._initialDPPXOverride);
    }

    if (this._initialJavascriptEnabled !== undefined) {
      this._setJavascriptEnabled(this._initialJavascriptEnabled);
    }

    if (this._initialTouchEventsOverride !== undefined) {
      this._setTouchEventsOverride(this._initialTouchEventsOverride);
    }
  }

  /**
   * Disable or enable the service workers testing features.
   */
  _setServiceWorkersTestingEnabled(enabled) {
    if (this._browsingContext.serviceWorkersTestingEnabled != enabled) {
      this._browsingContext.serviceWorkersTestingEnabled = enabled;
    }
  }

  /**
   * Disable or enable the print simulation.
   */
  _setPrintSimulationEnabled(enabled) {
    const value = enabled ? "print" : "";
    if (this._browsingContext.mediumOverride != value) {
      this._browsingContext.mediumOverride = value;
    }
  }

  /**
   * Disable or enable the color-scheme simulation.
   */
  _setColorSchemeSimulation(override) {
    const value = override || "none";
    if (this._browsingContext.prefersColorSchemeOverride != value) {
      this._browsingContext.prefersColorSchemeOverride = value;
      this._resetColorSchemeSimulationOnDestroy = true;
    }
  }

  /**
   * Set a custom user agent on the page
   *
   * @param {string} userAgent: The user agent to set on the page. If null, will reset the
   *                 user agent to its original value.
   * @returns {boolean} Whether the user agent was changed or not.
   */
  _setCustomUserAgent(userAgent = "") {
    if (this._browsingContext.customUserAgent === userAgent) {
      return;
    }

    if (this._initialUserAgent === undefined) {
      this._initialUserAgent = this._browsingContext.customUserAgent;
    }

    this._browsingContext.customUserAgent = userAgent;
  }

  isJavascriptEnabled() {
    return this._browsingContext.allowJavascript;
  }

  _setJavascriptEnabled(allow) {
    if (this._initialJavascriptEnabled === undefined) {
      this._initialJavascriptEnabled = this._browsingContext.allowJavascript;
    }
    if (allow !== undefined) {
      this._browsingContext.allowJavascript = allow;
    }
  }

  /* DPPX override */
  _setDPPXOverride(dppx) {
    if (this._browsingContext.overrideDPPX === dppx) {
      return;
    }

    if (!dppx && this._initialDPPXOverride) {
      dppx = this._initialDPPXOverride;
    } else if (dppx !== undefined && this._initialDPPXOverride === undefined) {
      this._initialDPPXOverride = this._browsingContext.overrideDPPX;
    }

    if (dppx !== undefined) {
      this._browsingContext.overrideDPPX = dppx;
    }
  }

  /**
   * Set the touchEventsOverride on the browsing context.
   *
   * @param {string} flag: See BrowsingContext.webidl `TouchEventsOverride` enum for values.
   */
  _setTouchEventsOverride(flag) {
    if (this._browsingContext.touchEventsOverride === flag) {
      return;
    }

    if (!flag && this._initialTouchEventsOverride) {
      flag = this._initialTouchEventsOverride;
    } else if (
      flag !== undefined &&
      this._initialTouchEventsOverride === undefined
    ) {
      this._initialTouchEventsOverride =
        this._browsingContext.touchEventsOverride;
    }

    if (flag !== undefined) {
      this._browsingContext.touchEventsOverride = flag;
    }
  }

  /**
   * Overrides navigator.maxTouchPoints.
   * Note that we don't need to reset the original value when the actor is destroyed,
   * as it's directly handled by the platform when RDM is closed.
   *
   * @param {Integer} maxTouchPoints
   */
  _setRDMPaneMaxTouchPoints(maxTouchPoints) {
    this._browsingContext.setRDMPaneMaxTouchPoints(maxTouchPoints);
  }

  /**
   * Set an orientation and an angle on the browsing context. This will be applied only
   * if Responsive Design Mode is enabled.
   *
   * @param {object} options
   * @param {string} options.type: The orientation type of the rotated device.
   * @param {number} options.angle: The rotated angle of the device.
   */
  _setRDMPaneOrientation({ type, angle }) {
    if (this._browsingContext.inRDMPane) {
      this._browsingContext.setOrientationOverride(type, angle);
    }
  }

  /**
   * Disable or enable the cache via the browsing context.
   *
   * @param {boolean} disabled: The state the cache should be changed to
   */
  _setCacheDisabled(disabled) {
    const value = disabled
      ? Ci.nsIRequest.LOAD_BYPASS_CACHE
      : Ci.nsIRequest.LOAD_NORMAL;
    if (this._browsingContext.defaultLoadFlags != value) {
      this._browsingContext.defaultLoadFlags = value;
      this._resetCacheDisabledOnDestroy = true;
    }
  }

  /**
   * Set the browsing context to offline.
   *
   * @param {boolean} offline: Whether the network throttling is set to offline
   */
  _setTabOffline(offline) {
    if (!this._browsingContext.isDiscarded) {
      this._browsingContext.forceOffline = offline;
    }
  }

  destroy() {
    Services.obs.removeObserver(
      this._onBrowsingContextAttached,
      "browsing-context-attached"
    );
    this.watcherActor.off(
      "bf-cache-navigation-pageshow",
      this._onBfCacheNavigation
    );
    // Avoid trying to restore if the related context is already being destroyed
    if (this._browsingContext && !this._browsingContext.isDiscarded) {
      this._restoreParentProcessConfiguration();
    }
    super.destroy();
  }

  /**
   * Called when the tracer is toggled on/off by the frontend.
   * Note that when `options` is defined, it is meant to be enabled.
   * It may not actually be tracing yet depending on the passed options.
   *
   * @param {object} options
   */
  _setTracerOptions(options) {
    if (!options) {
      if (this.#consolePrefValue === LOG_DISABLED) {
        Services.prefs.clearUserPref("logging.console");
      } else {
        Services.prefs.setIntPref("logging.console", this.#consolePrefValue);
      }
      this.#consolePrefValue = undefined;
      if (this.#pageMessagesPrefValue === LOG_DISABLED) {
        Services.prefs.clearUserPref("logging.PageMessages");
      } else {
        Services.prefs.setIntPref(
          "logging.PageMessages",
          this.#pageMessagesPrefValue
        );
      }
      this.#pageMessagesPrefValue = undefined;
      return;
    }

    // Only enable the MOZ_LOG's when recording to the profiler,
    // otherwise it would pollute firefox stdout unexpectedly.
    if (options.logMethod != TRACER_LOG_METHODS.PROFILER) {
      return;
    }

    // Enable `MOZ_LOG=console:5` via the logging.console so that all console API calls
    // are stored in the profiler when recording JS Traces via the profiler.
    //
    // We do this from here as TargetConfiguration runs in the parent process,
    // where we can set preferences. Whereas the profiler tracer actor runs in the content process.
    const LOG_VERBOSE = 5;
    this.#consolePrefValue = Services.prefs.getIntPref(
      "logging.console",
      LOG_DISABLED
    );
    Services.prefs.setIntPref("logging.console", LOG_VERBOSE);
    this.#pageMessagesPrefValue = Services.prefs.getIntPref(
      "logging.PageMessages",
      LOG_DISABLED
    );
    Services.prefs.setIntPref("logging.PageMessages", LOG_VERBOSE);
  }
}

exports.TargetConfigurationActor = TargetConfigurationActor;
