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

/**
 * The FormHandler actor pair implements the logic of detecting
 * form submissions and notifies of a form submission by
 * dispatching the event "form-submission-detected"
 */

export const FORM_SUBMISSION_REASON = {
  FORM_SUBMIT_EVENT: "form-submit-event",
  FORM_REMOVAL_AFTER_FETCH: "form-removal-after-fetch",
  IFRAME_PAGEHIDE: "iframe-pagehide",
  PAGE_NAVIGATION: "page-navigation",
  PASSWORD_REMOVAL_AFTER_FETCH: "password-removal-after-fetch",
};

export class FormHandlerChild extends JSWindowActorChild {
  actorCreated() {
    // Whenever a FormHandlerChild is created it's because somebody has registered
    // their interest in form submissions. This step might create FormHandler actors
    // across multiple window contexts. Whenever a FormHandlerChild is created in a
    // process root, we want to make sure that it registers the progress listener
    // in order to listen for form submissions in that process.
    if (this.manager.isProcessRoot) {
      this.registerProgressListener();
    }
  }
  /**
   * Tracks whether an interest in form submissions was registered in this window
   */
  #hasRegisteredFormSubmissionInterest = false;

  /**
   * Tracks the actors that are interested in form or password field removals from DOM
   * If this set is empty, FormHandlerChild can unregister the form removal event listeners
   */
  #actorsListeningForFormRemoval = new Set();

  handleEvent(event) {
    if (!event.isTrusted) {
      return;
    }

    if (!this.#hasRegisteredFormSubmissionInterest) {
      return;
    }

    switch (event.type) {
      case "DOMDocFetchSuccess":
        this.processDOMDocFetchSuccessEvent();
        break;
      case "DOMFormBeforeSubmit":
        this.processDOMFormBeforeSubmitEvent(event);
        break;
      case "DOMFormRemoved":
        this.processDOMFormRemovedEvent(event);
        break;
      case "DOMInputPasswordRemoved": {
        this.processDOMInputPasswordRemovedEvent(event);
        break;
      }
      default:
        throw new Error("Unexpected event type");
    }
  }

  receiveMessage(message) {
    switch (message.name) {
      case "FormHandler:FormSubmissionByNavigation": {
        this.processPageNavigation();
        break;
      }
      case "FormHandler:EnsureChildExists": {
        // This is just a dummy message to make sure that the
        // FormHandlerChild is created because then the actor
        // starts listening to page navigations
        break;
      }
    }
  }

  /**
   * Process the DOMFormBeforeSubmit event that is dispatched
   * after a form submit event.
   *
   * @param {Event} event DOMFormBeforeSubmit
   */
  processDOMFormBeforeSubmitEvent(event) {
    const form = event.target;
    const formSubmissionReason = FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT;

    this.#dispatchFormSubmissionEvent(form, formSubmissionReason);
  }

  /**
   * Process the DOMDocFetchSuccess event that is dispatched
   * after a successfull xhr/fetch request and start listening for
   * the events DOMFormRemoved and DOMInputPasswordRemoved
   */
  processDOMDocFetchSuccessEvent() {
    this.document.setNotifyFormOrPasswordRemoved(true);
    this.docShell.chromeEventHandler.addEventListener(
      "DOMFormRemoved",
      this,
      true
    );
    this.docShell.chromeEventHandler.addEventListener(
      "DOMInputPasswordRemoved",
      this,
      true
    );

    this.document.setNotifyFetchSuccess(false);
    this.docShell.chromeEventHandler.removeEventListener(
      "DOMDocFetchSuccess",
      this
    );

    this.#dispatchPrepareFormSubmissionEvent();
  }

  /**
   * Process the DOMFormRemoved event that is dispatched
   * after a form was removed from the DOM.
   *
   * @param {Event} event DOMFormRemoved
   */
  processDOMFormRemovedEvent(event) {
    const form = event.target;
    const formSubmissionReason =
      FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH;

    this.#dispatchFormSubmissionEvent(form, formSubmissionReason);
  }

  /**
   * Process the DOMInputPasswordRemoved event that is dispatched
   * after a password input was removed from the DOM.
   *
   * @param {Event} event DOMInputPasswordRemoved
   */
  processDOMInputPasswordRemovedEvent(event) {
    const form = event.target;
    const formSubmissionReason =
      FORM_SUBMISSION_REASON.PASSWORD_REMOVAL_AFTER_FETCH;

    this.#dispatchFormSubmissionEvent(form, formSubmissionReason);
  }

