/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const L10N = new LocalizationHelper(
  "devtools/client/locales/toolbox.properties"
);
const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");

// The min-width of toolbox and browser toolbox.
const WIDTH_CHEVRON_AND_MEATBALL = 50;
const WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE = 74;
const ZOOM_VALUE_PREF = "devtools.toolbox.zoomValue";

loader.lazyRequireGetter(
  this,
  "Toolbox",
  "resource://devtools/client/framework/toolbox.js",
  true
);
loader.lazyRequireGetter(
  this,
  "Hosts",
  "resource://devtools/client/framework/toolbox-hosts.js",
  true
);
loader.lazyRequireGetter(
  this,
  "LocalTabCommandsFactory",
  "resource://devtools/client/framework/local-tab-commands-factory.js",
  true
);

/**
 * Implement a wrapper on the chrome side to setup a Toolbox within Firefox UI.
 *
 * This component handles iframe creation within Firefox, in which we are loading
 * the toolbox document. Then both the chrome and the toolbox document communicate
 * via "message" events.
 *
 * Messages sent by the toolbox to the chrome:
 * - switch-host:
 *   Order to display the toolbox in another host (side, bottom, window, or the
 *   previously used one)
 * - raise-host:
 *   Focus the tools
 * - set-host-title:
 *   When using the window host, update the window title
 *
 * Messages sent by the chrome to the toolbox:
 * - switched-host:
 *   The `switch-host` command sent by the toolbox is done
 */

const LAST_HOST = "devtools.toolbox.host";
const PREVIOUS_HOST = "devtools.toolbox.previousHost";
let ID_COUNTER = 1;

