the field and a reference to the
   * originally associated element from the form.
   *
   * The "section", "addressType", "contactType", and "fieldName" values are
   * used to identify the exact field when the serializable data is received
   * from the backend.  There cannot be multiple fields which have
   * the same exact combination of these values.
   *
   * A direct reference to the associated element cannot be sent to the user
   * interface because processing may be done in the parent process.
   */
  #fieldDetails = null;

  /**
   * Flags if the MutationObserver (this.#formMutationObserver) that is observing
   * node additions/removals for the root element has been set up
   */
  #isObservingFormMutations = false;

  #formMutationObserver = null;

  #visibilityObserver = null;
  #visibilityStateObserverByElement = new WeakMap();

  /**
   *
   * fillOnFormChangeData.isWithinDynamicFormChangeThreshold:
   *              Flags if a "form-change" event is received within the timeout threshold
   *              (see FormAutofill.fillOnDynamicFormChangeTimeout), that we set
   *              in order to consider newly detected fields for filling.
   * fillOnFormChangeData.previouslyUsedProfile
   *              The previously used profile from the latest autocompletion.
   * fillOnFormChangeData.previouslyFocusedId
   *              The previously focused element id from the latest autocompletion
   *
   * This is used for any following form changes and is cleared after a time threshold
   * set by FormAutofill.fillOnDynamicFormChangeTimeout.
   */
  #fillOnFormChangeData = new Map();

  /**
   * Caching the refill timeout id to cancel it once we know that we're about to fill
   * on form change, because this sets up another refill timeout.
   */
  #refillTimeoutId = null;

  /**
   * Flag to indicate whethere there is an ongoing autofilling/clearing process.
   */
  #isAutofillInProgress = false;

  /**
   * Initialize the form from `FormLike` object to handle the section or form
   * operations.
   *
   * @param {FormLike} form Form that need to be auto filled
   * @param {Function} onFilledModifiedCallback Function that can be invoked
   *                   when we want to suggest autofill on a form.
   */
  constructor(form, onFilledModifiedCallback = () => {}) {
    this._updateForm(form);

    this.window = this.form.rootElement.ownerGlobal;

    this.onFilledModifiedCallback = onFilledModifiedCallback;

    // The identifier generated via ContentDOMReference for the root element.
    this.rootElementId = FormAutofillUtils.getElementIdentifier(
      form.rootElement
    );

    ChromeUtils.defineLazyGetter(this, "log", () =>
      FormAutofill.defineLogGetter(this, "FormAutofillHandler")
    );
  }

  get fillOnFormChangeData() {
    return this.#fillOnFormChangeData;
  }

  clearFillOnFormChangeData() {
    this.#fillOnFormChangeData = new Map();
    this.#fillOnFormChangeData.isWithinDynamicFormChangeThreshold = false;
  }

  /**
   * Retrieves the 'fieldDetails' property, ensuring it has been initialized by
   * `setIdentifiedFieldDetails`. Throws an error if accessed before initialization.
   *
   * This is because 'fieldDetail'' contains information that need to be computed
   * in the parent side first.
   *
   * @throws {Error} If `setIdentifiedFieldDetails` has not been called.
   * @returns {Array<FieldDetail>}
   *          The list of autofillable field details for this form.
   */
  get fieldDetails() {
    if (!this.#fieldDetails) {
      throw new Error(
        `Should only use 'fieldDetails' after 'setIdentifiedFieldDetails' is called`
      );
    }
    return this.#fieldDetails;
  }

  /**
   * Sets the list of 'FieldDetail' objects for autofillable fields within the form.
   *
   * @param {Array<FieldDetail>} fieldDetails
   *        An array of field details that has been computed on the parent side.
   *        This method should be called before accessing `fieldDetails`.
   */
  setIdentifiedFieldDetails(fieldDetails) {
    this.#fieldDetails = fieldDetails;
  }

  /**
   * Determines whether 'setIdentifiedFieldDetails' has been called and the
   * `fieldDetails` have been initialized.
   *
   * @returns {boolean}
   *          True if 'fieldDetails' has been initialized; otherwise, False.
   */
  hasIdentifiedFields() {
    return !!this.#fieldDetails;
  }

  get isAutofillInProgress() {
    return this.#isAutofillInProgress;
  }

  handleEvent(event) {
    switch (event.type) {
      case "input": {
        if (!event.isTrusted || this.isAutofillInProgress) {
          return;
        }

        // This uses the #filledStateByElement map instead of
        // autofillState as the state has already been cleared by the time
        // the input event fires.
        const fieldDetail = this.getFieldDetailByElement(event.target);
        const previousState = this.getFilledStateByElement(event.target);
        const newState = FIELD_STATES.NORMAL;

        if (previousState != newState) {
          this.changeFieldState(fieldDetail, newState);
        }

        this.onFilledModifiedCallback?.(fieldDetail, previousState, newState);
      }
    }
  }

  getFieldDetailByName(fieldName) {
    return this.fieldDetails.find(detail => detail.fieldName == fieldName);
  }

  getFieldDetailByElement(element) {
    return this.fieldDetails.find(detail => detail.element == element);
  }

  getFieldDetailByElementId(elementId) {
    return this.fieldDetails.find(detail => detail.elementId == elementId);
  }

  /**
   * Only use this API within handleEvent
   */
  getFilledStateByElement(element) {
    return this.#filledStateByElement.get(element);
  }

  #clearVisibilityObserver() {
    this.#visibilityObserver.disconnect();
    this.#visibilityObserver = null;
    this.#visibilityStateObserverByElement = new WeakMap();
  }

  /**
   * Check the form is necessary to be updated. This function should be able to
   * detect any changes including all control elements in the form.
   *
   * @param {HTMLElement} element The element supposed to be in the form.
   * @returns {boolean} FormAutofillHandler.form is updated or not.
   */
  updateFormIfNeeded(element) {
    // When the following condition happens, FormAutofillHandler.form should be
    // updated:
    // * The count of form controls is changed.
    // * When the element can not be found in the current form.
    //
    // However, we should improve the function to detect the element changes.
    // e.g. a tel field is changed from type="hidden" to type="tel".

    let _formLike;
    const getFormLike = () => {
      if (!_formLike) {
        _formLike = lazy.AutofillFormFactory.createFromField(element);
      }
      return _formLike;
    };

    const currentForm = getFormLike();
    if (currentForm.elements.length != this.form.elements.length) {
      this.log.debug("The count of form elements is changed.");
      this._updateForm(getFormLike());
      return true;
    }

    if (!this.form.elements.includes(element)) {
      this.log.debug("The element can not be found in the current form.");
      this._updateForm(getFormLike());
      return true;
    }

    return false;
  }

  updateFormByElement(element) {
    const formLike = lazy.AutofillFormFactory.createFromField(element);
    this._updateForm(formLike);
  }

  /**
   * Update the form with a new FormLike, and the related fields should be
   * updated or clear to ensure the data consistency.
   *
   * @param {FormLike} form a new FormLike to replace the original one.
   */
  _updateForm(form) {
    this.form = form;

    this.#fieldDetails = null;
  }

  /**
   * Collect <input>, <select>, and <iframe> elements from the specified form
   * and return the correspond 'FieldDetail' objects.
   *
   * @param {formLike} formLike
   *        The form that we collect information from.
   * @param {boolean} includeIframe
   *        True to add <iframe> to the returned FieldDetails array.
   * @param {boolean} ignoreInvisibleInput
   *        True to NOT run heuristics on invisible <input> fields.
   *
   * @returns {Array<FieldDeail>}
   *        An array containing eligible fields for autofill, also
   *        including iframe.
   */
  static collectFormFieldDetails(
    formLike,
    includeIframe,
    ignoreInvisibleInput = true
  ) {
    const fieldDetails =
      lazy.FormAutofillHeuristics.getFormInfo(formLike, ignoreInvisibleInput) ??
      [];

    // 'FormLike' only contains <input> & <select>, so in order to include <iframe>
    // in the list of 'FieldDetails', we need to search for <iframe> in the form.
    if (!includeIframe) {
      return fieldDetails;
    }

    // Insert <iframe> elements into the fieldDetails array, maintaining the element order.
    const elements = formLike.rootElement.querySelectorAll("iframe");

    let startIndex = 0;

    // eslint-disable-next-line no-labels
    outer: for (const element of elements) {
      if (FormAutofillUtils.isFieldVisible(element)) {
        const iframeFd = lazy.FieldDetail.create(element, formLike, "iframe");

        for (let index = startIndex; index < fieldDetails.length; index++) {
          let position = element.compareDocumentPosition(
            fieldDetails[index]?.element
          );
          if (
            position &
            (Node.DOCUMENT_POSITION_FOLLOWING |
              Node.DOCUMENT_POSITION_CONTAINED_BY)
          ) {
            fieldDetails.splice(index, 0, iframeFd);
            startIndex = index; // start from this index for later iframes
            // eslint-disable-next-line no-labels
            continue outer;
          }
        }

        fieldDetails.push(iframeFd);
      }
    }

    return fieldDetails;
  }

  /**
   * Resetting the filled state after an element was removed from the form
   * Todo: We'll need to update this.filledResult in FormAutofillParent (Bug 1948077).
   *
   * @param {HTMLElement} element that was removed
   */
  resetFieldStateWhenRemoved(element) {
    if (this.getFilledStateByElement(element) != FIELD_STATES.AUTO_FILLED) {
      return;
    }
    this.#filledStateByElement.delete(element);
  }

  /**
   * Change the state of a field to correspond with different presentations.
   *
   * @param {object} fieldDetail
   *        A fieldDetail of which its element is about to update the state.
   * @param {string} state
   *        The state to apply.
   */
  changeFieldState(fieldDetail, state) {
    const element = fieldDetail.element;
    if (!element) {
      this.log.warn(
        fieldDetail.fieldName,
        "is unreachable while changing state"
      );
      return;
    }

    if (!Object.values(FIELD_STATES).includes(state)) {
      this.log.warn(
        fieldDetail.fieldName,
        "is trying to change to an invalid state"
      );
      return;
    }

    element.autofillState = state;
    this.#filledStateByElement.set(element, state);

    if (state == FIELD_STATES.AUTO_FILLED) {
      element.addEventListener("input", this, { mozSystemGroup: true });
    }
  }

  /**
   * Populates result to the preview layers with given profile.
   *
   * @param {Array} elementIds
   * @param {object} profile
   *        A profile to be previewed with
   */
  previewFields(elementIds, profile) {
    this.getAdaptedProfiles([profile]);

    for (const fieldDetail of this.fieldDetails) {
      const element = fieldDetail.element;

      // Skip the field if it is null or readonly or disabled
      if (
        !elementIds.includes(fieldDetail.elementId) ||
        !FormAutofillUtils.isFieldAutofillable(element)
      ) {
        continue;
      }

      let value = this.getFilledValueFromProfile(fieldDetail, profile);
      if (!value) {
        // A field could have been filled by a previous fill, so only
        // clear when in the preview state.
        if (element.autofillState == FIELD_STATES.PREVIEW) {
          this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
        }
        continue;
      }

      if (FormAutofillUtils.isTextControl(element)) {
        if (element.value && element.value != element.defaultValue) {
          // Skip the field if the user has already entered text and that text
          // is not the site prefilled value.
          continue;
        }
      } else if (HTMLSelectElement.isInstance(element)) {
        // Unlike text input, select element is always previewed even if
        // the option is already selected.
        const option = this.matchSelectOptions(fieldDetail, profile);
        if (!option) {
          continue;
        }
        value = option.text ?? "";
      } else {
        continue;
      }

      element.previewValue = value?.toString().replaceAll("*", "•");
      this.changeFieldState(fieldDetail, FIELD_STATES.PREVIEW);
    }
  }

  /**
   * Processes form fields that can be autofilled, and populates them with the
   * profile provided by backend.
   *
   * @param {string} focusedId
   *        The id of the element that triggers autofilling.
   * @param {Array} elementIds
   *        An array of IDs for the elements that should be autofilled.
   * @param {object} profile
   *        The data profile containing the values to be autofilled into the form fields.
   * @param {boolean} isFormChange
   *        True if this a fill caused by a form change.
   */
  fillFields(focusedId, elementIds, profile, isFormChange) {
    this.cancelRefillOnSiteClearingFieldsAction();

    this.#isAutofillInProgress = true;
    this.getAdaptedProfiles([profile]);

    const filledValuesByElement = new Map();
    for (const fieldDetail of this.fieldDetails) {
      const { element, elementId } = fieldDetail;

      if (
        !elementIds.includes(elementId) ||
        !FormAutofillUtils.isFieldAutofillable(element)
      ) {
        continue;
      }

      element.previewValue = "";

      let filledValue;
      if (FormAutofillUtils.isTextControl(element)) {
        // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field
        // that is generated when presentation ready data doesn't fit into the autofilling element.
        // For example, autofilling expiration month into an input element will not work as expected if
        // the month is less than 10, since the input is expected a zero-padded string.
        // See Bug 1722941 for follow up.
        const value = this.getFilledValueFromProfile(fieldDetail, profile);
        if (!value) {
          continue;
        }

        // For the focused input element, it will be filled with a valid value
        // anyway.
        // For the others, the fields should be only filled when their values are empty
        // or their values are equal to the site prefill value
        // or are the result of an earlier auto-fill.
        if (
          elementId == focusedId ||
          !element.value ||
          element.value == element.defaultValue ||
          element.autofillState == FIELD_STATES.AUTO_FILLED
        ) {
          FormAutofillHandler.fillFieldValue(element, value);
          filledValue = value;
        } else if (isFormChange && element.value == value) {
          // If this was a fill caused by a form change, and the value is
          // identical to the expected filled value, highlight it anyway.
          filledValue = value;
        }
      } else if (HTMLSelectElement.isInstance(element)) {
        const option = this.matchSelectOptions(fieldDetail, profile);
        if (!option) {
          if (
            this.getFilledStateByElement(element) == FIELD_STATES.AUTO_FILLED
          ) {
            // The select element was previously autofilled, but there
            // is no matching option under the current set of options anymore.
            // Changing the state will also remove the highlighting from the element
            this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
          }
          continue;
        }

        // Do not change value or dispatch events if the option is already selected.
        // Use case for multiple select is not considered here.
        if (!option.selected) {
          option.selected = true;
          FormAutofillHandler.fillFieldValue(element, option.value);
        }
        // Autofill highlight appears regardless if value is changed or not
        filledValue = option.value;
      }

      if (filledValue) {
        this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
        filledValuesByElement.set(element, filledValue);
      }
    }

    this.focusPreviouslyFocusedElement(focusedId);
    this.#isAutofillInProgress = false;

    this.registerFormChangeHandler();

    this.reassignValuesIfModified(filledValuesByElement, false);
  }

  registerFormChangeHandler() {
    if (this.onChangeHandler) {
      return;
    }

    this.log.debug("register change handler for filled form:", this.form);

    this.onChangeHandler = e => {
      if (!e.isTrusted) {
        return;
      }
      if (e.type == "reset") {
        this.cancelRefillOnSiteClearingFieldsAction();
        for (const fieldDetail of this.fieldDetails) {
          const element = fieldDetail.element;
          element.removeEventListener("input", this, { mozSystemGroup: true });
          this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
        }
      }

      // Unregister listeners once no field is in AUTO_FILLED state.
      if (
        this.fieldDetails.every(
          detail => detail.element.autofillState != FIELD_STATES.AUTO_FILLED
        )
      ) {
        this.form.rootElement.removeEventListener(
          "input",
          this.onChangeHandler,
          {
            mozSystemGroup: true,
          }
        );
        this.form.rootElement.removeEventListener(
          "reset",
          this.onChangeHandler,
          {
            mozSystemGroup: true,
          }
        );
        this.onChangeHandler = null;
      }
    };

    // Handle the highlight style resetting caused by user's correction afterward.
    this.form.rootElement.addEventListener("input", this.onChangeHandler, {
      mozSystemGroup: true,
    });
    this.form.rootElement.addEventListener("reset", this.onChangeHandler, {
      mozSystemGroup: true,
    });
  }

  /**
   * After a refill or clear action, the website might adjust the value of an
   * element immediately afterwards. If this happens, fill or clear the value
   * a second time to avoid having elements that are empty but highlighted, or
   * vice versa.
   *
   * @param {Map<HTMLElement,string>} filledValuesByElement
   * @param {boolean} onClear true for a clear action
   */
  reassignValuesIfModified(filledValuesByElement, onClear) {
    if (!FormAutofill.refillOnSiteClearingFields) {
      return;
    }

    this.#refillTimeoutId = lazy.setTimeout(() => {
      for (let [e, v] of filledValuesByElement) {
        if (onClear) {
          if (e.autofillState != FIELD_STATES.NORMAL || e.value !== v) {
            // Only reclear if the value was changed back to the original value.
            continue;
          }
        } else if (e.autofillState == FIELD_STATES.NORMAL || e.value) {
          // Nothing to do if the autofilled value wasn't cleared or the
          // element's autofill state has changed to NORMAL in the meantime
          continue;
        }

        this.#isAutofillInProgress = true;
        FormAutofillHandler.fillFieldValue(e, onClear ? "" : v, {
          ignoreFocus: true,
        });
        // Although the field should already be in the autofilled state at this point,
        // still setting autofilled state to re-highlight the element.
        e.autofillState = onClear
          ? FIELD_STATES.NORMAL
          : FIELD_STATES.AUTO_FILLED;
        this.#isAutofillInProgress = false;
      }

      this.#refillTimeoutId = null;
    }, FormAutofill.refillOnSiteClearingFieldsTimeout);
  }

  cancelRefillOnSiteClearingFieldsAction() {
    if (!FormAutofill.refillOnSiteClearingFields) {
      return;
    }
    if (this.#refillTimeoutId) {
      lazy.clearTimeout(this.#refillTimeoutId);
      this.#refillTimeoutId = null;
    }
  }

  /**
   * Listens for dynamic form changes by setting up two observer types:
   *      1. IntersectionObserver(s) that observe(s) intersections between
   *         (in-)visibile elements and an intersection target (the form/document of interest).
   *         (see this.setUpElementVisibilityObserver)
   *      2. MutationsObserver that observes child node additions and removals
   *         in the form/document of interest (see this.setUpNodesObserver)
   * If a form change is observed, a "form-changed" event gets dispatched transfering
   * the changed fields and the reason for the form change (see FORM_CHANGE_REASON).
   */
  setUpDynamicFormChangeObserver() {
    if (!FormAutofill.detectDynamicFormChanges) {
      return;
    }

    this.setUpElementVisibilityObserver();
    this.setUpFormNodesMutationObserver();
  }

  #initializeIntersectionObserver() {
    this.#visibilityObserver ??= new this.window.IntersectionObserver(
      (entries, _observer) => {
        const nowVisible = [];
        const nowInvisible = [];
        entries.forEach(entry => {
          let observedElement = entry.target;

          let oldState =
            this.#visibilityStateObserverByElement.get(observedElement);
          let newState = FormAutofillUtils.isFieldVisible(observedElement);
          if (oldState == newState) {
            return;
          }

          if (newState) {
            nowVisible.push(observedElement);
          } else {
            nowInvisible.push(observedElement);
          }
        });

        if (!nowVisible.length && !nowInvisible.length) {
          return;
        }

        let changes = {};
        if (nowVisible.length) {
          changes[FORM_CHANGE_REASON.ELEMENT_VISIBLE] = nowVisible;
        }
        if (nowInvisible.length) {
          changes[FORM_CHANGE_REASON.ELEMENT_INVISIBLE] = nowInvisible;
        }

        // Clear all of the observer state. The notification will add a new
        // observer if needed.
        this.#clearVisibilityObserver();

        const formChangedEvent = new CustomEvent("form-changed", {
          detail: {
            form: this.form.rootElement,
            changes,
          },
          bubbles: true,
        });
        this.form.ownerDocument.dispatchEvent(formChangedEvent);
      },
      {
        root: this.form.rootElement,
        // intersection ratio between 0.0 (invisible element) and 1.0 (visible element)
        threshold: [0, 1],
      }
    );
  }

  /**
   * Sets up an IntersectionObserver to handle each (in-)visible address/cc input element
   * in a form. The observer notifies of intersections between the (in-)visible element and
   * the intersection target (handler.form). This is the case if e.g. a visible element becomes
   * invisible or an invisible element becomes visible. If a visibility state change is observed,
   * a "form-changes" event is dispatched.
   */
  setUpElementVisibilityObserver() {
    for (let element of this.form.elements) {
      if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) {
        continue;
      }

      if (this.#visibilityStateObserverByElement.has(element)) {
        continue;
      }

      let state = FormAutofillUtils.isFieldVisible(element);
      if (state) {
        // We don't care about visibility state changes for fields that are not recognized
        // by our heuristics. We only handle this for visible fields because we currently
        // don't run field detection heuristics for invisible fields.
        const fieldDetail = this.getFieldDetailByElement(element);
        if (!fieldDetail.fieldName) {
          continue;
        }
      }

      this.#initializeIntersectionObserver();

      this.#visibilityObserver.observe(element);
      this.#visibilityStateObserverByElement.set(element, state);
    }
  }

  /**
   * Sets up a MutationObserver for the form or document (if form-less) of interest
   * in order to be notified about child nodes additions or removals.
   * If any of the added/removed nodes (including the nodes in the node's subtree)
   * are of an address of cc type, a "form-changed" event is dispatched.
   */
  setUpFormNodesMutationObserver() {
    if (this.#isObservingFormMutations) {
      return;
    }

    const mutationObserver = new this.window.MutationObserver(
      (mutations, _) => {
        const collectMutatedNodes = mutations => {
          let removedNodes = new Set();
          let addedNodes = new Set();
          let changedSelectElements = new Set();
          mutations.forEach(mutation => {
            if (mutation.type == "childList") {
              if (HTMLSelectElement.isInstance(mutation.target)) {
                changedSelectElements.add(mutation.target);
              } else if (mutation.addedNodes.length) {
                addedNodes.add(...mutation.addedNodes);
              } else if (mutation.removedNodes.length) {
                removedNodes.add(...mutation.removedNodes);
              }
            }
          });
          return [addedNodes, removedNodes, changedSelectElements];
        };

        const collectAllSubtreeElements = node => {
          if (!node.childNodes.length) {
            return node;
          }
          return Array.from(node.childNodes).flatMap(childNode =>
            collectAllSubtreeElements(childNode)
          );
        };

        const getCCAndAddressElements = nodes => {
          return nodes
            .flatMap(node => collectAllSubtreeElements(node))
            .filter(element =>
              FormAutofillUtils.isCreditCardOrAddressFieldType(element)
            );
        };

        const [addedNodes, removedNodes, changedSelectElements] =
          collectMutatedNodes(mutations);
        let relevantAddedElements = getCCAndAddressElements([...addedNodes]);
        // We only care about removed elements and changed select options
        // from the current set of detected fieldDetails
        let relevantRemovedElements = getCCAndAddressElements([
          ...removedNodes,
        ]).filter(
          element =>
            this.#fieldDetails && !!this.getFieldDetailByElement(element)
        );
        let relevantChangedSelectElements = [...changedSelectElements].filter(
          element =>
            this.#fieldDetails && !!this.getFieldDetailByElement(element)
        );

        if (
          !relevantRemovedElements.length &&
          !relevantAddedElements.length &&
          !relevantChangedSelectElements.length
        ) {
          return;
        }

        let changes = {};
        if (relevantChangedSelectElements.length) {
          changes[FORM_CHANGE_REASON.SELECT_OPTIONS_CHANGED] =
            relevantChangedSelectElements;
        }
        if (relevantRemovedElements.length) {
          changes[FORM_CHANGE_REASON.NODES_REMOVED] = relevantRemovedElements;
        }
        if (relevantAddedElements.length) {
          changes[FORM_CHANGE_REASON.NODES_ADDED] = relevantAddedElements;
        }

        const formChangedEvent = new CustomEvent("form-changed", {
          detail: {
            form: this.form.rootElement,
            changes,
          },
          bubbles: true,
        });
        this.form.ownerDocument.dispatchEvent(formChangedEvent);
      }
    );
    const config = { childList: true, subtree: true };
    this.#formMutationObserver = mutationObserver;
    this.#formMutationObserver.observe(this.form.rootElement, config);
    this.#isObservingFormMutations = true;
  }

  /**
   * After the form was submitted, disconnect all IntersectionObserver that
   * are still observing form's elements and disconnect the MutationsOberver
   * that is observing the form.
   */
  clearFormChangeObservers() {
    if (!this.#isObservingFormMutations) {
      return;
    }
    // Disconnect intersection observers
    this.#clearVisibilityObserver();
    // Disconnect mutation observer
    this.#formMutationObserver.disconnect();
    this.#isObservingFormMutations = false;
  }

  computeFillingValue(fieldDetail) {
    const element = fieldDetail.element;
    if (!element) {
      return null;
    }

    let value = element.value.trim();
    switch (fieldDetail.fieldName) {
      case "address-level1":
        if (HTMLSelectElement.isInstance(element)) {
          // Don't save the record when the option value is empty *OR* there
          // are multiple options being selected. The empty option is usually
          // assumed to be default along with a meaningless text to users.
          if (!value || element.selectedOptions.length != 1) {
            // Keep the property and preserve more information for address updating
            value = "";
          } else {
            const text = element.selectedOptions[0].text.trim();
            value =
              FormAutofillUtils.getAbbreviatedSubregionName([value, text]) ||
              text;
          }
        }
        break;
      case "country":
        // This is a temporary fix. Ideally we should have either case-insensitive comparison of country codes
        // or handle this elsewhere see Bug 1889234 for more context.
        value = value.toUpperCase();
        break;
      case "cc-type":
        if (
          HTMLSelectElement.isInstance(element) &&
          !lazy.CreditCard.isValidNetwork(value)
        ) {
          // Don't save the record when the option value is empty *OR* there
          // are multiple options being selected. The empty option is usually
          // assumed to be default along with a meaningless text to users.
          if (value && element.selectedOptions.length == 1) {
            const selectedOption = element.selectedOptions[0];
            const networkType =
              lazy.CreditCard.getNetworkFromName(selectedOption.text) ??
              lazy.CreditCard.getNetworkFromName(selectedOption.value);
            if (networkType) {
              value = networkType;
            }
          }
        }
        break;
    }

    return value;
  }

  /**
   * Match the select option for a field if we autofill with the given profile.
   * This function caches the matching result in the `#matchingSelectionOption`
   * variable.
   *
   * @param {FieldDetail} fieldDetail
   *        The field information of the matching element.
   * @param {object} profile
   *        The profile used for autofill.
   *
   * @returns {Option}
   *        The matched option, or undefined if no matching option is found.
   */
  matchSelectOptions(fieldDetail, profile) {
    if (!this.#matchingSelectOption) {
      this.#matchingSelectOption = new WeakMap();
    }

    const { element, fieldName } = fieldDetail;
    if (!HTMLSelectElement.isInstance(element)) {
      return undefined;
    }

    const cache = this.#matchingSelectOption.get(element) || {};

    let value;
    if (fieldName == "tel-country-code") {
      // Since some telephone country codes are for multiple countries, search
      // the options by the country name instead.
      let countries = lazy.PhoneNumber.FindCountriesForCountryCode(
        profile[fieldName]
      );
      if (countries.length > 1 && countries.includes(profile.country)) {
        value = profile.country;
      } else {
        value = countries[0];
      }
    } else {
      value = profile[fieldName];
    }

    let option = cache[value]?.deref();
    if (!option || !option.isConnected) {
      option = FormAutofillUtils.findSelectOption(
        element,
        profile,
        fieldName,
        value
      );

      if (option) {
        cache[value] = new WeakRef(option);
        this.#matchingSelectOption.set(element, cache);
      } else if (cache[value]) {
        delete cache[value];
        this.#matchingSelectOption.set(element, cache);
      }
    }

    return option;
  }

  getAdaptedProfiles(originalProfiles) {
    for (let profile of originalProfiles) {
      let transformer = new ProfileTransformer(this, profile);
      transformer.applyTransformers();
    }
    return originalProfiles;
  }

  /**
   *
   * @param {object} fieldDetail A fieldDetail of the related element.
   * @param {object} profile The profile to fill.
   * @returns {string} The value to fill for the given field.
   */
  getFilledValueFromProfile(fieldDetail, profile) {
    let value =
      profile[`${fieldDetail.fieldName}-formatted`] ||
      profile[fieldDetail.fieldName];

    if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) {
      const part = fieldDetail.part;
      return value.slice((part - 1) * 4, part * 4);
    }
    return value;
  }
  /**
   * Fills the provided element with the specified value.
   *
   * @param {HTMLElement} element - The form field element to be filled.
   * @param {string} value - The value to be filled into the form field.
   * @param {object} options
   * @param {boolean} [options.ignoreFocus] - Whether to ignore focusing the field that is filled.
   *                                          True  - When an autofilled field get's refilled after
   *                                                  its value was cleared
   *                                          False - Default
   */
  static fillFieldValue(element, value, { ignoreFocus = false } = {}) {
    // Ignoring to focus the field if it gets refilled (after the site cleared its value),
    // because it was already focused on the previous autofill action and we want to avoid
    // re-triggering any event listener callbacks or autocomplete dropdowns
    if (FormAutofillUtils.focusOnAutofill && !ignoreFocus) {
      element.focus({ preventScroll: true });
    }
    if (FormAutofillUtils.isTextControl(element)) {
      element.setUserInput(value);
    } else if (HTMLSelectElement.isInstance(element)) {
      // Set the value of the select element so that web event handlers can react accordingly
      element.value = value;
      element.dispatchEvent(
        new element.ownerGlobal.Event("input", { bubbles: true })
      );
      element.dispatchEvent(
        new element.ownerGlobal.Event("change", { bubbles: true })
      );
    }
  }

  clearPreviewedFields(elementIds) {
    for (const elementId of elementIds) {
      const fieldDetail = this.getFieldDetailByElementId(elementId);
      const element = fieldDetail?.element;
      if (!element) {
        this.log.warn(fieldDetail.fieldName, "is unreachable");
        continue;
      }

      element.previewValue = "";
      if (element.autofillState == FIELD_STATES.AUTO_FILLED) {
        continue;
      }
      this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
    }
  }

  clearFilledFields(focusedId, elementIds) {
    this.cancelRefillOnSiteClearingFieldsAction();
    this.#isAutofillInProgress = true;
    const fieldDetails = elementIds.map(id =>
      this.getFieldDetailByElementId(id)
    );

    const filledValuesByElement = new Map();

    for (const fieldDetail of fieldDetails) {
      const element = fieldDetail?.element;
      if (!element) {
        this.log.warn(fieldDetail?.fieldName, "is unreachable");
        continue;
      }

      if (element.autofillState == FIELD_STATES.AUTO_FILLED) {
        let value = "";
        if (HTMLSelectElement.isInstance(element)) {
          if (!element.options.length) {
            continue;
          }
          // Resets a <select> element to its selected option or the first
          // option if there is none selected.
          const selected = [...element.options].find(option =>
            option.hasAttribute("selected")
          );
          value = selected ? selected.value : element.options[0].value;
        } else {
          filledValuesByElement.set(element, element.value);
        }

        FormAutofillHandler.fillFieldValue(element, value);
        this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
      }
    }

    this.focusPreviouslyFocusedElement(focusedId);
    this.#isAutofillInProgress = false;

    this.reassignValuesIfModified(filledValuesByElement, true);
  }

  focusPreviouslyFocusedElement(focusedId) {
    let focusedElement = FormAutofillUtils.getElementByIdentifier(focusedId);
    if (FormAutofillUtils.focusOnAutofill && focusedElement) {
      focusedElement.focus({ preventScroll: true });
    }
  }

  /**
   * Return the record that is keyed by element id and value is the normalized value
   * done by computeFillingValue
   *
   * @returns {object} An object keyed by element id, and the value is
   *                   an object that includes the following properties:
   * filledState: The autofill state of the element
   * filledvalue: The value of the element
   */
  collectFormFilledData() {
    const filledData = new Map();

    for (const fieldDetail of this.fieldDetails) {
      const element = fieldDetail.element;
      filledData.set(fieldDetail.elementId, {
        filledState: element.autofillState,
        filledValue: this.computeFillingValue(fieldDetail),
      });
    }
    return filledData;
  }

  isFieldAutofillable(fieldDetail, profile) {
    if (FormAutofillUtils.isTextControl(fieldDetail.element)) {
      return !!profile[fieldDetail.fieldName];
    }
    return !!this.matchSelectOptions(fieldDetail, profile);
  }
}