  /**
   * This or the page of a parent browsing context was navigated,
   * so process the page navigation, only when somebody in the current has
   * registered interest for it
   */
  processPageNavigation() {
    if (!this.#hasRegisteredFormSubmissionInterest) {
      // Nobody is interested in the current window
      // so don't bother notifying anyone
      return;
    }
    const formSubmissionReason = FORM_SUBMISSION_REASON.PAGE_NAVIGATION;
    this.#dispatchFormSubmissionEvent(null, formSubmissionReason);
  }

  /**
   * Dispatch the CustomEvent form-submission-detected and transfer
   * the following information:
   *            detail.form   - the form that is being submitted
   *            detail.reason - the heuristic that detected the form submission
   *                            (see FORM_SUBMISSION_REASON)
   *
   * @param {HTMLFormElement} form
   * @param {string} reason
   */
  #dispatchFormSubmissionEvent(form, reason) {
    const formSubmissionEvent = new CustomEvent("form-submission-detected", {
      detail: { form, reason },
      bubbles: true,
    });
    this.document.dispatchEvent(formSubmissionEvent);
  }

  /**
   * Dispatch the before-form-submission event after receiving
   * a DOMDocFetchSuccess event. This gives the listening actors a chance to
   * save observed fields before they are removed from the DOM.
   */
  #dispatchPrepareFormSubmissionEvent() {
    const parepareFormSubmissionEvent = new CustomEvent(
      "before-form-submission",
      {
        bubbles: true,
      }
    );
    this.document.dispatchEvent(parepareFormSubmissionEvent);
  }

  /**
   * A page navigation was observed in this window or in the subtree.
   * If somebody in this window is interested in form submissions, process it here.
   * Additionally, inform the parent of the navigation so that all FormHandler
   * children in the subtree of the navigated browsing context are notified as well.
   *
   * @param {BrowsingContext} navigatedBrowingContext
   */
  onNavigationObserved(navigatedBrowingContext) {
    if (
      this.#hasRegisteredFormSubmissionInterest &&
      this.browsingContext == navigatedBrowingContext
    ) {
      // This is the most probable case, that an interest in form submissions was registered
      // in the navigated browing context, so we call processPageNavigation directly
      // instead of letting the parent notify this actor again to process it.
      this.processPageNavigation();
    }
    this.sendAsyncMessage(
      "FormHandler:NotifyNavigatedSubtree",
      navigatedBrowingContext
    );
  }

  /**
   * Set up needed listeners in order to detect form submissions after an actor indicated their interest
   *
   * 1. Register listeners relevant to form / password input removal heuristic
   *    - Set up 'DOMDocFetchSuccess' event listener (by calling setNotifyFetchSuccess)
   *
   * 2. Set up listeners relevant to page navigation heuristic
   *    - Create the corresponding parent of the current child, because the existence
   *      of the FormHandlerParent is the condition for being notified of a page navigation.
   *      If the current process is not the process root, we create the FormHandlerChild in
   *      the process root. The progress listener is registered after creating the child.
   *      If the current process is in a cross-origin frame, we notify the parent
   *      to register the progress listener also with the top level's process root.
   *
   * @param {JSWindowActorChild} interestedActor
   * @param {boolean} includesFormRemoval
   */
  registerFormSubmissionInterest(
    interestedActor,
    { includesFormRemoval = true, includesPageNavigation = true } = {}
  ) {
    if (includesFormRemoval) {
      if (!this.#actorsListeningForFormRemoval.size) {
        // The list of actors interest in form removals is empty when this is the
        // first time an actor registered to be notified of form removals or when all actors
        // processed their forms previously and unregistered their interest again. In both
        // cases we need to set up the listener for the event 'DOMDocFetchSuccess' here.
        this.document.setNotifyFetchSuccess(true);
        this.docShell.chromeEventHandler.addEventListener(
          "DOMDocFetchSuccess",
          this,
          true
        );
      }
      this.#actorsListeningForFormRemoval.add(interestedActor);
    }

    if (this.#hasRegisteredFormSubmissionInterest) {
      // If an actor in this window has already registered their interest
      // in form submissions, then the page navigation listeners are already set up
      return;
    }

    if (includesPageNavigation) {
      // We use the existence of the FormHandlerParent on the parent side
      // to determine whether to notify the corresponding FormHandleChild
      // when a page is navigated. So we explicitly create the parent actor
      // by sending a dummy message here
      this.sendAsyncMessage("FormHandler:EnsureParentExists");

      if (!this.manager.isProcessRoot) {
        // The progress listener is registered after the
        // FormHandlerChild is created in the process root
        this.document.documentGlobal.windowRoot.window.windowGlobalChild.getActor(
          "FormHandler"
        );
      }

      if (!this.manager.sameOriginWithTop) {
        // If the top level is navigated, that also effects the current cross-origin frame.
        // So we notify the parent to set up the progress listeners at the top as well.
        this.sendAsyncMessage("FormHandler:RegisterProgressListenerAtTopLevel");
      }
      this.#hasRegisteredFormSubmissionInterest = true;
    }
  }

  /**
   * The actors that are interested in form submissions explicitly unregister their interest
   * in form removals here. This way we can keep track if there is any interested actor left
   * so that we don't remove the form removal event listeners too early, but we also don't
   * listen to the form removal events for too long unnecessarily.
   *
   * @param {JSWindowActorChild} interestedActor
   */
  unregisterFormRemovalInterest(interestedActor) {
    this.#actorsListeningForFormRemoval.delete(interestedActor);

    if (this.#actorsListeningForFormRemoval.size) {
      // Other actors are still interested in form removals
      return;
    }
    this.document.setNotifyFormOrPasswordRemoved(false);
    this.docShell.chromeEventHandler.removeEventListener(
      "DOMFormRemoved",
      this
    );
    this.docShell.chromeEventHandler.removeEventListener(
      "DOMInputPasswordRemoved",
      this
    );
  }

  /**
   * Set up a nsIWebProgressListener that notifies of certain request state
   * changes such as changes of the location and the history stack for this docShell
   * and for the children's same-orign docShells.
   *
   * Note: Registering the listener only in the process root (instead of for
   *       every window) is enough to receive notifications for the whole process,
   *       because the notifications bubble up
   */
  registerProgressListener() {
    const webProgress = this.docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIWebProgress);

    const flags =
      Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT |
      Ci.nsIWebProgress.NOTIFY_LOCATION;
    try {
      webProgress.addProgressListener(observer, flags);
    } catch (ex) {
      // Ignore NS_ERROR_FAILURE if the progress listener was already added
    }
  }
}

