/* 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 { Ci } = require("chrome");
const Services = require("Services");
const { Task } = require("devtools/shared/task");
const { BrowserElementWebNavigation } = require("./web-navigation");
const { getStack } = require("devtools/shared/platform/stack");

// A symbol used to hold onto the frame loader from the outer browser while tunneling.
const FRAME_LOADER = Symbol("devtools/responsive/frame-loader");

function debug(msg) {
  // console.log(msg);
}

/**
 * Properties swapped between browsers by browser.xml's `swapDocShells`.  See also the
 * list at /devtools/client/responsive.html/docs/browser-swap.md.
 */
const SWAPPED_BROWSER_STATE = [
  "_remoteFinder",
  "_securityUI",
  "_documentURI",
  "_documentContentType",
  "_contentTitle",
  "_characterSet",
  "_contentPrincipal",
  "_imageDocument",
  "_fullZoom",
  "_textZoom",
  "_isSyntheticDocument",
  "_innerWindowID",
  "_manifestURI",
];

/**
 * This module takes an "outer" <xul:browser> from a browser tab as described by
 * Firefox's tabbrowser.xml and wires it up to an "inner" <iframe mozbrowser>
 * browser element containing arbitrary page content of interest.
 *
 * The inner <iframe mozbrowser> element is _just_ the page content.  It is not
 * enough to to replace <xul:browser> on its own.  <xul:browser> comes along
 * with lots of associated functionality via XBL bindings defined for such
 * elements in browser.xml and remote-browser.xml, and the Firefox UI depends on
 * these various things to make the UI function.
 *
 * By mapping various methods, properties, and messages from the outer browser
 * to the inner browser, we can control the content inside the inner browser
 * using the standard Firefox UI elements for navigation, reloading, and more.
 *
 * The approaches used in this module were chosen to avoid needing changes to
 * the core browser for this specialized use case.  If we start to increase
 * usage of <iframe mozbrowser> in the core browser, we should avoid this module
 * and instead refactor things to work with mozbrowser directly.
 *
 * For the moment though, this serves as a sufficient path to connect the
 * Firefox UI to a mozbrowser.
 *
 * @param outer
 *        A <xul:browser> from a regular browser tab.
 * @param inner
 *        A <iframe mozbrowser> containing page content to be wired up to the
 *        primary browser UI via the outer browser.
 */