/**
 * Apply some transformations to the fields on the profile based
 *  on the fields that appear in the form. The original values are
 *  saved and used if the transformer is used again.
 */
class ProfileTransformer {
  // The FormAutofillHandler
  #handler = null;

  // A profile for adjusting credit card and address related values.
  #profile = null;

  constructor(handler, profile) {
    this.#handler = handler;
    this.#profile = profile;
  }

  // Get the original unmodified value of a field if it exists.
  getField(fieldName) {
    if (this.#profile._original) {
      let value = this.#profile._original[fieldName];
      if (value) {
        return value;
      }
    }

    return this.#profile[fieldName];
  }

  // Get the modified value of a field if it exists, or the
  // original value.
  getUpdatedField(fieldName) {
    return this.#profile[fieldName];
  }

  // Modify a field's value, but store the original value for
  // use later.
  setField(fieldName, value) {
    let originalValue = this.#profile[fieldName];
    if (originalValue) {
      if (!this.#profile._original) {
        this.#profile._original = {};
      }
      if (!this.#profile._original[fieldName]) {
        this.#profile._original[fieldName] = originalValue;
      }
    }

    this.#profile[fieldName] = value;
  }

  // Delete the modified value of a field, but leave the stored
  // original value.
  deleteField(fieldName) {
    delete this.#profile[fieldName];
  }