const observer = {
  QueryInterface: ChromeUtils.generateQI([
    "nsIWebProgressListener",
    "nsISupportsWeakReference",
  ]),

  /**
   * Handle history stack changes (history.replaceState(), history.pushState())
   * on the same document as page navigation
   */
  onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
    if (
      !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) ||
      !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE)
    ) {
      return;
    }
    const navigatedWindow = aWebProgress.DOMWindow;

    this.notifyProcessRootOfNavigation(navigatedWindow);
  },

  /*
   * Handle certain state changes of requests as page navigation
   * such as location changes (location.assign(), location.replace())
   * See further comments for more details
   */
  onStateChange(aWebProgress, aRequest, aStateFlags, _aStatus) {
    if (
      aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING &&
      aStateFlags & Ci.nsIWebProgressListener.STATE_STOP
    ) {
      // a document is restored from bfcache
      return;
    }

    if (!(aStateFlags & Ci.nsIWebProgressListener.STATE_START)) {
      return;
    }

    // We only care about when a page triggered a load, not the user. For example:
    // clicking refresh/back/forward, typing a URL and hitting enter, and loading a bookmark aren't
    // likely to be when a user wants to save formautofill data.
    let channel = aRequest.QueryInterface(Ci.nsIChannel);
    let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
    if (
      triggeringPrincipal.isNullPrincipal ||
      triggeringPrincipal.equals(
        Services.scriptSecurityManager.getSystemPrincipal()
      )
    ) {
      return;
    }

    // We don't handle history navigation, reloads (e.g. history.go(-1), history.back(), location.reload())
    // Note: History state changes (e.g. history.replaceState(), history.pushState()) are handled in onLocationChange
    if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) {
      return;
    }

    const navigatedWindow = aWebProgress.DOMWindow;
    this.notifyProcessRootOfNavigation(navigatedWindow);
  },

  /**
   * Notify the current process root parent of the page navigation
   * and pass on the navigated browsing context
   *
   * @param {Window} navigatedWindow
   */
  notifyProcessRootOfNavigation(navigatedWindow) {
    const processRootWindow = navigatedWindow.windowRoot.window;
    const formHandlerChild =
      processRootWindow.windowGlobalChild.getExistingActor("FormHandler");
    const navigatedBrowsingContext = navigatedWindow.browsingContext;

    formHandlerChild?.onNavigationObserved(navigatedBrowsingContext);
  },
};