function tunnelToInnerBrowser(outer, inner) {
  let browserWindow = outer.ownerDocument.defaultView;
  let gBrowser = browserWindow.gBrowser;
  let mmTunnel;

  return {

    start: Task.async(function* () {
      if (outer.isRemoteBrowser) {
        throw new Error("The outer browser must be non-remote.");
      }
      if (!inner.isRemoteBrowser) {
        throw new Error("The inner browser must be remote.");
      }

      // Various browser methods access the `frameLoader` property, including:
      //   * `saveBrowser` from contentAreaUtils.js
      //   * `docShellIsActive` from remote-browser.xml
      //   * `hasContentOpener` from remote-browser.xml
      //   * `preserveLayers` from remote-browser.xml
      //   * `receiveMessage` from SessionStore.jsm
      // In general, these methods are interested in the `frameLoader` for the content,
      // so we redirect them to the inner browser's `frameLoader`.
      outer[FRAME_LOADER] = outer.frameLoader;
      Object.defineProperty(outer, "frameLoader", {
        get() {
          let stack = getStack();
          // One exception is `receiveMessage` from SessionStore.jsm.  SessionStore
          // expects data updates to come in as messages targeted to a <xul:browser>.
          // In addition, it verifies[1] correctness by checking that the received
          // message's `targetFrameLoader` property matches the `frameLoader` of the
          // <xul:browser>. To keep SessionStore functioning as expected, we give it the
          // outer `frameLoader` as if nothing has changed.
          // [1]: https://dxr.mozilla.org/mozilla-central/rev/b1b18f25c0ea69d9ee57c4198d577dfcd0129ce1/browser/components/sessionstore/SessionStore.jsm#716
          if (stack.caller.filename.endsWith("SessionStore.jsm")) {
            return outer[FRAME_LOADER];
          }
          return inner.frameLoader;
        },
        configurable: true,
        enumerable: true,
      });

      // The `outerWindowID` of the content is used by browser actions like view source
      // and print.  They send the ID down to the client to find the right content frame
      // to act on.
      Object.defineProperty(outer, "outerWindowID", {
        get() {
          return inner.outerWindowID;
        },
        configurable: true,
        enumerable: true,
      });

      // The `permanentKey` property on a <xul:browser> is used to index into various maps
      // held by the session store.  When you swap content around with
      // `_swapBrowserDocShells`, these keys are also swapped so they follow the content.
      // This means the key that matches the content is on the inner browser.  Since we
      // want the browser UI to believe the page content is part of the outer browser, we
      // copy the content's `permanentKey` up to the outer browser.
      debug("Copy inner permanentKey to outer browser");
      outer.permanentKey = inner.permanentKey;

      // Replace the outer browser's native messageManager with a message manager tunnel
      // which we can use to route messages of interest to the inner browser instead.
      // Note: The _actual_ messageManager accessible from
      // `browser.frameLoader.messageManager` is not overridable and is left unchanged.
      // Only the XBL getter `browser.messageManager` is overridden.  Browser UI code
      // always uses this getter instead of `browser.frameLoader.messageManager` directly,
      // so this has the effect of overriding the message manager for browser UI code.
      mmTunnel = new MessageManagerTunnel(outer, inner);

      // We are tunneling to an inner browser with a specific remoteness, so it is simpler
      // for the logic of the browser UI to assume this tab has taken on that remoteness,
      // even though it's not true.  Since the actions the browser UI performs are sent
      // down to the inner browser by this tunnel, the tab's remoteness effectively is the
      // remoteness of the inner browser.
      outer.setAttribute("remote", "true");

      // Clear out any cached state that references the current non-remote XBL binding,
      // such as form fill controllers.  Otherwise they will remain in place and leak the
      // outer docshell.
      outer.destroy();
      // The XBL binding for remote browsers uses the message manager for many actions in
      // the UI and that works well here, since it gives us one main thing we need to
      // route to the inner browser (the messages), instead of having to tweak many
      // different browser properties.  It is safe to alter a XBL binding dynamically.
      // The content within is not reloaded.
      outer.style.MozBinding = "url(chrome://browser/content/tabbrowser.xml" +
                               "#tabbrowser-remote-browser)";

      // The constructor of the new XBL binding is run asynchronously and there is no
      // event to signal its completion.  Spin an event loop to watch for properties that
      // are set by the contructor.
      while (!outer._remoteWebNavigation) {
        Services.tm.currentThread.processNextEvent(true);
      }

      // Replace the `webNavigation` object with our own version which tries to use
      // mozbrowser APIs where possible.  This replaces the webNavigation object that the
      // remote-browser.xml binding creates.  We do not care about it's original value
      // because stop() will remove the remote-browser.xml binding and these will no
      // longer be used.
      let webNavigation = new BrowserElementWebNavigation(inner);
      webNavigation.copyStateFrom(inner._remoteWebNavigationImpl);
      outer._remoteWebNavigation = webNavigation;
      outer._remoteWebNavigationImpl = webNavigation;

      // Now that we've flipped to the remote browser XBL binding, add `progressListener`
      // onto the remote version of `webProgress`.  Normally tabbrowser.xml does this step
      // when it creates a new browser, etc.  Since we manually changed the XBL binding
      // above, it caused a fresh webProgress object to be created which does not have any
      // listeners added.  So, we get the listener that gBrowser is using for the tab and
      // reattach it here.
      let tab = gBrowser.getTabForBrowser(outer);
      let filteredProgressListener = gBrowser._tabFilters.get(tab);
      outer.webProgress.addProgressListener(filteredProgressListener);

      // Add the inner browser to tabbrowser's WeakMap from browser to tab.  This assists
      // with tabbrowser's processing of some events such as MozLayerTreeReady which
      // bubble up from the remote content frame and trigger tabbrowser to lookup the tab
      // associated with the browser that triggered the event.
      gBrowser._tabForBrowser.set(inner, tab);

      // All of the browser state from content was swapped onto the inner browser.  Pull
      // this state up to the outer browser.
      for (let property of SWAPPED_BROWSER_STATE) {
        outer[property] = inner[property];
      }

      // Expose `PopupNotifications` on the content's owner global.
      // This is used by PermissionUI.jsm for permission doorhangers.
      // Note: This pollutes the responsive.html tool UI's global.
      Object.defineProperty(inner.ownerGlobal, "PopupNotifications", {
        get() {
          return outer.ownerGlobal.PopupNotifications;
        },
        configurable: true,
        enumerable: true,
      });

      // Expose `whereToOpenLink` on the content's owner global.
      // This is used by ContentClick.jsm when opening links in ways other than just
      // navigating the viewport.
      // Note: This pollutes the responsive.html tool UI's global.
      Object.defineProperty(inner.ownerGlobal, "whereToOpenLink", {
        get() {
          return outer.ownerGlobal.whereToOpenLink;
        },
        configurable: true,
        enumerable: true,
      });

      // Add mozbrowser event handlers
      inner.addEventListener("mozbrowseropenwindow", this);
    }),

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

      // Minimal support for <a target/> and window.open() which just ensures we at
      // least open them somewhere (in a new tab).  The following things are ignored:
      //   * Specific target names (everything treated as _blank)
      //   * Window features
      //   * window.opener
      // These things are deferred for now, since content which does depend on them seems
      // outside the main focus of RDM.
      let { detail } = event;
      event.preventDefault();
      let uri = Services.io.newURI(detail.url, null, null);
      // This API is used mainly because it's near the path used for <a target/> with
      // regular browser tabs (which calls `openURIInFrame`).  The more elaborate APIs
      // that support openers, window features, etc. didn't seem callable from JS and / or
      // this event doesn't give enough info to use them.
      browserWindow.browserDOMWindow
        .openURI(uri, null, Ci.nsIBrowserDOMWindow.OPEN_NEWTAB,
                 Ci.nsIBrowserDOMWindow.OPEN_NEW);
    },

    stop() {
      let tab = gBrowser.getTabForBrowser(outer);
      let filteredProgressListener = gBrowser._tabFilters.get(tab);

      // The browser's state has changed over time while the tunnel was active.  Push the
      // the current state down to the inner browser, so that it follows the content in
      // case that browser will be swapped elsewhere.
      for (let property of SWAPPED_BROWSER_STATE) {
        inner[property] = outer[property];
      }

      // Remove the inner browser from the WeakMap from browser to tab.
      gBrowser._tabForBrowser.delete(inner);

      // Remove the progress listener we added manually.
      outer.webProgress.removeProgressListener(filteredProgressListener);

      // Reset the XBL binding back to the default.
      outer.destroy();
      outer.style.MozBinding = "";

      // Reset @remote since this is now back to a regular, non-remote browser
      outer.setAttribute("remote", "false");

      // Delete browser window properties exposed on content's owner global
      delete inner.ownerGlobal.PopupNotifications;
      delete inner.ownerGlobal.whereToOpenLink;

      // Remove mozbrowser event handlers
      inner.removeEventListener("mozbrowseropenwindow", this);

      mmTunnel.destroy();
      mmTunnel = null;

      // Reset overridden XBL properties and methods.  Deleting the override
      // means it will fallback to the original XBL binding definitions which
      // are on the prototype.
      delete outer.frameLoader;
      delete outer[FRAME_LOADER];
      delete outer.outerWindowID;

      // Invalidate outer's permanentKey so that SessionStore stops associating
      // things that happen to the outer browser with the content inside in the
      // inner browser.
      outer.permanentKey = { id: "zombie" };

      browserWindow = null;
      gBrowser = null;
    },

  };
}

