/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
const {
  getOrientation,
} = require("resource://devtools/client/responsive/utils/orientation.js");
const Constants = require("resource://devtools/client/responsive/constants.js");
const {
  CommandsFactory,
} = require("resource://devtools/shared/commands/commands-factory.js");

loader.lazyRequireGetter(
  this,
  "throttlingProfiles",
  "resource://devtools/client/shared/components/throttling/profiles.js"
);
loader.lazyRequireGetter(
  this,
  "message",
  "resource://devtools/client/responsive/utils/message.js"
);
loader.lazyRequireGetter(
  this,
  "showNotification",
  "resource://devtools/client/responsive/utils/notification.js",
  true
);
loader.lazyRequireGetter(
  this,
  "PriorityLevels",
  "resource://devtools/client/shared/components/NotificationBox.js",
  true
);
loader.lazyRequireGetter(
  this,
  "l10n",
  "resource://devtools/client/responsive/utils/l10n.js"
);
loader.lazyRequireGetter(
  this,
  "asyncStorage",
  "resource://devtools/shared/async-storage.js"
);
loader.lazyRequireGetter(
  this,
  "captureAndSaveScreenshot",
  "resource://devtools/client/shared/screenshot.js",
  true
);

const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions.";
const RELOAD_NOTIFICATION_PREF =
  "devtools.responsive.reloadNotification.enabled";
const USE_DYNAMIC_TOOLBAR_PREF = "devtools.responsive.dynamicToolbar.enabled";
const DYNAMIC_TOOLBAR_PREF_ON_TOP_PREF =
  "devtools.responsive.dynamicToolbar.onTop";
const DYNAMIC_TOOLBAR_MAX_HEIGHT = 50; // px
const DYNAMIC_TOOLBAR_SNAP_ANIMATION_DURATION_MS = 120; // ms

function debug(_msg) {
  // console.log(`RDM manager: ${_msg}`);
}

/**
 * ResponsiveUI manages the responsive design tool for a specific tab.  The
 * actual tool itself lives in a separate chrome:// document that is loaded into
 * the tab upon opening responsive design.  This object acts a helper to
 * integrate the tool into the surrounding browser UI as needed.
 */
class ResponsiveUI extends EventEmitter {
  /**
   * @param {ResponsiveUIManager} manager
   *        The ResponsiveUIManager instance.
   * @param {ChromeWindow} window
   *        The main browser chrome window (that holds many tabs).
   * @param {Tab} tab
   *        The specific browser <tab> element this responsive instance is for.
   */
  constructor(manager, window, tab) {
    super();
    this.manager = manager;
    // The main browser chrome window (that holds many tabs).
    this.browserWindow = window;
    // The specific browser tab this responsive instance is for.
    this.tab = tab;

    // Flag set when destruction has begun.
    this.destroying = false;
    // Flag set when destruction has ended.
    this.destroyed = false;
    // The iframe containing the RDM UI.
    this.rdmFrame = null;
    // The div containing resizers and the dynamic toolbar.
    this.screenBox = null;

    // Bind callbacks for resizers.
    this.onResizeDrag = this.onResizeDrag.bind(this);
    this.onResizeStart = this.onResizeStart.bind(this);
    this.onResizeStop = this.onResizeStop.bind(this);

    this.onTargetAvailable = this.onTargetAvailable.bind(this);

    this.networkFront = null;
    // Promise resolved when the UI init has completed.
    const { promise, resolve } = Promise.withResolvers();
    this.initialized = promise;
    this.resolveInited = resolve;

    this.dynamicToolbar = null;
    this.browserBottomCover = null;
    this.dynamicToolbarEnabled = Services.prefs.getBoolPref(
      USE_DYNAMIC_TOOLBAR_PREF,
      false
    );
    this.dynamicToolbarOnTop = Services.prefs.getBoolPref(
      DYNAMIC_TOOLBAR_PREF_ON_TOP_PREF,
      false
    );
    this.currentDynamicToolbarHeight = 0;
    this.dynamicToolbarSnapAnimation = null;
    this.mouseScreenYWhilePressed = null;
  }

  get toolWindow() {
    return this.rdmFrame.contentWindow;
  }

  get docShell() {
    return this.toolWindow.docShell;
  }

  get viewportElement() {
    return this.browserStackEl.querySelector("browser");
  }

  get currentTarget() {
    return this.commands.targetCommand.targetFront;
  }

  get watcherFront() {
    return this.resourceCommand.watcherFront;
  }

  /**
   * Open RDM while preserving the state of the page.
   */
  async initialize() {
    debug("Init start");

    this.initRDMFrame();

    // Hide the browser content temporarily while things move around to avoid displaying
    // strange intermediate states.
    this.hideBrowserUI();

    // To clean up RDM synchronously, watch for:
    // - tab close
    // - tab discarded
    // - window close
    this.tab.addEventListener("TabBrowserDiscarded", this);
    this.tab.addEventListener("TabClose", this);
    this.browserWindow.addEventListener("unload", this);

    this.rdmFrame.contentWindow.addEventListener("message", this);

    this.tab.linkedBrowser.enterResponsiveMode();

    // Listen to FullZoomChange events coming from the browser window,
    // so that we can zoom the size of the viewport by the same amount.
    this.browserWindow.addEventListener("FullZoomChange", this);

    // Get the protocol ready to speak with responsive emulation actor
    debug("Wait until RDP server connect");
    await this.connectToServer();

    // Restore the previous UI state.
    await this.restoreUIState();

    // Show the browser UI now that its state is ready.
    this.showBrowserUI();

    // Non-blocking message to tool UI to start any delayed init activities
    message.post(this.toolWindow, "post-init");

    debug("Init done");
    this.resolveInited();
  }

