/* 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 {
  WILL_NAVIGATE_TIME_SHIFT,
} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js");

class ParentProcessDocumentEventWatcher {
  /**
   * Start watching, from the parent process, for DOCUMENT_EVENT's "will-navigate" event related to a given Watcher Actor.
   *
   * All other DOCUMENT_EVENT events are implemented from another watcher class, running in the content process.
   * Note that this other content process watcher will also emit one special edgecase of will-navigate
   * retlated to the iframe dropdown menu.
   *
   * We have to move listen for navigation in the parent to better handle bfcache navigations
   * and more generally all navigations which are initiated from the parent process.
   * 'bfcacheInParent' feature enabled many types of navigations to be controlled from the parent process.
   *
   * This was especially important to have this implementation in the parent
   * because the navigation event may be fired too late in the content process.
   * Leading to will-navigate being emitted *after* the new target we navigate to is notified to the client.
   *
   * @param WatcherActor watcherActor
   *        The watcher actor from which we should observe document event
   * @param Object options
   *        Dictionary object with following attributes:
   *        - onAvailable: mandatory function
   *          This will be called for each resource.
   */
  async watch(watcherActor, { onAvailable }) {
    this.watcherActor = watcherActor;
    this.onAvailable = onAvailable;

    // List of listeners keyed by innerWindowId.
    // Listeners are called as soon as we emitted the will-navigate
    // resource for the related WindowGlobal.
    this._onceWillNavigate = new Map();

    // Filter browsing contexts to only have the top BrowsingContext of each tree of BrowsingContexts…
    const topLevelBrowsingContexts = this.watcherActor
      .getAllBrowsingContexts()
      .filter(browsingContext => browsingContext.top == browsingContext);

    // Only register one WebProgressListener per BrowsingContext tree.
    // We will be notified about children BrowsingContext navigations/state changes via the top level BrowsingContextWebProgressListener,
    // and BrowsingContextWebProgress.browsingContext attribute will be updated dynamically everytime
    // we get notified about a child BrowsingContext.
    // Note that regular web page toolbox will only have one BrowsingContext tree, for the given tab.
    // But the Browser Toolbox will have many trees to listen to, one per top-level Window, and also one per tab,
    // as tabs's BrowsingContext context aren't children of their top level window!
    //
    // Also save the WebProgress and not the BrowsingContext because `BrowsingContext.webProgress` will be undefined in destroy(),
    // while it is still valuable to call `webProgress.removeProgressListener`. Otherwise events keeps being notified!!
    this.webProgresses = topLevelBrowsingContexts.map(
      browsingContext => browsingContext.webProgress
    );
    this.webProgresses.forEach(webProgress => {
      webProgress.addProgressListener(
        this,
        Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
      );
    });
  }

  /**
   * Wait for the emission of will-navigate for a given WindowGlobal
   *
   * @param Number innerWindowId
   *        WindowGlobal's id we want to track
   * @return Promise
   *         Resolves immediatly if the WindowGlobal isn't tracked by any target
   *         -or- resolve later, once the WindowGlobal navigates to another document
   *         and will-navigate has been emitted.
   */
  onceWillNavigateIsEmitted(innerWindowId) {
    // Only delay the target-destroyed event if the target is for BrowsingContext for which we will emit will-navigate
    const isTracked = this.webProgresses.find(
      webProgress =>
        webProgress.browsingContext.currentWindowGlobal?.innerWindowId ==
        innerWindowId
    );
    if (isTracked) {
      return new Promise(resolve => {
        this._onceWillNavigate.set(innerWindowId, resolve);
      });
    }
    return Promise.resolve();
  }

  onStateChange(progress, request, flag) {
    const isStart = flag & Ci.nsIWebProgressListener.STATE_START;
    const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
    if (isDocument && isStart) {
      const { browsingContext } = progress;
      // Ignore if we are still on the initial document,
      // as that's the navigation from it (about:blank) to the actual first location.
      // The target isn't created yet.
      if (browsingContext.currentWindowGlobal.isUncommittedInitialDocument) {
        return;
      }

      // Never emit will-navigate for content browsing contexts in the Browser Toolbox.
      // They might verify `browsingContext.top == browsingContext` because of the chrome/content
      // boundary, but they do not represent a top-level target for this DevTools session.
      if (
        this.watcherActor.sessionContext.type == "all" &&
        browsingContext.isContent
      ) {
        return;
      }
      // Also ignore will-navigate for Web Extensions as we shouldn't clear things up when
      // any of its WindowGlobal starts navigating away. Instead things should be cleared when
      // we destroy the targets.
      if (this.watcherActor.sessionContext.type == "webextension") {
        return;
      }

      // Only emit will-navigate for top-level targets.
      const isTopLevel = browsingContext.top == browsingContext;
      if (!isTopLevel) {
        return;
      }

      const newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null;
      const { innerWindowId } = browsingContext.currentWindowGlobal;
      this.onAvailable([
        {
          browsingContextID: browsingContext.id,
          innerWindowId,
          name: "will-navigate",
          time: Date.now() - WILL_NAVIGATE_TIME_SHIFT,
          isFrameSwitching: false,
          newURI,
        },
      ]);
      const callback = this._onceWillNavigate.get(innerWindowId);
      if (callback) {
        this._onceWillNavigate.delete(innerWindowId);
        callback();
      }

      // Also emit the event on the watcher actor for other actors to catch this event
      if (browsingContext == this.watcherActor.browsingContext) {
        this.watcherActor.emit("top-browsing-context-will-navigate");
      }
    }
  }

  get QueryInterface() {
    return ChromeUtils.generateQI([
      "nsIWebProgressListener",
      "nsISupportsWeakReference",
    ]);
  }

  destroy() {
    this.webProgresses.forEach(webProgress => {
      webProgress.removeProgressListener(
        this,
        Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
      );
    });
    this.webProgresses = null;
  }
}

module.exports = ParentProcessDocumentEventWatcher;
