/* 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, {
  FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
});

/**
 * Represents the detailed information about a form field, including
 * the inferred field name, the approach used for inferring, and additional metadata.
 */
export class FieldDetail {
  // Reference to the elemenet
  elementWeakRef = null;

  // The identifier generated via ContentDOMReference for the associated DOM element
  // of this field
  elementId = null;

  // The identifier generated via ContentDOMReference for the root element of
  // this field
  rootElementId = null;

  // If the element is an iframe, it is the id of the BrowsingContext of the iframe,
  // Otherwise, it is the id of the BrowsingContext the element is in
  browsingContextId = null;

  // string with `${element.id}/{element.name}`. This is only used for debugging.
  identifier = "";

  // tag name attribute of the element
  localName = null;

  // The inferred field name for this element.
  fieldName = null;

  // The approach we use to infer the information for this element
  // The possible values are "autocomplete", "fathom", and "regex-heuristic"
  reason = null;

  // This field could be a lookup field, for example, one that could be used to
  // search for an address or postal code and fill in other fields.
  isLookup = false;

  /*
   * The "section", "addressType", and "contactType" 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.
   */

  // Which section the field belongs to. The value comes from autocomplete attribute.
  // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens for more details
  section = "";
  addressType = "";
  contactType = "";
  credentialType = "";

  // When a field is split into N fields, we use part to record which field it is
  // For example, a credit card number field is split into 4 fields, the value of
  // "part" for the first cc-number field is 1, for the last one is 4.
  // If the field is not split, the value is null
  part = null;

  // Confidence value when the field name is inferred by "fathom"
  confidence = null;

  constructor(element) {
    this.elementWeakRef = new WeakRef(element);
  }

  get element() {
    return this.elementWeakRef.deref();
  }

  /**
   * Convert FieldDetail class to an object that is suitable for
   * sending over IPC. Avoid using this in other case.
   */
  toVanillaObject() {
    const json = { ...this };
    delete json.elementWeakRef;
    return json;
  }

  static fromVanillaObject(obj) {
    const element = lazy.FormAutofillUtils.getElementByIdentifier(
      obj.elementId
    );
    return element ? Object.assign(new FieldDetail(element), obj) : null;
  }

  static create(
    element,
    form,
    fieldName = null,
    {
      autocompleteInfo = null,
      fathomConfidence = null,
      isVisible = true,
      isLookup = false,
      mlData = null,
    } = {}
  ) {
    const fieldDetail = new FieldDetail(element);

    fieldDetail.elementId =
      lazy.FormAutofillUtils.getElementIdentifier(element);
    fieldDetail.rootElementId = lazy.FormAutofillUtils.getElementIdentifier(
      form.rootElement
    );
    fieldDetail.identifier = `${element.id}/${element.name}`;
    fieldDetail.localName = element.localName;

    if (Array.isArray(fieldName)) {
      fieldDetail.fieldName = fieldName[0] ?? "";
      fieldDetail.alternativeFieldName = fieldName[1] ?? "";
    } else {
      fieldDetail.fieldName = fieldName;
    }

    if (!fieldDetail.fieldName) {
      fieldDetail.reason = "unknown";
    } else if (autocompleteInfo) {
      fieldDetail.reason = "autocomplete";
      fieldDetail.section = autocompleteInfo.section;
      fieldDetail.addressType = autocompleteInfo.addressType;
      fieldDetail.contactType = autocompleteInfo.contactType;
      fieldDetail.credentialType = autocompleteInfo.credentialType;
      fieldDetail.sectionName =
        autocompleteInfo.section || autocompleteInfo.addressType;
    } else if (fathomConfidence) {
      fieldDetail.reason = "fathom";
      fieldDetail.confidence = fathomConfidence;
    } else {
      fieldDetail.reason = "regex-heuristic";
    }

    fieldDetail.mlData = mlData;

    try {
      fieldDetail.browsingContextId =
        element.localName == "iframe"
          ? element.browsingContext.id
          : BrowsingContext.getFromWindow(element.documentGlobal).id;
    } catch {
      /* unit test doesn't have documentGlobal */
    }

    fieldDetail.isVisible = isVisible;

    // Info required by heuristics
    fieldDetail.maxLength = element.maxLength;

    fieldDetail.isLookup = isLookup;

    return fieldDetail;
  }
}