  /**
   * Initialize the RDM iframe inside of the browser document.
   */
  initRDMFrame() {
    const { document: doc, gBrowser } = this.browserWindow;
    const rdmFrame = doc.createElement("iframe");
    rdmFrame.src = "chrome://devtools/content/responsive/toolbar.xhtml";
    rdmFrame.classList.add("rdm-toolbar");

    this.screenBox = doc.createElement("div");
    this.screenBox.classList.add("rdm-screen-box");

    // Create dynamic toolbar
    this.dynamicToolbar = doc.createElement("div");
    this.dynamicToolbar.classList.add("rdm-dynamic-toolbar", "dynamic-toolbar");
    this.dynamicToolbar.classList.toggle(
      "dynamic-toolbar-enabled",
      this.dynamicToolbarEnabled
    );
    this.dynamicToolbar.classList.toggle(
      "dynamic-toolbar-on-top",
      this.dynamicToolbarOnTop
    );

    const dynamicToolbarInner = doc.createElement("div");
    dynamicToolbarInner.classList.add("rdm-dynamic-toolbar-inner");
    this.dynamicToolbar.append(dynamicToolbarInner);

    // Create .rdm-browser-bottom-cover which which will cover up any bits of the
    // browser that stick out of the screen box when pushed down by the dynamic
    // toolbar (when the toolbar is on the top).
    // We'd prefer to clip off the bottom using e.g. overflow:clip, but we
    // don't have an ancestor element in the DOM that can act as the clip.
    //
    // Ideally we wouldn't need to move the browser at all when the dynamic
    // toolbar moves. This is tracked in bug 1921331.
    // But in the meantime, this "move the browser" implementation matches how
    // the top toolbar works in Firefox for Android. This includes visual glitches
    // such as fixed-to-the-bottom elements that wiggle during scrolling.
    this.browserBottomCover = doc.createElement("div");
    this.browserBottomCover.classList.add("rdm-browser-bottom-cover");

    // Create resizer handlers
    const resizeHandle = doc.createElement("div");
    resizeHandle.classList.add(
      "rdm-viewport-resize-handle",
      "viewport-resize-handle"
    );
    const resizeHandleX = doc.createElement("div");
    resizeHandleX.classList.add(
      "rdm-viewport-resize-handle",
      "viewport-horizontal-resize-handle"
    );
    const resizeHandleY = doc.createElement("div");
    resizeHandleY.classList.add(
      "rdm-viewport-resize-handle",
      "viewport-vertical-resize-handle"
    );

    this.browserContainerEl = gBrowser.getBrowserContainer(
      gBrowser.getBrowserForTab(this.tab)
    );
    this.browserStackEl =
      this.browserContainerEl.querySelector(".browserStack");

    this.browserContainerEl.classList.add("responsive-mode");

    this.browserContainerEl.style.setProperty(
      "--rdm-dynamic-toolbar-max-height",
      DYNAMIC_TOOLBAR_MAX_HEIGHT + "px"
    );

    // Prepend the RDM iframe inside of the current tab's browser container.
    this.browserContainerEl.prepend(rdmFrame);

    // Put .rdm-screen-box inside the browser stack, as a sibling to the browser.
    this.browserStackEl.append(this.screenBox);

    this.screenBox.append(
      this.dynamicToolbar,
      this.browserBottomCover,
      resizeHandle,
      resizeHandleX,
      resizeHandleY
    );

    if (this.dynamicToolbarEnabled) {
      this.dynamicToolbar.style.height = DYNAMIC_TOOLBAR_MAX_HEIGHT + "px";
      InspectorUtils.setDynamicToolbarMaxHeight(
        this.tab.linkedBrowser.browsingContext,
        DYNAMIC_TOOLBAR_MAX_HEIGHT
      );
      this.setCurrentDynamicToolbarHeight(DYNAMIC_TOOLBAR_MAX_HEIGHT);
      this.tab.linkedBrowser.addEventListener("mousedown", this);
      this.tab.linkedBrowser.addEventListener("mousemove", this);
      this.tab.linkedBrowser.addEventListener("mouseup", this);
    }

    // Wait for the frame script to be loaded.
    message.wait(rdmFrame.contentWindow, "script-init").then(async () => {
      // Notify the frame window that the Resposnive UI manager has begun initializing.
      // At this point, we can render our React content inside the frame.
      message.post(rdmFrame.contentWindow, "init");
      // Wait for the tools to be rendered above the content. The frame script will
      // then dispatch the necessary actions to the Redux store to give the toolbar the
      // state it needs.
      message.wait(rdmFrame.contentWindow, "init:done").then(() => {
        rdmFrame.contentWindow.addInitialViewport({
          userContextId: this.tab.userContextId,
        });
      });
    });

    this.rdmFrame = rdmFrame;

    this.resizeHandle = resizeHandle;
    this.resizeHandle.addEventListener("mousedown", this.onResizeStart);

    this.resizeHandleX = resizeHandleX;
    this.resizeHandleX.addEventListener("mousedown", this.onResizeStart);

    this.resizeHandleY = resizeHandleY;
    this.resizeHandleY.addEventListener("mousedown", this.onResizeStart);

    this.resizeToolbarObserver = new this.browserWindow.ResizeObserver(
      entries => {
        for (const entry of entries) {
          // If the toolbar needs extra space for the UA input, then set a class
          // that will accomodate its height. We should also make sure to keep
          // the width value we're toggling against in sync with the media-query
          // in devtools/client/responsive/index.css
          this.rdmFrame.classList.toggle(
            "accomodate-ua",
            entry.contentBoxSize[0].inlineSize <= 800
          );
        }
      }
    );

    this.resizeToolbarObserver.observe(this.browserStackEl);
  }

