/* 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/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  truncate: "chrome://remote/content/shared/Format.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());

export class NavigationListenerChild extends JSWindowActorChild {
  #listener;
  #webProgress;

  constructor() {
    super();

    this.#listener = {
      onLocationChange: this.#onLocationChange,
      onStateChange: this.#onStateChange,
      QueryInterface: ChromeUtils.generateQI([
        "nsIWebProgressListener",
        "nsISupportsWeakReference",
      ]),
    };
    this.#webProgress = null;
  }

  actorCreated() {
    this.#webProgress = this.manager.browsingContext.docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIWebProgress);

    this.#webProgress.addProgressListener(
      this.#listener,
      Ci.nsIWebProgress.NOTIFY_LOCATION |
        Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
    );
  }

  didDestroy() {
    try {
      this.#webProgress.removeProgressListener(this.#listener);
    } catch (e) {
      // Ignore potential errors if the window global was already destroyed.
    }
  }

  // Note: we rely on events and messages to trigger the actor creation, but
  // all the logic is in the actorCreated callback. The handleEvent and
  // receiveMessage methods are only there as placeholders to avoid errors.

  /**
   * See note above
   */
  handleEvent() {}

  /**
   * See note above
   */
  receiveMessage() {}

  /**
   * A browsing context might be replaced before reaching the parent process,
   * instead we serialize enough information to retrieve the navigable in the
   * parent process.
   *
   * If the browsing context is top level, then the browserId can be used to
   * find the browser element and the new browsing context.
   * Otherwise (frames) the browsing context should not be replaced and the
   * browsing context id should be enough to find the browsing context.
   *
   * @param {BrowsingContext} browsingContext
   *     The browsing context for which we want to get details.
   * @returns {object}
   *     An object that returns the following properties:
   *       - browserId: browser id for this browsing context
   *       - browsingContextId: browsing context id
   *       - isTopBrowsingContext: flag that indicates if the browsing context is
   *         top level
   */
  #getBrowsingContextDetails(browsingContext) {
    return {
      browserId: browsingContext.browserId,
      browsingContextId: browsingContext.id,
      isTopBrowsingContext: browsingContext.parent === null,
    };
  }

  #getTargetURI(request) {
    try {
      return request.QueryInterface(Ci.nsIChannel).originalURI;
    } catch (e) {}

    return null;
  }

  #onLocationChange = (progress, request, location, stateFlags) => {
    if (stateFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
      const context = progress.browsingContext;

      const payload = {
        contextDetails: this.#getBrowsingContextDetails(context),
        url: location.spec,
      };

      if (location.hasRef) {
        // If the target URL contains a hash, handle the navigation as a
        // fragment navigation.
        this.#trace(
          context.id,
          lazy.truncate`Location=fragmentNavigated: ${location.spec}`
        );

        this.sendAsyncMessage(
          "NavigationListenerChild:fragmentNavigated",
          payload
        );
        return;
      }

      this.#trace(
        context.id,
        lazy.truncate`Location=sameDocumentChanged: ${location.spec}`
      );

      this.sendAsyncMessage(
        "NavigationListenerChild:sameDocumentChanged",
        payload
      );
    }
  };

  #onStateChange = (progress, request, stateFlags, status) => {
    const context = progress.browsingContext;
    const targetURI = this.#getTargetURI(request);

    const isBindingAborted = status == Cr.NS_BINDING_ABORTED;
    const isStart = !!(stateFlags & Ci.nsIWebProgressListener.STATE_START);
    const isStop = !!(stateFlags & Ci.nsIWebProgressListener.STATE_STOP);

    if (lazy.Log.isTraceLevelOrMore) {
      const isNetwork = !!(
        stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
      );
      this.#trace(
        context.id,
        `Loading state: flags: ${stateFlags}, status: ${status}, ` +
          ` isStart: ${isStart}, isStop: ${isStop}, isNetwork: ${isNetwork},` +
          ` isBindingAborted: ${isBindingAborted},` +
          lazy.truncate` targetURI: ${targetURI?.spec}`
      );
    }

    try {
      if (isStart) {
        this.sendAsyncMessage("NavigationListenerChild:navigationStarted", {
          contextDetails: this.#getBrowsingContextDetails(context),
          url: targetURI?.spec,
        });

        return;
      }

      if (isStop && !isBindingAborted) {
        // Skip NS_BINDING_ABORTED state changes as this can happen during a
        // browsing context + process change and we should get the real stop state
        // change from the correct process later.
        this.sendAsyncMessage("NavigationListenerChild:navigationStopped", {
          contextDetails: this.#getBrowsingContextDetails(context),
          status,
          url: targetURI?.spec,
        });
      }
    } catch (e) {
      if (e.name === "InvalidStateError") {
        // We'll arrive here if we no longer have our manager, so we can
        // just swallow this error.
        return;
      }
      throw e;
    }
  };

  #trace(contextId, message) {
    lazy.logger.trace(`[${contextId}] ${this.constructor.name} ${message}`);
  }
}