/**
 * A scanner for traversing all elements in a form. It also provides a
 * cursor (parsingIndex) to indicate which element is waiting for parsing.
 *
 * The scanner retrives the field detail by calling heuristics handlers
 * `inferFieldInfo` function.
 */
export class FieldScanner {
  #parsingIndex = 0;

  #fieldDetails = [];

  /**
   * Create a FieldScanner based on form elements with the existing
   * fieldDetails.
   *
   * @param {Array<FieldDetails>} fieldDetails
   *        An array of fieldDetail object to be scanned.
   */
  constructor(fieldDetails) {
    this.#fieldDetails = fieldDetails;
  }

  /**
   * This cursor means the index of the element which is waiting for parsing.
   *
   * @returns {number}
   *          The index of the element which is waiting for parsing.
   */
  get parsingIndex() {
    return this.#parsingIndex;
  }

  get parsingFinished() {
    return this.parsingIndex >= this.#fieldDetails.length;
  }

  /**
   * Move the parsingIndex to the next elements. Any elements behind this index
   * means the parsing tasks are finished.
   *
   * @param {number} index
   *        The latest index of elements waiting for parsing.
   */
  set parsingIndex(index) {
    if (index > this.#fieldDetails.length) {
      throw new Error("The parsing index is out of range.");
    }
    this.#parsingIndex = index;
  }

  /**
   * Retrieve the field detail by the index. If the field detail is not ready,
   * the elements will be traversed until matching the index.
   *
   * @param {number} index
   *        The index of the element that you want to retrieve.
   * @returns {object}
   *          The field detail at the specific index.
   */
  getFieldDetailByIndex(index) {
    if (index >= this.#fieldDetails.length) {
      return null;
    }

    return this.#fieldDetails[index];
  }

  getFieldsMatching(matchFn, includeInvisible = false) {
    let fields = [];

    for (let idx = 0; this.elementExisting(idx); idx++) {
      let field = this.#fieldDetails[idx];
      if ((includeInvisible || field.isVisible) && matchFn(field)) {
        fields.push(field);
      }
    }

    return fields;
  }

  /**
   * Return the index of the first visible field found with the given name.
   *
   * @param {string} fieldName
   *        The field name to find.
   * @param {string} includeInvisible
   *        Whether to find non-visible fields.
   * @returns {number}
   *          The index of the element or -1 if not found.
   */
  getFieldIndexByName(fieldName, includeInvisible = false) {
    for (let idx = 0; this.elementExisting(idx); idx++) {
      let field = this.#fieldDetails[idx];
      if (
        field.fieldName == fieldName &&
        (includeInvisible || field.isVisible)
      ) {
        return idx;
      }
    }

    return -1;
  }

  /**
   * When a field detail should be changed its fieldName after parsing, use
   * this function to update the fieldName which is at a specific index.
   *
   * @param {number} index
   *        The index indicates a field detail to be updated.
   * @param {string} fieldName
   *        The new name of the field
   * @param {boolean} [ignoreAutocomplete=false]
   *        Whether to change the field name when the field name is determined by
   *        autocomplete attribute
   */
  updateFieldName(index, fieldName, ignoreAutocomplete = false) {
    if (index >= this.#fieldDetails.length) {
      throw new Error("Try to update the non-existing field detail.");
    }

    const fieldDetail = this.#fieldDetails[index];
    if (fieldDetail.fieldName == fieldName) {
      return;
    }

    if (!ignoreAutocomplete && fieldDetail.reason == "autocomplete") {
      return;
    }

    fieldDetail.fieldName = fieldName;
    fieldDetail.reason = "update-heuristic";
  }

  elementExisting(index) {
    return index < this.#fieldDetails.length;
  }
}

export default FieldScanner;