  /**
   * Close RDM and restore page content back into a regular tab.
   *
   * @param object
   *        Destroy options, which currently includes a `reason` string.
   * @return boolean
   *         Whether this call is actually destroying.  False means destruction
   *         was already in progress.
   */
  async destroy(options) {
    if (this.destroying) {
      return false;
    }
    this.destroying = true;

    // If our tab is about to be closed, there's not enough time to exit
    // gracefully, but that shouldn't be a problem since the tab will go away.
    // So, skip any waiting when we're about to close the tab.
    const isTabDestroyed = !this.tab.linkedBrowser;
    const isWindowClosing = options?.reason === "unload" || isTabDestroyed;
    const isTabContentDestroying =
      isWindowClosing ||
      ["TabBrowserDiscarded", "TabClose"].includes(options?.reason);

    // Ensure init has finished before starting destroy
    if (!isTabContentDestroying) {
      await this.initialized;

      // Restore screen orientation of physical device.
      await Promise.all([
        this.updateScreenOrientation("landscape-primary", 0),
        this.updateMaxTouchPointsEnabled(false),
      ]);

      // Hide browser UI to avoid displaying weird intermediate states while closing.
      this.hideBrowserUI();

      // Resseting the throtting needs to be done before the
      // network events watching is stopped.
      await this.updateNetworkThrottling();
    }

    this.tab.removeEventListener("TabBrowserDiscarded", this);
    this.tab.removeEventListener("TabClose", this);
    this.browserWindow.removeEventListener("unload", this);

    // Browsing context might be gone already (eg when discarding).
    // leaveResponsiveMode only updates linkedBrowser.browsingContext, so it can
    // be skipped in this case.
    if (this.tab.linkedBrowser.browsingContext) {
      this.tab.linkedBrowser.leaveResponsiveMode();
    }

    this.browserWindow.removeEventListener("FullZoomChange", this);

    this.tab.linkedBrowser.removeEventListener("mousedown", this);
    this.tab.linkedBrowser.removeEventListener("mousemove", this);
    this.tab.linkedBrowser.removeEventListener("mouseup", this);

    // When discarding, the content document may already have been destroyed.
    this.rdmFrame.contentWindow?.removeEventListener("message", this);

    // Remove observers on the stack.
    this.resizeToolbarObserver.unobserve(this.browserStackEl);

    // Cleanup the frame content before disconnecting the frame element.
    this.rdmFrame.contentWindow?.destroy();

    this.rdmFrame.remove();
    this.screenBox.remove();

    this.browserContainerEl.classList.remove("responsive-mode");
    this.browserStackEl.style.removeProperty("--rdm-width");
    this.browserStackEl.style.removeProperty("--rdm-height");
    this.browserStackEl.style.removeProperty("--rdm-zoom");

    // Remove any .style.top remnants (set by setCurrentDynamicToolbarHeight).
    this.tab.linkedBrowser.style.removeProperty("top");

    // Reset any dynamic toolbar related settings on the browser.
    InspectorUtils.setVerticalClipping(
      this.tab.linkedBrowser.browsingContext,
      0
    );
    InspectorUtils.setDynamicToolbarMaxHeight(
      this.tab.linkedBrowser.browsingContext,
      0
    );

    // Ensure the tab is reloaded if required when exiting RDM so that no emulated
    // settings are left in a customized state.
    if (!isTabContentDestroying) {
      let reloadNeeded = false;
      await this.updateDPPX(null);
      reloadNeeded |=
        (await this.updateUserAgent()) && this.reloadOnChange("userAgent");

      // Don't reload on the server if we're already doing a reload on the client
      const reloadOnTouchSimulationChange =
        this.reloadOnChange("touchSimulation") && !reloadNeeded;
      await this.updateTouchSimulation(null, reloadOnTouchSimulationChange);
      if (reloadNeeded) {
        await this.reloadSelectedTab();
      }

      // Unwatch targets & resources as the last step. If we are not waching for
      // any resource & target anymore, the JSWindowActors will be unregistered
      // which will trigger an early destruction of the RDM target, before we
      // could finalize the cleanup.
      this.commands.targetCommand.unwatchTargets({
        types: [this.commands.targetCommand.TYPES.FRAME],
        onAvailable: this.onTargetAvailable,
      });

      this.resourceCommand.unwatchResources(
        [this.resourceCommand.TYPES.NETWORK_EVENT],
        { onAvailable: this.onNetworkResourceAvailable }
      );

      this.commands.targetCommand.destroy();
    }

    // Show the browser UI now.
    this.showBrowserUI();

    // Destroy local state
    this.browserContainerEl = null;
    this.browserStackEl = null;
    this.browserWindow = null;
    this.tab = null;
    this.initialized = null;
    this.rdmFrame = null;
    this.screenBox = null;
    this.resizeHandle = null;
    this.resizeHandleX = null;
    this.resizeHandleY = null;
    this.dynamicToolbar = null;
    this.resizeToolbarObserver = null;

    // Destroying the commands will close the devtools client used to speak with responsive emulation actor.
    // The actor handles clearing any overrides itself, so it's not necessary to clear
    // anything on shutdown client side.
    const commandsDestroyed = this.commands.destroy();
    if (!isTabContentDestroying) {
      await commandsDestroyed;
    }
    this.commands = null;
    this.destroyed = true;

    return true;
  }