  getFieldDetailByName(fieldName) {
    return this.#handler.getFieldDetailByName(fieldName);
  }

  applyTransformers() {
    this.#addressTransformer();
    this.#telTransformer();
    this.#creditCardExpiryDateTransformer();
    this.#creditCardExpMonthAndYearTransformer();
    this.#creditCardNameTransformer();
    this.#addressLevelOneTransformer();
    this.#adaptFieldMaxLength();
  }

  /**
   * Replaces an abbreviated address-level1 code (e.g. "B") with the full
   * region name (e.g. "Buenos Aires") if the target field is a text input.
   */
  #addressLevelOneTransformer() {
    const fieldName = "address-level1";
    const fieldDetail = this.getFieldDetailByName(fieldName);
    if (!fieldDetail || !FormAutofillUtils.isTextControl(fieldDetail.element)) {
      return;
    }

    const element = fieldDetail.element;
    const abbreviatedValue = this.getField(fieldName);
    const country = this.getField("country");

    const fullSubregionName = FormAutofillUtils.getFullSubregionName(
      abbreviatedValue,
      country
    );

    if (!fullSubregionName || fullSubregionName === abbreviatedValue) {
      return;
    }

    // No point in using full subregion name if allowed string length is too small.
    if (
      element.maxLength !== -1 &&
      fullSubregionName.length > element.maxLength
    ) {
      return;
    }