exports.tunnelToInnerBrowser = tunnelToInnerBrowser;

/**
 * This module allows specific messages of interest to be directed from the
 * outer browser to the inner browser (and vice versa) in a targetted fashion
 * without having to touch the original code paths that use them.
 */
function MessageManagerTunnel(outer, inner) {
  if (outer.isRemoteBrowser) {
    throw new Error("The outer browser must be non-remote.");
  }
  this.outer = outer;
  this.inner = inner;
  this.tunneledMessageNames = new Set();
  this.init();
}

MessageManagerTunnel.prototype = {

  /**
   * Most message manager methods are left alone and are just passed along to
   * the outer browser's real message manager.
   */
  PASS_THROUGH_METHODS: [
    "killChild",
    "assertPermission",
    "assertContainApp",
    "assertAppHasPermission",
    "assertAppHasStatus",
    "removeDelayedFrameScript",
    "getDelayedFrameScripts",
    "loadProcessScript",
    "removeDelayedProcessScript",
    "getDelayedProcessScripts",
    "addWeakMessageListener",
    "removeWeakMessageListener",
  ],

  /**
   * The following methods are overridden with special behavior while tunneling.
   */
  OVERRIDDEN_METHODS: [
    "loadFrameScript",
    "addMessageListener",
    "removeMessageListener",
    "sendAsyncMessage",
  ],

  OUTER_TO_INNER_MESSAGES: [
    // Messages sent from remote-browser.xml
    "Browser:PurgeSessionHistory",
    "InPermitUnload",
    "PermitUnload",
    // Messages sent from browser.js
    "Browser:Reload",
    // Messages sent from SelectParentHelper.jsm
    "Forms:DismissedDropDown",
    "Forms:MouseOut",
    "Forms:MouseOver",
    "Forms:SelectDropDownItem",
    // Messages sent from SessionStore.jsm
    "SessionStore:flush",
  ],

  INNER_TO_OUTER_MESSAGES: [
    // Messages sent to RemoteWebProgress.jsm
    "Content:LoadURIResult",
    "Content:LocationChange",
    "Content:ProgressChange",
    "Content:SecurityChange",
    "Content:StateChange",
    "Content:StatusChange",
    // Messages sent to remote-browser.xml
    "DOMTitleChanged",
    "ImageDocumentLoaded",
    "Forms:ShowDropDown",
    "Forms:HideDropDown",
    "InPermitUnload",
    "PermitUnload",
    // Messages sent to tabbrowser.xml
    "contextmenu",
    // Messages sent to SelectParentHelper.jsm
    "Forms:UpdateDropDown",
    // Messages sent to browser.js
    "PageVisibility:Hide",
    "PageVisibility:Show",
    // Messages sent to SessionStore.jsm
    "SessionStore:update",
    // Messages sent to BrowserTestUtils.jsm
    "browser-test-utils:loadEvent",
  ],

  OUTER_TO_INNER_MESSAGE_PREFIXES: [
    // Messages sent from nsContextMenu.js
    "ContextMenu:",
    // Messages sent from DevTools
    "debug:",
    // Messages sent from findbar.xml
    "Findbar:",
    // Messages sent from RemoteFinder.jsm
    "Finder:",
    // Messages sent from InlineSpellChecker.jsm
    "InlineSpellChecker:",
    // Messages sent from pageinfo.js
    "PageInfo:",
    // Messages sent from printUtils.js
    "Printing:",
    // Messages sent from browser-social.js
    "Social:",
    "PageMetadata:",
    // Messages sent from viewSourceUtils.js
    "ViewSource:",
  ],

  INNER_TO_OUTER_MESSAGE_PREFIXES: [
    // Messages sent to nsContextMenu.js
    "ContextMenu:",
    // Messages sent to DevTools
    "debug:",
    // Messages sent to findbar.xml
    "Findbar:",
    // Messages sent to RemoteFinder.jsm
    "Finder:",
    // Messages sent to pageinfo.js
    "PageInfo:",
    // Messages sent to printUtils.js
    "Printing:",
    // Messages sent to browser-social.js
    "Social:",
    "PageMetadata:",
    // Messages sent to viewSourceUtils.js
    "ViewSource:",
  ],

  OUTER_TO_INNER_FRAME_SCRIPTS: [
    // DevTools server for OOP frames
    "resource://devtools/server/child.js"
  ],

  get outerParentMM() {
    if (!this.outer[FRAME_LOADER]) {
      return null;
    }
    return this.outer[FRAME_LOADER].messageManager;
  },

  get outerChildMM() {
    // This is only possible because we require the outer browser to be
    // non-remote, so we're able to reach into its window and use the child
    // side message manager there.
    let docShell = this.outer[FRAME_LOADER].docShell;
    return docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                   .getInterface(Ci.nsIContentFrameMessageManager);
  },

  get innerParentMM() {
    if (!this.inner.frameLoader) {
      return null;
    }
    return this.inner.frameLoader.messageManager;
  },

  init() {
    for (let method of this.PASS_THROUGH_METHODS) {
      // Workaround bug 449811 to ensure a fresh binding each time through the loop
      let _method = method;
      this[_method] = (...args) => {
        if (!this.outerParentMM) {
          return null;
        }
        return this.outerParentMM[_method](...args);
      };
    }

    for (let name of this.INNER_TO_OUTER_MESSAGES) {
      this.innerParentMM.addMessageListener(name, this);
      this.tunneledMessageNames.add(name);
    }

    Services.obs.addObserver(this, "message-manager-close", false);

    // Replace the outer browser's messageManager with this tunnel
    Object.defineProperty(this.outer, "messageManager", {
      value: this,
      writable: false,
      configurable: true,
      enumerable: true,
    });
  },

  destroy() {
    if (this.destroyed) {
      return;
    }
    this.destroyed = true;
    debug("Destroy tunnel");

    // Watch for the messageManager to close.  In most cases, the caller will stop the
    // tunnel gracefully before this, but when the browser window closes or application
    // exits, we may not see the high-level close events.
    Services.obs.removeObserver(this, "message-manager-close");

    // Reset the messageManager.  Deleting the override means it will fallback to the
    // original XBL binding definitions which are on the prototype.
    delete this.outer.messageManager;

    for (let name of this.tunneledMessageNames) {
      this.innerParentMM.removeMessageListener(name, this);
    }

    // Some objects may have cached this tunnel as the messageManager for a frame.  To
    // ensure it keeps working after tunnel close, rewrite the overidden methods as pass
    // through methods.
    for (let method of this.OVERRIDDEN_METHODS) {
      // Workaround bug 449811 to ensure a fresh binding each time through the loop
      let _method = method;
      this[_method] = (...args) => {
        if (!this.outerParentMM) {
          return null;
        }
        return this.outerParentMM[_method](...args);
      };
    }
  },

  observe(subject, topic, data) {
    if (topic != "message-manager-close") {
      return;
    }
    if (subject == this.innerParentMM) {
      debug("Inner messageManager has closed");
      this.destroy();
    }
    if (subject == this.outerParentMM) {
      debug("Outer messageManager has closed");
      this.destroy();
    }
  },

  loadFrameScript(url, ...args) {
    debug(`Calling loadFrameScript for ${url}`);

    if (!this.OUTER_TO_INNER_FRAME_SCRIPTS.includes(url)) {
      debug(`Should load ${url} into inner?`);
      this.outerParentMM.loadFrameScript(url, ...args);
      return;
    }

    debug(`Load ${url} into inner`);
    this.innerParentMM.loadFrameScript(url, ...args);
  },

  addMessageListener(name, ...args) {
    debug(`Calling addMessageListener for ${name}`);

    debug(`Add outer listener for ${name}`);
    // Add an outer listener, just like a simple pass through
    this.outerParentMM.addMessageListener(name, ...args);

    // If the message name is part of a prefix we're tunneling, we also need to add the
    // tunnel as an inner listener.
    if (this.INNER_TO_OUTER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix))) {
      debug(`Add inner listener for ${name}`);
      this.innerParentMM.addMessageListener(name, this);
      this.tunneledMessageNames.add(name);
    }
  },

  removeMessageListener(name, ...args) {
    debug(`Calling removeMessageListener for ${name}`);

    debug(`Remove outer listener for ${name}`);
    // Remove an outer listener, just like a simple pass through
    this.outerParentMM.removeMessageListener(name, ...args);

    // Leave the tunnel as an inner listener for the case of prefix messages to avoid
    // tracking counts of add calls.  The inner listener will get removed on destroy.
  },

  sendAsyncMessage(name, ...args) {
    debug(`Calling sendAsyncMessage for ${name}`);

    if (!this._shouldTunnelOuterToInner(name)) {
      debug(`Should ${name} go to inner?`);
      this.outerParentMM.sendAsyncMessage(name, ...args);
      return;
    }

    debug(`${name} outer -> inner`);
    this.innerParentMM.sendAsyncMessage(name, ...args);
  },

  receiveMessage({ name, data, objects, principal, sync }) {
    if (!this._shouldTunnelInnerToOuter(name)) {
      debug(`Received unexpected message ${name}`);
      return undefined;
    }

    debug(`${name} inner -> outer, sync: ${sync}`);
    if (sync) {
      return this.outerChildMM.sendSyncMessage(name, data, objects, principal);
    }
    this.outerChildMM.sendAsyncMessage(name, data, objects, principal);
    return undefined;
  },

  _shouldTunnelOuterToInner(name) {
    return this.OUTER_TO_INNER_MESSAGES.includes(name) ||
           this.OUTER_TO_INNER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix));
  },

  _shouldTunnelInnerToOuter(name) {
    return this.INNER_TO_OUTER_MESSAGES.includes(name) ||
           this.INNER_TO_OUTER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix));
  },

};