  async connectToServer() {
    this.commands = await CommandsFactory.forTab(this.tab);
    this.resourceCommand = this.commands.resourceCommand;

    await this.commands.targetCommand.startListening();

    await this.commands.targetCommand.watchTargets({
      types: [this.commands.targetCommand.TYPES.FRAME],
      onAvailable: this.onTargetAvailable,
    });

    // To support network throttling the resource command
    // needs to be watching for network resources.
    await this.resourceCommand.watchResources(
      [this.resourceCommand.TYPES.NETWORK_EVENT],
      { onAvailable: this.onNetworkResourceAvailable }
    );

    this.networkFront = await this.watcherFront.getNetworkParentActor();
  }

  /**
   * Show one-time notification about reloads for responsive emulation.
   */
  showReloadNotification() {
    if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) {
      showNotification(this.browserWindow, this.tab, {
        msg: l10n.getFormatStr("responsive.reloadNotification.description2"),
      });
      Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false);
    }
  }

  reloadOnChange(id) {
    this.showReloadNotification();
    const pref = RELOAD_CONDITION_PREF_PREFIX + id;
    return Services.prefs.getBoolPref(pref, false);
  }

  hideBrowserUI() {
    this.tab.linkedBrowser.style.visibility = "hidden";
    this.resizeHandle.style.visibility = "hidden";
  }

  showBrowserUI() {
    this.tab.linkedBrowser.style.removeProperty("visibility");
    this.resizeHandle.style.removeProperty("visibility");
  }

  handleEvent(event) {
    const { browserWindow, tab } = this;

    switch (event.type) {
      case "message":
        this.handleMessage(event);
        break;
      case "FullZoomChange": {
        // Get the current device size and update to that size, which
        // will pick up changes to the zoom.
        const { width, height } = this.getViewportSize();
        this.updateViewportSize(width, height);
        break;
      }
      case "TabBrowserDiscarded":
      case "TabClose":
      case "unload":
        this.manager.closeIfNeeded(browserWindow, tab, {
          reason: event.type,
        });
        break;
      case "mousedown":
      case "mousemove":
      case "mouseup":
        this.handleBrowserMouseEvent(event);
        break;
    }
  }

  handleMessage(event) {
    if (event.origin !== "chrome://devtools") {
      return;
    }

    switch (event.data.type) {
      case "change-device":
        this.onChangeDevice(event);
        break;
      case "change-network-throttling":
        this.onChangeNetworkThrottling(event);
        break;
      case "change-pixel-ratio":
        this.onChangePixelRatio(event);
        break;
      case "change-touch-simulation":
        this.onChangeTouchSimulation(event);
        break;
      case "change-user-agent":
        this.onChangeUserAgent(event);
        break;
      case "exit":
        this.onExit();
        break;
      case "remove-device-association":
        this.onRemoveDeviceAssociation(event);
        break;
      case "viewport-orientation-change":
        this.onRotateViewport(event);
        break;
      case "viewport-resize":
        this.onResizeViewport(event);
        break;
      case "screenshot":
        this.onScreenshot();
        break;
      case "toggle-left-alignment":
        this.onToggleLeftAlignment(event);
        break;
      case "update-device-modal":
        this.onUpdateDeviceModal(event);
        break;
    }
  }

  handleBrowserMouseEvent(evt) {
    // We only listen to mouse events in order to simulate dynamic toolbar movement.
    if (!this.dynamicToolbarEnabled) {
      return;
    }

    // Ignore all but real mouse event coming from physical mouse. This matches
    // what touch-simulator.js does inside the content process.
    if (
      evt.button ||
      evt.inputSource != evt.MOZ_SOURCE_MOUSE ||
      evt.isSynthesized
    ) {
      return;
    }

    switch (evt.type) {
      case "mousedown":
        this.mouseScreenYWhilePressed = evt.screenY;
        this.interruptDynamicToolbarSnapAnimation();
        break;

      case "mousemove": {
        if (this.mouseScreenYWhilePressed === null) {
          return;
        }

        const deltaY = evt.screenY - this.mouseScreenYWhilePressed;
        this.mouseScreenYWhilePressed = evt.screenY;
        this.setCurrentDynamicToolbarHeight(
          this.currentDynamicToolbarHeight + deltaY
        );
        break;
      }

      case "mouseup": {
        if (this.mouseScreenYWhilePressed === null) {
          return;
        }

        const deltaY = evt.screenY - this.mouseScreenYWhilePressed;
        this.mouseScreenYWhilePressed = null;
        this.setCurrentDynamicToolbarHeight(
          this.currentDynamicToolbarHeight + deltaY
        );
        this.startDynamicToolbarSnapAnimation();
        break;
      }
    }
  }

  async onChangeDevice(event) {
    const { pixelRatio, touch, userAgent } = event.data.device;
    let reloadNeeded = false;
    await this.updateDPPX(pixelRatio);

    // Get the orientation values of the device we are changing to and update.
    const { device, viewport } = event.data;
    const { type, angle } = getOrientation(device, viewport);
    await this.updateScreenOrientation(type, angle);
    await this.updateMaxTouchPointsEnabled(touch);

    reloadNeeded |=
      (await this.updateUserAgent(userAgent)) &&
      this.reloadOnChange("userAgent");

    // Don't reload on the server if we're already doing a reload on the client
    const reloadOnTouchSimulationChange =
      this.reloadOnChange("touchSimulation") && !reloadNeeded;
    await this.updateTouchSimulation(touch, reloadOnTouchSimulationChange);

    if (reloadNeeded) {
      this.reloadSelectedTab();
    }

    // Used by tests
    this.emitForTests("device-changed", {
      reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange,
    });
  }

  async onChangeNetworkThrottling(event) {
    const { enabled, profile } = event.data;
    await this.updateNetworkThrottling(enabled, profile);
    // Used by tests
    this.emit("network-throttling-changed");
  }

  onChangePixelRatio(event) {
    const { pixelRatio } = event.data;
    this.updateDPPX(pixelRatio);
  }

  async onChangeTouchSimulation(event) {
    const { enabled } = event.data;

    await this.updateMaxTouchPointsEnabled(enabled);

    await this.updateTouchSimulation(
      enabled,
      this.reloadOnChange("touchSimulation")
    );

    // Used by tests
    this.emit("touch-simulation-changed");
  }

  async onChangeUserAgent(event) {
    const { userAgent } = event.data;
    const reloadNeeded =
      (await this.updateUserAgent(userAgent)) &&
      this.reloadOnChange("userAgent");
    if (reloadNeeded) {
      this.reloadSelectedTab();
    }
    this.emit("user-agent-changed");
  }

  onExit() {
    const { browserWindow, tab } = this;
    this.manager.closeIfNeeded(browserWindow, tab);
  }

  async onRemoveDeviceAssociation(event) {
    const { resetProfile } = event.data;

    if (resetProfile) {
      let reloadNeeded = false;
      await this.updateDPPX(null);
      reloadNeeded |=
        (await this.updateUserAgent()) && this.reloadOnChange("userAgent");

      // Don't reload on the server if we're already doing a reload on the client
      const reloadOnTouchSimulationChange =
        this.reloadOnChange("touchSimulation") && !reloadNeeded;
      await this.updateTouchSimulation(null, reloadOnTouchSimulationChange);
      if (reloadNeeded) {
        this.reloadSelectedTab();
      }
    }

    // Used by tests
    this.emitForTests("device-association-removed");
  }

  /**
   * Resizing the browser on mousemove
   */
  onResizeDrag({ screenX, screenY }) {
    if (!this.isResizing || !this.rdmFrame.contentWindow) {
      return;
    }

    const zoom = this.tab.linkedBrowser.fullZoom;

    let deltaX = (screenX - this.lastScreenX) / zoom;
    let deltaY = (screenY - this.lastScreenY) / zoom;

    const leftAlignmentEnabled = Services.prefs.getBoolPref(
      "devtools.responsive.leftAlignViewport.enabled",
      false
    );

    if (!leftAlignmentEnabled) {
      // The viewport is centered horizontally, so horizontal resize resizes
      // by twice the distance the mouse was dragged - on left and right side.
      deltaX = deltaX * 2;
    }

    if (this.ignoreX) {
      deltaX = 0;
    }
    if (this.ignoreY) {
      deltaY = 0;
    }

    const viewportSize = this.getViewportSize();

    let width = Math.round(viewportSize.width + deltaX);
    let height = Math.round(viewportSize.height + deltaY);

    if (width < Constants.MIN_VIEWPORT_DIMENSION) {
      width = Constants.MIN_VIEWPORT_DIMENSION;
    } else if (width != viewportSize.width) {
      this.lastScreenX = screenX;
    }

    if (height < Constants.MIN_VIEWPORT_DIMENSION) {
      height = Constants.MIN_VIEWPORT_DIMENSION;
    } else if (height != viewportSize.height) {
      this.lastScreenY = screenY;
    }

    // Update the RDM store and viewport size with the new width and height.
    this.rdmFrame.contentWindow.setViewportSize({ width, height });
    this.updateViewportSize(width, height);

    // Change the device selector back to an unselected device
    if (this.rdmFrame.contentWindow.getAssociatedDevice()) {
      this.rdmFrame.contentWindow.clearDeviceAssociation();
    }
  }

  /**
   * Start the process of resizing the browser.
   */
  onResizeStart({ target, screenX, screenY }) {
    this.browserWindow.addEventListener("mousemove", this.onResizeDrag, true);
    this.browserWindow.addEventListener("mouseup", this.onResizeStop, true);

    this.isResizing = true;
    this.lastScreenX = screenX;
    this.lastScreenY = screenY;
    this.ignoreX = target === this.resizeHandleY;
    this.ignoreY = target === this.resizeHandleX;
  }

  /**
   * Stop the process of resizing the browser.
   */
  onResizeStop() {
    this.browserWindow.removeEventListener(
      "mousemove",
      this.onResizeDrag,
      true
    );
    this.browserWindow.removeEventListener("mouseup", this.onResizeStop, true);

    this.isResizing = false;
    this.lastScreenX = 0;
    this.lastScreenY = 0;
    this.ignoreX = false;
    this.ignoreY = false;

    // Used by tests.
    this.emit("viewport-resize-dragend");
  }

  onResizeViewport(event) {
    const { width, height } = event.data;
    this.updateViewportSize(width, height);
    this.emit("viewport-resize", {
      width,
      height,
    });
  }

  async onRotateViewport(event) {
    const { orientationType: type, angle } = event.data;
    await this.updateScreenOrientation(type, angle);
  }

  async onScreenshot() {
    const messages = await captureAndSaveScreenshot(
      this.currentTarget,
      this.browserWindow
    );

    const priorityMap = {
      error: PriorityLevels.PRIORITY_CRITICAL_HIGH,
      warn: PriorityLevels.PRIORITY_WARNING_HIGH,
    };
    for (const { text, level } of messages) {
      // captureAndSaveScreenshot returns "saved" messages, that indicate where the
      // screenshot was saved. We don't want to display them as the download UI can be
      // used to open the file.
      if (level !== "warn" && level !== "error") {
        continue;
      }

      showNotification(this.browserWindow, this.tab, {
        msg: text,
        priority: priorityMap[level],
      });
    }

    message.post(this.rdmFrame.contentWindow, "screenshot-captured");
  }

  onToggleLeftAlignment(event) {
    this.updateUIAlignment(event.data.leftAlignmentEnabled);
  }

  onUpdateDeviceModal(event) {
    this.rdmFrame.classList.toggle("device-modal-opened", event.data.isOpen);
  }

  async hasDeviceState() {
    const deviceState = await asyncStorage.getItem(
      "devtools.responsive.deviceState"
    );
    return !!deviceState;
  }

  /**
   * Restores the previous UI state.
   */
  async restoreUIState() {
    const leftAlignmentEnabled = Services.prefs.getBoolPref(
      "devtools.responsive.leftAlignViewport.enabled",
      false
    );

    this.updateUIAlignment(leftAlignmentEnabled);

    const height = Services.prefs.getIntPref(
      "devtools.responsive.viewport.height",
      0
    );
    const width = Services.prefs.getIntPref(
      "devtools.responsive.viewport.width",
      0
    );
    this.updateViewportSize(width, height);
  }

  /**
   * Restores the previous actor state.
   *
   * @param {boolean} isTargetSwitching
   */
  async restoreActorState(isTargetSwitching) {
    // It's possible the target will switch to a page loaded in the
    // parent-process (i.e: about:robots). When this happens, the values set
    // on the BrowsingContext by RDM are not preserved. So we need to call
    // enterResponsiveMode whenever there is a target switch.
    this.tab.linkedBrowser.enterResponsiveMode();

    // If the target follows the window global lifecycle, the configuration was already
    // restored from the server during target switch, so we can stop here.
    // This function is still called at startup to restore potential state from previous
    // RDM session so we only stop here during target switching.
    if (
      isTargetSwitching &&
      this.commands.targetCommand.targetFront.targetForm
        .followWindowGlobalLifeCycle
    ) {
      return;
    }

    const hasDeviceState = await this.hasDeviceState();
    if (hasDeviceState) {
      // Return if there is a device state to restore, this will be done when the
      // device list is loaded after the post-init.
      return;
    }

    const height = Services.prefs.getIntPref(
      "devtools.responsive.viewport.height",
      0
    );
    const pixelRatio = Services.prefs.getIntPref(
      "devtools.responsive.viewport.pixelRatio",
      0
    );
    const touchSimulationEnabled = Services.prefs.getBoolPref(
      "devtools.responsive.touchSimulation.enabled",
      false
    );
    const userAgent = Services.prefs.getCharPref(
      "devtools.responsive.userAgent",
      ""
    );
    const width = Services.prefs.getIntPref(
      "devtools.responsive.viewport.width",
      0
    );

    // Restore the previously set orientation, or get it from the initial viewport if it
    // wasn't set yet.
    const { type, angle } =
      this.commands.targetConfigurationCommand.configuration
        .rdmPaneOrientation ||
      this.getInitialViewportOrientation({
        width,
        height,
      });

    await this.updateDPPX(pixelRatio);
    await this.updateScreenOrientation(type, angle);
    await this.updateMaxTouchPointsEnabled(touchSimulationEnabled);

    if (touchSimulationEnabled) {
      await this.updateTouchSimulation(touchSimulationEnabled);
    }

    let reloadNeeded = false;
    if (userAgent) {
      reloadNeeded |=
        (await this.updateUserAgent(userAgent)) &&
        this.reloadOnChange("userAgent");
    }
    if (reloadNeeded) {
      await this.reloadSelectedTab();
    }
  }

  /**
   * Set or clear the emulated device pixel ratio.
   *
   * @param {number | null} dppx: The ratio to simulate. Set to null to disable the
   *                      simulation and roll back to the original ratio
   */
  async updateDPPX(dppx = null) {
    await this.commands.targetConfigurationCommand.updateConfiguration({
      overrideDPPX: dppx,
    });
  }

  /**
   * Set or clear network throttling.
   *
   * @return boolean
   *         Whether a reload is needed to apply the change.
   *         (This is always immediate, so it's always false.)
   */
  async updateNetworkThrottling(enabled, profile) {
    if (!enabled) {
      await this.networkFront.clearNetworkThrottling();
      await this.commands.targetConfigurationCommand.updateConfiguration({
        setTabOffline: false,
      });
      return false;
    }
    const data = throttlingProfiles.profiles.find(({ id }) => id == profile);
    const { download, upload, latency, id } = data;

    // Update offline mode
    await this.commands.targetConfigurationCommand.updateConfiguration({
      setTabOffline: id === throttlingProfiles.PROFILE_CONSTANTS.OFFLINE,
    });

    await this.networkFront.setNetworkThrottling({
      downloadThroughput: download,
      uploadThroughput: upload,
      latency,
    });
    return false;
  }

  /**
   * Set or clear the emulated user agent.
   *
   * @param {string | null} userAgent: The user agent to set on the page. Set to null to revert
   *                      the user agent to its original value
   * @return {boolean} Whether a reload is needed to apply the change.
   */
  async updateUserAgent(userAgent) {
    const getConfigurationCustomUserAgent = () =>
      this.commands.targetConfigurationCommand.configuration.customUserAgent ||
      "";
    const previousCustomUserAgent = getConfigurationCustomUserAgent();
    await this.commands.targetConfigurationCommand.updateConfiguration({
      customUserAgent: userAgent,
    });

    const updatedUserAgent = getConfigurationCustomUserAgent();
    return previousCustomUserAgent !== updatedUserAgent;
  }

  /**
   * Set or clear touch simulation. When setting to true, this method will
   * additionally set meta viewport override.
   * When setting to false, this method will clear all touch simulation and meta viewport
   * overrides, returning to default behavior for both settings.
   *
   * @param {boolean} enabled
   * @param {boolean} reloadOnTouchSimulationToggle: Set to true to trigger a page reload
   *        if the touch simulation state changes.
   */
  async updateTouchSimulation(enabled, reloadOnTouchSimulationToggle) {
    await this.commands.targetConfigurationCommand.updateConfiguration({
      touchEventsOverride: enabled ? "enabled" : null,
      reloadOnTouchSimulationToggle,
    });
  }

  /**
   * Sets the screen orientation values of the simulated device.
   *
   * @param {string} type
   *        The orientation type to update the current device screen to.
   * @param {number} angle
   *        The rotation angle to update the current device screen to.
   */
  async updateScreenOrientation(type, angle) {
    // We need to call the method on the parent process
    await this.commands.targetConfigurationCommand.updateConfiguration({
      rdmPaneOrientation: { type, angle },
    });
  }

  /**
   * Sets whether or not maximum touch points are supported for the simulated device.
   *
   * @param {boolean} touchSimulationEnabled
   *        Whether or not touch is enabled for the simulated device.
   */
  async updateMaxTouchPointsEnabled(touchSimulationEnabled) {
    return this.commands.targetConfigurationCommand.updateConfiguration({
      rdmPaneMaxTouchPoints: touchSimulationEnabled ? 1 : 0,
    });
  }

  /**
   * Sets whether or not the RDM UI should be left-aligned.
   *
   * @param {boolean} leftAlignmentEnabled
   *        Whether or not the UI is left-aligned.
   */
  updateUIAlignment(leftAlignmentEnabled) {
    this.browserContainerEl.classList.toggle(
      "left-aligned",
      leftAlignmentEnabled
    );
  }

  /**
   * Sets the browser element to be the given width and height.
   *
   * @param {number} width
   *        The viewport's width.
   * @param {number} height
   *        The viewport's height.
   */
  updateViewportSize(width, height) {
    const zoom = this.tab.linkedBrowser.fullZoom;

    // Setting this with a variable on the stack instead of directly as width/height
    // on the <browser> because we'll need to use this for the alert dialog as well.
    this.browserStackEl.style.setProperty("--rdm-width", `${width}px`);
    this.browserStackEl.style.setProperty("--rdm-height", `${height}px`);
    this.browserStackEl.style.setProperty("--rdm-zoom", zoom);

    // This is a bit premature, but we emit a content-resize event here. It
    // would be preferrable to wait until the viewport is actually resized,
    // but the "resize" event is not triggered by this style change. The
    // content-resize message is only used by tests, and if needed those tests
    // can use the testing function setViewportSizeAndAwaitReflow to ensure
    // the viewport has had time to reach this size.
    this.emit("content-resize", {
      width,
      height,
    });
  }

  /**
   * Helper for tests. Assumes a single viewport for now.
   */
  getViewportSize() {
    // The getViewportSize function is loaded in index.js, and might not be
    // available yet.
    if (this.toolWindow.getViewportSize) {
      return this.toolWindow.getViewportSize();
    }

    return { width: 0, height: 0 };
  }

  /**
   * Helper for tests, etc. Assumes a single viewport for now.
   */
  async setViewportSize(size) {
    await this.initialized;

    // Ensure that width and height are valid.
    let { width, height } = size;
    if (!size.width) {
      width = this.getViewportSize().width;
    }

    if (!size.height) {
      height = this.getViewportSize().height;
    }

    this.rdmFrame.contentWindow.setViewportSize({ width, height });
    this.updateViewportSize(width, height);
  }

  /**
   * Helper for tests/reloading the viewport. Assumes a single viewport for now.
   */
  getViewportBrowser() {
    return this.tab.linkedBrowser;
  }

  /**
   * Helper for contacting the viewport content. Assumes a single viewport for now.
   */
  getViewportMessageManager() {
    return this.getViewportBrowser().messageManager;
  }

  /**
   * Helper for getting the initial viewport orientation.
   */
  getInitialViewportOrientation(viewport) {
    return getOrientation(viewport, viewport);
  }

  /**
   * Helper for tests to get the browser's window.
   */
  getBrowserWindow() {
    return this.browserWindow;
  }

  clamp(min, max, value) {
    return Math.min(Math.max(value, min), max);
  }

  setCurrentDynamicToolbarHeight(unclampedHeight) {
    const height = this.clamp(0, DYNAMIC_TOOLBAR_MAX_HEIGHT, unclampedHeight);
    this.currentDynamicToolbarHeight = height;
    this.dynamicToolbar.style.height = height + "px";
    if (this.dynamicToolbarOnTop) {
      this.tab.linkedBrowser.style.top = height + "px";
      this.browserBottomCover.style.height = height + "px";
    }
    const offset = height - DYNAMIC_TOOLBAR_MAX_HEIGHT;
    InspectorUtils.setVerticalClipping(
      this.tab.linkedBrowser.browsingContext,
      offset
    );
  }

  /**
   * Make the dynamic toolbar snap to either fully-visible or fully-hidden, with
   * a short animation. This animation can be interrupted with a call to
   * interruptDynamicToolbarSnapAnimation() when a new scroll gesture starts.
   *
   * We use requestAnimationFrame here instead of CSS transitions so that
   * setCurrentDynamicToolbarHeight can call setVerticalClipping in sync with
   * the toolbar height change on each animation frame.
   */
  startDynamicToolbarSnapAnimation() {
    if (
      this.currentDynamicToolbarHeight == 0 ||
      this.currentDynamicToolbarHeight == DYNAMIC_TOOLBAR_MAX_HEIGHT
    ) {
      return;
    }

    const destHeight =
      this.currentDynamicToolbarHeight <= DYNAMIC_TOOLBAR_MAX_HEIGHT / 2
        ? 0
        : DYNAMIC_TOOLBAR_MAX_HEIGHT;
    const startTimestamp = this.browserWindow.performance.now();
    const endTimestamp =
      startTimestamp + DYNAMIC_TOOLBAR_SNAP_ANIMATION_DURATION_MS;
    const startHeight = this.currentDynamicToolbarHeight;
    const endHeight = destHeight;
    const rafHandle = this.browserWindow.requestAnimationFrame(() =>
      this.updateDynamicToolbarSnapAnimation()
    );
    this.dynamicToolbarSnapAnimation = {
      startTimestamp,
      endTimestamp,
      startHeight,
      endHeight,
      rafHandle,
    };
  }

  updateDynamicToolbarSnapAnimation() {
    if (this.dynamicToolbarSnapAnimation === null) {
      return;
    }

    const { startTimestamp, endTimestamp, startHeight, endHeight } =
      this.dynamicToolbarSnapAnimation;
    const currentTimestamp = this.browserWindow.performance.now();
    let t =
      (currentTimestamp - startTimestamp) / (endTimestamp - startTimestamp);
    t = this.clamp(0, 1, t);
    const k = t * t * (2 - t); // smooth (similar to ease-in-out)
    const h = k * endHeight + (1 - k) * startHeight; // lerp
    this.setCurrentDynamicToolbarHeight(h);

    if (currentTimestamp < endTimestamp) {
      this.dynamicToolbarSnapAnimation.rafHandle =
        this.browserWindow.requestAnimationFrame(() =>
          this.updateDynamicToolbarSnapAnimation()
        );
    } else {
      this.dynamicToolbarSnapAnimation = null;
    }
  }

  interruptDynamicToolbarSnapAnimation() {
    if (this.dynamicToolbarSnapAnimation === null) {
      return;
    }

    this.browserWindow.cancelAnimationFrame(
      this.dynamicToolbarSnapAnimation.rafHandle
    );
    this.dynamicToolbarSnapAnimation = null;
  }

  async onTargetAvailable({ targetFront, isTargetSwitching }) {
    if (this.destroying) {
      return;
    }

    if (targetFront.isTopLevel) {
      await this.restoreActorState(isTargetSwitching);
      this.emitForTests("responsive-ui-target-switch-done");
    }
  }

  async setElementPickerState(state, pickerType) {
    this.commands.responsiveCommand.setElementPickerState(state, pickerType);
  }

  // This just needed to setup watching for network resources,
  // to support network throttling.
  onNetworkResourceAvailable() {}

  /**
   * Reload the current tab.
   */
  async reloadSelectedTab() {
    await this.commands.targetCommand.reloadTopLevelTarget();
  }
}

module.exports = ResponsiveUI;