class ToolboxHostManager {
  constructor(commands, hostType, hostOptions) {
    this.commands = commands;

    // When debugging a local tab, we keep a reference of the current tab into which the toolbox is displayed.
    // This will only change from the descriptor's localTab when we start debugging popups (i.e. window.open).
    this.currentTab = this.commands.descriptorFront.localTab;

    // Keep the previously instantiated Host for all tabs where we displayed the Toolbox.
    // This will only be useful when we start debugging popups (i.e. window.open).
    // This is used to re-use the previous host instance when we re-select the original tab
    // we were debugging before the popup opened.
    this.hostPerTab = new Map();

    this.frameId = ID_COUNTER++;

    if (!hostType) {
      hostType = Services.prefs.getCharPref(LAST_HOST);
      if (!Hosts[hostType]) {
        // If the preference value is unexpected, restore to the default value.
        Services.prefs.clearUserPref(LAST_HOST);
        hostType = Services.prefs.getCharPref(LAST_HOST);
      }
    }
    this.eventController = new AbortController();
    this.host = this.createHost(hostType, hostOptions);
    this.hostType = hostType;
    // List of event which are collected when a new host is created for a popup
    // from `switchHostToTab` method.
    this.collectPendingMessages = null;
    Services.prefs.addObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom);
  }
  /**
   * Create a Toolbox
   *
   * @param {string} toolId
   *        The id of the tool to show
   * @param {object} toolOptions
   *        Options that will be passed to the tool init function
   * @returns {Toolbox}
   */
  async create(toolId, toolOptions) {
    this.host.createElements();
    await this.host.finalizeCreation();
    if (this.currentTab) {
      this.hostPerTab.set(this.currentTab, this.host);
    }

    this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label"));
    this.host.frame.ownerDocument.defaultView.addEventListener(
      "message",
      this.#onMessage,
      { signal: this.eventController.signal }
    );
    if (this.currentTab) {
      this.currentTab.addEventListener("TabClose", this, {
        capture: true,
        signal: this.eventController.signal,
      });
    }

    const toolbox = new Toolbox({
      commands: this.commands,
      selectedTool: toolId,
      selectedToolOptions: toolOptions,
      hostType: this.host.type,
      contentWindow: this.host.frame.contentWindow,
      frameId: this.frameId,
    });
    toolbox.once("destroyed", this.#onToolboxDestroyed);

    // Prevent reloading the toolbox when loading the tools in a tab
    // (e.g. from about:debugging)
    const location = this.host.frame.contentWindow.location;
    if (!location.href.startsWith("about:devtools-toolbox")) {
      this.host.frame.setAttribute("src", "about:devtools-toolbox");
    }

    this.setMinWidthWithZoom();
    return toolbox;
  }

  setMinWidthWithZoom = () => {
    const zoomValue = parseFloat(Services.prefs.getCharPref(ZOOM_VALUE_PREF));

    if (isNaN(zoomValue)) {
      return;
    }

    if (
      this.hostType === Toolbox.HostType.LEFT ||
      this.hostType === Toolbox.HostType.RIGHT
    ) {
      this.host.frame.style.minWidth =
        WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE * zoomValue + "px";
    } else if (
      this.hostType === Toolbox.HostType.WINDOW ||
      this.hostType === Toolbox.HostType.PAGE ||
      this.hostType === Toolbox.HostType.BROWSERTOOLBOX
    ) {
      this.host.frame.style.minWidth =
        WIDTH_CHEVRON_AND_MEATBALL * zoomValue + "px";
    }
  };

  #onToolboxDestroyed = () => {
    // Delay self-destruction to let the debugger complete async destruction.
    // Otherwise it throws when running browser_dbg-breakpoints-in-evaled-sources.js
    // because the promise middleware delay each promise action using setTimeout...
    DevToolsUtils.executeSoon(() => {
      this.destroy();
    });
  };

  #onMessage = event => {
    if (!event.data) {
      return;
    }
    const msg = event.data;
    // Toolbox document is still chrome and disallow identifying message
    // origin via event.source as it is null. So use a custom id.
    if (msg.frameId != this.frameId) {
      return;
    }
    if (this.collectPendingMessages) {
      this.collectPendingMessages.push(event);
      return;
    }
    switch (msg.name) {
      case "switch-host":
        this.switchHost(msg.hostType);
        break;
      case "switch-host-to-tab":
        this.switchHostToTab(msg.tabBrowsingContextID);
        break;
      case "raise-host":
        this.host.raise();
        this.postMessage({
          name: "host-raised",
        });
        break;
      case "set-host-title":
        this.host.setTitle(msg.title);
        break;
    }
  };

  postMessage(data) {
    const window = this.host.frame.contentWindow;
    window.postMessage(data, "*");
  }

  destroy() {
    Services.prefs.removeObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom);
    this.eventController.abort();
    this.eventController = null;
    this.destroyHost();
    // When we are debugging popup, we created host for each popup opened
    // in some other tabs. Ensure destroying them here.
    for (const host of this.hostPerTab.values()) {
      host.destroy();
    }
    this.hostPerTab.clear();
    this.host = null;
    this.hostType = null;
    this.commands = null;
  }

  /**
   * Create a host object based on the given host type.
   *
   * Warning: bottom and sidebar hosts require that the toolbox target provides
   * a reference to the attached tab. Not all Targets have a tab property -
   * make sure you correctly mix and match hosts and targets.
   *
   * @param {string} hostType
   *        The host type of the new host object
   *
   * @return {Host} host
   *        The created host object
   */
  createHost(hostType, options) {
    if (!Hosts[hostType]) {
      throw new Error("Unknown hostType: " + hostType);
    }
    const newHost = new Hosts[hostType](this.currentTab, options);
    return newHost;
  }

  /**
   * Migrate the toolbox to a new host, while keeping it fully functional.
   * The toolbox's iframe will be moved as-is to the new host.
   *
   * @param {string} hostType
   *        The new type of host to spawn
   * @param {boolean} destroyPreviousHost
   *        Defaults to true. If false is passed, we will avoid destroying
   *        the previous host. This is helpful for popup debugging,
   *        where we migrate the toolbox between two tabs. In this scenario
   *        we are reusing previously instantiated hosts. This is especially
   *        useful when we close the current tab and have to have an
   *        already instantiated host to migrate to. If we don't have one,
   *        the toolbox iframe will already be destroyed before we have a chance
   *        to migrate it.
   */
  async switchHost(hostType, destroyPreviousHost = true) {
    if (hostType == "previous") {
      // Switch to the last used host for the toolbox UI.
      // This is determined by the devtools.toolbox.previousHost pref.
      hostType = Services.prefs.getCharPref(PREVIOUS_HOST);

      // Handle the case where the previous host happens to match the current
      // host. If so, switch to bottom if it's not already used, and right side if not.
      if (hostType === this.hostType) {
        if (hostType === Toolbox.HostType.BOTTOM) {
          hostType = Toolbox.HostType.RIGHT;
        } else {
          hostType = Toolbox.HostType.BOTTOM;
        }
      }
    }
    const iframe = this.host.frame;
    const newHost = this.createHost(hostType);

    // Create the host DOM Elements so that we can retrieve the new host's iframe
    newHost.createElements();

    let newIframe = newHost.frame;
    // For any in-browser hosts (left, bottom, right), we should have the iframe
    // available synchronously.
    //
    // This is the critical code spot where we actually move the devtools
    // from the former to the new iframe/host.
    //
    // Is is important when moving tab between two top level windows
    // to call swapFrameLoaders right away before the former iframe
    // gets destroyed (removed from DOM).
    if (newIframe) {
      newIframe.swapFrameLoaders(iframe);
    }

    // Do any asynchronous action required to create the new host DOM Elements
    await newHost.finalizeCreation();

    // For Window host, the iframe isn't available synchronously
    // as it requires opening the window asynchronously.
    //
    // It is fine calling swapFrameLoaders asynchronously
    // as we moving the debugged tab between two windows
    // doesn't require switching hosts between tabs.
    if (!newIframe) {
      newIframe = newHost.frame;
      newIframe.swapFrameLoaders(iframe);
    }

    // swapFrameLoaders ends up disabling the new frame activeness,
    // so ensure we set the expected state at the end of this method
    iframe.docShellIsActive = false;
    newIframe.docShellIsActive = true;

    if (destroyPreviousHost) {
      this.destroyHost({ newHostType: hostType });
    }

    if (
      this.hostType !== Toolbox.HostType.BROWSERTOOLBOX &&
      this.hostType !== Toolbox.HostType.PAGE
    ) {
      Services.prefs.setCharPref(PREVIOUS_HOST, this.hostType);
    }
    this.host = newHost;
    if (this.currentTab) {
      this.hostPerTab.set(this.currentTab, newHost);
    }
    this.hostType = hostType;
    this.host.setTitle(this.host.frame.contentWindow.document.title);
    this.host.frame.ownerDocument.defaultView.addEventListener(
      "message",
      this.#onMessage,
      { signal: this.eventController.signal }
    );
    if (this.currentTab) {
      this.currentTab.addEventListener("TabClose", this, {
        capture: true,
        signal: this.eventController.signal,
      });
    }

    this.setMinWidthWithZoom();

    if (
      hostType !== Toolbox.HostType.BROWSERTOOLBOX &&
      hostType !== Toolbox.HostType.PAGE
    ) {
      Services.prefs.setCharPref(LAST_HOST, hostType);
    }

    // Tell the toolbox the host changed
    this.postMessage({
      name: "switched-host",
      hostType,
    });
  }

  /**
   * When we are debugging popup, we are moving around the toolbox between original tab
   * and popup tabs. This method will only move the host to a new tab, while
   * keeping the same host type.
   *
   * @param {string} tabBrowsingContextID
   *        The ID of the browsing context of the tab we want to move to.
   */
  async switchHostToTab(tabBrowsingContextID) {
    const { gBrowser } = this.host.frame.ownerDocument.defaultView;

    const previousTab = this.currentTab;
    const newTab = gBrowser.tabs.find(
      tab => tab.linkedBrowser.browsingContext.id == tabBrowsingContextID
    );
    // Note that newTab will be undefined when the popup opens in a new top level window.
    if (newTab && newTab != previousTab) {
      this.currentTab = newTab;
      const newHost = this.hostPerTab.get(this.currentTab);
      if (newHost) {
        newHost.frame.swapFrameLoaders(this.host.frame);
        this.host = newHost;
      } else {
        // Ensure collecting any message sent by the toolbox in order to emit them
        // on the newly created host, which is created asynchronously
        const pendingMessages = [];
        this.collectPendingMessages = pendingMessages;
        await this.switchHost(this.hostType, false);
        this.collectPendingMessages = null;
        for (const message of pendingMessages) {
          this.#onMessage(message);
        }
      }
      previousTab.addEventListener(
        "TabSelect",
        event => {
          this.switchHostToTab(event.target.linkedBrowser.browsingContext.id);
        },
        { once: true, signal: this.eventController.signal }
      );
    }

    this.postMessage({
      name: "switched-host-to-tab",
      browsingContextID: tabBrowsingContextID,
    });
  }

  async handleEvent(event) {
    if (event.type != "TabClose") {
      return;
    }

    // Handle moving the debugged tab to another top-level window.
    // Reuse a logic similar to switchHostToTab.
    //
    // Listen to `TabClose` as it is emitted sooner than `SwapDocShells`.
    // By the time `SwapDocShells` is emitted, the former DevTools iframe
    // is destroyed and can't be "swaped" to another one in the destination
    // top level window.
    //
    // Some important care is required within `switchHost()` in order to
    // call `newIframe.swapFrameLoaders` synchronously, otherwise again,
    // the former DevTools iframe (this.host.frame) will be destroyed
    // and can't be swaped.
    const newTab = event.detail.adoptedBy;

    // If there is no new tab, this is a regular TabClose (without cross window move).
    if (!newTab) {
      return;
    }

    // Remove event listener before switching to the new tab.
    this.currentTab.removeEventListener("TabClose", this);

    // Update all places refering to the toolbox's tab:
    LocalTabCommandsFactory.swapTab(this.currentTab, newTab);
    this.commands.descriptorFront.setLocalTab(newTab);

    this.currentTab = newTab;

    // We only have to switch host to new tab for browser-integrated hosts
    if (
      this.hostType !== Toolbox.HostType.LEFT &&
      this.hostType !== Toolbox.HostType.RIGHT &&
      this.hostType !== Toolbox.HostType.BOTTOM
    ) {
      return;
    }

    // Ensure collecting any message sent by the toolbox in order to emit them
    // on the newly created host, which is created asynchronously
    const pendingMessages = [];
    this.collectPendingMessages = pendingMessages;
    await this.switchHost(this.hostType);
    this.collectPendingMessages = null;
    for (const message of pendingMessages) {
      this.#onMessage(message);
    }
  }

  /**
   * Destroy the current host, and remove event listeners from its frame.
   *
   * @param {object} options
   * @param {string} options.newHostType: The new host type when this is called from `switchHost`
   * @return {promise} to be resolved when the host is destroyed.
   */
  destroyHost(options = {}) {
    return this.host.destroy({ newHostType: options.newHostType });
  }
}

exports.ToolboxHostManager = ToolboxHostManager;