    this.setField(fieldName, fullSubregionName);
  }

  // This function mostly uses getUpdatedField as it relies on the modified
  // values of fields from the previous functions.
  #adaptFieldMaxLength() {
    for (let key in this.#profile) {
      let detail = this.getFieldDetailByName(key);
      if (!detail || detail.part) {
        continue;
      }

      let element = detail.element;
      if (!element) {
        continue;
      }

      let maxLength = element.maxLength;
      if (
        maxLength === undefined ||
        maxLength < 0 ||
        this.getUpdatedField(key).toString().length <= maxLength
      ) {
        continue;
      }

      if (maxLength) {
        switch (typeof this.getUpdatedField(key)) {
          case "string":
            // If this is an expiration field and our previous
            // adaptations haven't resulted in a string that is
            // short enough to satisfy the field length, and the
            // field is constrained to a length of 4 or 5, then we
            // assume it is intended to hold an expiration of the
            // form "MMYY" or "MM/YY".
            if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) {
              const month2Digits = (
                "0" + this.getField("cc-exp-month").toString()
              ).slice(-2);
              const year2Digits = this.getField("cc-exp-year")
                .toString()
                .slice(-2);
              const separator = maxLength == 5 ? "/" : "";
              this.setField(key, `${month2Digits}${separator}${year2Digits}`);
            } else if (key == "cc-number") {
              // We want to show the last four digits of credit card so that
              // the masked credit card previews correctly and appears correctly
              // in the autocomplete menu
              let value = this.getField(key);
              this.setField(key, value.substr(value.length - maxLength));
            } else {
              this.setField(
                key,
                this.getUpdatedField(key).substr(0, maxLength)
              );
            }
            break;
          case "number":
            // There's no way to truncate a number smaller than a
            // single digit.
            if (maxLength < 1) {
              maxLength = 1;
            }
            // The only numbers we store are expiration month/year,
            // and if they truncate, we want the final digits, not
            // the initial ones.
            this.setField(
              key,
              this.getUpdatedField(key) % Math.pow(10, maxLength)
            );
            break;
          default:
        }
      } else {
        // This code only seems to run when maxlength = 0, an edge case which
        // hardly seems worth handling.
        this.deleteField(key);
        this.deleteField(`${key}-formatted`);
      }
    }
  }

  /**
   * Handles credit card expiry date transformation when
   * the expiry date exists in a cc-exp field.
   */
  #creditCardExpiryDateTransformer() {
    if (!this.getField("cc-exp")) {
      return;
    }

    const element = this.getFieldDetailByName("cc-exp")?.element;
    if (!element) {
      return;
    }

    function updateExpiry(_string, _month, _year) {
      // Bug 1687681: This is a short term fix to other locales having
      // different characters to represent year.
      // - FR locales may use "A" to represent year.
      // - DE locales may use "J" to represent year.
      // - PL locales may use "R" to represent year.
      // This approach will not scale well and should be investigated in a follow up bug.
      const monthChars = "m";
      const yearChars = "yy|aa|jj|rr";
      const expiryDateFormatRegex = (firstChars, secondChars) =>
        new RegExp(
          "(?:\\b|^)((?:[" +
            firstChars +
            "]{2}){1,2})\\s*([\\-/])\\s*((?:[" +
            secondChars +
            "]{2}){1,2})(?:\\b|$)",
          "i"
        );

      // If the month first check finds a result, where placeholder is "mm - yyyy",
      // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"]
      let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string);
      if (result) {
        return (
          _month.padStart(result[1].length, "0") +
          result[2] +
          _year.substr(-1 * result[3].length)
        );
      }

      // If the year first check finds a result, where placeholder is "yyyy mm",
      // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"]
      result = expiryDateFormatRegex(yearChars, monthChars).exec(_string);
      if (result) {
        return (
          _year.substr(-1 * result[1].length) +
          result[2] +
          _month.padStart(result[3].length, "0")
        );
      }
      return null;
    }

    let newExpiryString = null;
    const month = this.getField("cc-exp-month").toString();
    const year = this.getField("cc-exp-year").toString();
    if (element.localName == "input") {
      // Use the placeholder or label to determine the expiry string format.
      const possibleExpiryStrings = [];
      if (element.placeholder) {
        possibleExpiryStrings.push(element.placeholder);
      }
      const labels = lazy.LabelUtils.findLabelElements(element);
      if (labels) {
        // Not consider multiple lable for now.
        possibleExpiryStrings.push(element.labels[0]?.textContent);
      }
      if (element.previousElementSibling?.localName == "label") {
        possibleExpiryStrings.push(element.previousElementSibling.textContent);
      }

      possibleExpiryStrings.some(string => {
        newExpiryString = updateExpiry(string, month, year);
        return !!newExpiryString;
      });
    }

    // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the
    // preferred presentation format for credit card expiry dates.
    this.setField(
      "cc-exp",
      newExpiryString ?? `${month.padStart(2, "0")}/${year}`
    );
  }

  /**
   * Handles credit card expiry date transformation when the expiry date exists in
   * the separate cc-exp-month and cc-exp-year fields
   */
  #creditCardExpMonthAndYearTransformer() {
    const getInputElementByField = (field, self) => {
      if (!field) {
        return null;
      }
      const detail = self.getFieldDetailByName(field);
      if (!detail) {
        return null;
      }
      const element = detail.element;
      return element.localName === "input" ? element : null;
    };
    const month = getInputElementByField("cc-exp-month", this);
    if (month) {
      // Transform the expiry month to MM since this is a common format needed for filling.
      this.setField(
        "cc-exp-month-formatted",
        this.getField("cc-exp-month")?.toString().padStart(2, "0")
      );
    }
    const year = getInputElementByField("cc-exp-year", this);
    // If the expiration year element is an input,
    // then we examine any placeholder to see if we should format the expiration year
    // as a zero padded string in order to autofill correctly.
    if (year) {
      const placeholder = year.placeholder;

      // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits.
      const result = /\b(yy|aa|jj|rr)\b/i.test(placeholder);
      if (result) {
        this.setField(
          "cc-exp-year-formatted",
          this.getField("cc-exp-year")?.toString().substring(2)
        );
      }
    }
  }

  /**
   * Handles credit card name transformation when the name exists in
   * the separate cc-given-name, cc-middle-name, and cc-family name fields
   */
  #creditCardNameTransformer() {
    const name = this.getField("cc-name");
    if (!name) {
      return;
    }

    const given = this.getFieldDetailByName("cc-given-name");
    const middle = this.getFieldDetailByName("cc-middle-name");
    const family = this.getFieldDetailByName("cc-family-name");
    if (given || middle || family) {
      const nameParts = lazy.FormAutofillNameUtils.splitName(name);
      if (given && nameParts.given) {
        this.setField("cc-given-name", nameParts.given);
      }
      if (middle && nameParts.middle) {
        this.setField("cc-middle-name", nameParts.middle);
      }
      if (family && nameParts.family) {
        this.setField("cc-family-name", nameParts.family);
      }
    }
  }

  #addressTransformer() {
    let streetAddress = this.getField("street-address");
    if (streetAddress) {
      // "-moz-street-address-one-line" is used by the labels in
      // ProfileAutoCompleteResult.
      this.setField(
        "-moz-street-address-one-line",
        FormAutofillUtils.toOneLineAddress(streetAddress)
      );
      let streetAddressDetail = this.getFieldDetailByName("street-address");
      if (
        streetAddressDetail &&
        FormAutofillUtils.isTextControl(streetAddressDetail.element)
      ) {
        this.setField(
          "street-address",
          this.getField("-moz-street-address-one-line")
        );
      }

      let waitForConcat = [];
      for (let f of ["address-line3", "address-line2", "address-line1"]) {
        waitForConcat.unshift(this.getField(f));
        if (this.getFieldDetailByName(f)) {
          if (waitForConcat.length > 1) {
            this.setField(f, FormAutofillUtils.toOneLineAddress(waitForConcat));
          }
          waitForConcat = [];
        }
      }
    }

    // If a house number field exists, split the address up into house number
    // and street name.
    if (this.getFieldDetailByName("address-housenumber")) {
      let address = lazy.AddressParser.parseStreetAddress(
        this.getField("street-address")
      );
      if (address) {
        this.setField("address-housenumber", address.street_number);
        let field = this.getFieldDetailByName("address-line1")
          ? "address-line1"
          : "street-address";
        this.setField(field, address.street_name);
      }
    }
  }

  /**
   * Replace tel with tel-national if tel violates the input element's
   * restriction.
   */
  #telTransformer() {
    let tel = this.getField("tel");
    let telNational = this.getField("tel-national");
    if (!tel || !telNational) {
      return;
    }

    let detail = this.getFieldDetailByName("tel");
    if (!detail) {
      return;
    }

    let element = detail.element;
    let _pattern;
    let testPattern = str => {
      if (!_pattern) {
        // The pattern has to match the entire value.
        _pattern = new RegExp("^(?:" + element.pattern + ")$", "u");
      }
      return _pattern.test(str);
    };
    if (element.pattern) {
      if (testPattern(tel)) {
        return;
      }
    } else if (element.maxLength) {
      if (detail.reason == "autocomplete" && tel.length <= element.maxLength) {
        return;
      }
    }

    if (detail.reason != "autocomplete") {
      // Since we only target people living in US and using en-US websites in
      // MVP, it makes more sense to fill `tel-national` instead of `tel`
      // if the field is identified by heuristics and no other clues to
      // determine which one is better.
      // TODO: [Bug 1407545] This should be improved once more countries are
      // supported.
      this.setField("tel", telNational);
    } else if (element.pattern) {
      if (testPattern(telNational)) {
        this.setField("tel", telNational);
      }
    } else if (element.maxLength) {
      if (telNational.length <= element.maxLength) {
        this.setField("tel", telNational);
      }
    }
  }
}
PK