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

ChromeUtils.defineLazyGetter(
  lazy,
  "l10n",
  () => new Localization(["toolkit/formautofill/formAutofill.ftl"], true)
);

class ProfileAutoCompleteResult {
  externalEntries = [];

  constructor(
    searchString,
    focusedFieldDetail,
    allFieldNames,
    matchingProfiles,
    fillCategories,
    { resultCode = null, isSecure = true, isInputAutofilled = false }
  ) {
    // nsISupports
    this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]);

    // The user's query string
    this.searchString = searchString;
    // The field name of the focused input.
    this._focusedFieldName = focusedFieldDetail.fieldName;
    // The content dom reference id of the focused input.
    this._focusedElementId = focusedFieldDetail.elementId;
    // The matching profiles contains the information for filling forms.
    this._matchingProfiles = matchingProfiles;
    // The default item that should be entered if none is selected
    this.defaultIndex = 0;
    // The reason the search failed
    this.errorDescription = "";
    // The value used to determine whether the form is secure or not.
    this._isSecure = isSecure;
    // The value to indicate whether the focused input has been autofilled or not.
    this._isInputAutofilled = isInputAutofilled;
    // All fillable field names in the form including the field name of the currently-focused input.
    this._allFieldNames = [
      ...this._matchingProfiles.reduce((fieldSet, curProfile) => {
        for (let field of Object.keys(curProfile)) {
          fieldSet.add(field);
        }

        return fieldSet;
      }, new Set()),
    ].filter(field => allFieldNames.includes(field));

    this._fillCategories = fillCategories;

    // Force return success code if the focused field is auto-filled in order
    // to show clear form button popup.
    if (isInputAutofilled) {
      resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
    }
    // The result code of this result object.
    if (resultCode) {
      this.searchResult = resultCode;
    } else {
      this.searchResult = matchingProfiles.length
        ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
        : Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
    }

    // An array of primary and secondary labels for each profile.
    this._popupLabels = this._generateLabels(
      this._focusedFieldName,
      this._allFieldNames,
      this._matchingProfiles,
      this._fillCategories
    );
  }

  getAt(index) {
    for (const group of [this._popupLabels, this.externalEntries]) {
      if (index < group.length) {
        return group[index];
      }
      index -= group.length;
    }

    throw Components.Exception(
      "Index out of range.",
      Cr.NS_ERROR_ILLEGAL_VALUE
    );
  }

  /**
   * @returns {number} The number of results
   */
  get matchCount() {
    return this._popupLabels.length + this.externalEntries.length;
  }

  /**
   * Get the secondary label based on the focused field name and related field names
   * in the same form.
   *
   * @param   {string} _focusedFieldName The field name of the focused input
   * @param   {Array<object>} _allFieldNames The field names in the same section
   * @param   {object} _profile The profile providing the labels to show.
   * @returns {string} The secondary label
   */
  _getSecondaryLabel(_focusedFieldName, _allFieldNames, _profile) {
    return "";
  }

  _generateLabels(
    _focusedFieldName,
    _allFieldNames,
    _profiles,
    _fillCategories
  ) {}

  /**
   * Get the value of the result at the given index.
   *
   * Always return empty string for form autofill feature to suppress
   * AutoCompleteController from autofilling, as we'll populate the
   * fields on our own.
   *
   * @param   {number} index The index of the result requested
   * @returns {string} The result at the specified index
   */
  getValueAt(index) {
    this.getAt(index);
    return "";
  }

  getLabelAt(index) {
    const item = this.getAt(index);
    return typeof item == "string" ? item : item.primary || item.label;
  }

  /**
   * Retrieves a comment (metadata instance)
   *
   * @param   {number} index The index of the comment requested
   * @returns {string} The comment at the specified index
   */
  getCommentAt(index) {
    const item = this.getAt(index);
    if (item.style == "status") {
      return JSON.stringify(item);
    }

    const data = {
      fillMessageData: {
        focusElementId: this._focusedElementId,
      },
    };

    const type = this.getTypeOfIndex(index);
    switch (type) {
      case "clear":
        data.fillMessageName = "FormAutofill:ClearForm";
        break;
      case "manage":
        data.fillMessageName = "FormAutofill:OpenPreferences";
        break;
      case "insecure":
        data.noLearnMore = true;
        break;
      default: {
        if (item.comment) {
          return item.comment;
        }

        data.fillMessageName = "FormAutofill:FillForm";
        data.fillMessageData.profile = this._matchingProfiles[index];
        break;
      }
    }

    return JSON.stringify({ ...item, ...data });
  }

  /**
   * Retrieves a style hint specific to a particular index.
   *
   * @param   {number} index The index of the style hint requested
   * @returns {string} The style hint at the specified index
   */
  getStyleAt(index) {
    const itemStyle = this.getAt(index).style;
    if (itemStyle) {
      return itemStyle;
    }

    switch (this.getTypeOfIndex(index)) {
      case "manage":
        return "action";
      case "clear":
        return "action";
      case "insecure":
        return "insecureWarning";
      default:
        return "autofill";
    }
  }

  /**
   * Retrieves an image url.
   *
   * @param   {number} index The index of the image url requested
   * @returns {string} The image url at the specified index
   */
  getImageAt(index) {
    return this.getAt(index).image ?? "";
  }

  /**
   * Retrieves a result
   *
   * @param   {number} index The index of the result requested
   * @returns {string} The result at the specified index
   */
  getFinalCompleteValueAt(index) {
    return this.getValueAt(index);
  }

  /**
   * Returns true if the value at the given index is removable
   *
   * @param   {number}  _index The index of the result to remove
   * @returns {boolean} True if the value is removable
   */
  isRemovableAt(_index) {
    return false;
  }

  /**
   * Removes a result from the resultset
   *
   * @param {number} _index The index of the result to remove
   */
  removeValueAt(_index) {
    // There is no plan to support removing profiles via autocomplete.
  }

  /**
   * Returns a type string that identifies te type of row at the given index.
   *
   * @param   {number} index The index of the result requested
   * @returns {string} The type at the specified index
   */
  getTypeOfIndex(index) {
    if (this._isInputAutofilled && index == 0) {
      return "clear";
    }

    if (index == this._popupLabels.length - 1) {
      return "manage";
    }

    return "item";
  }
}

export class AddressResult extends ProfileAutoCompleteResult {
  _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
    // We group similar fields into the same field name so we won't pick another
    // field in the same group as the secondary label.
    const GROUP_FIELDS = {
      name: ["name", "given-name", "additional-name", "family-name"],
      "street-address": [
        "street-address",
        "address-line1",
        "address-line2",
        "address-line3",
      ],
      "country-name": ["country", "country-name"],
      tel: [
        "tel",
        "tel-country-code",
        "tel-national",
        "tel-area-code",
        "tel-local",
        "tel-local-prefix",
        "tel-local-suffix",
      ],
    };

    const secondaryLabelOrder = [
      "street-address", // Street address
      "name", // Full name
      "address-level3", // Townland / Neighborhood / Village
      "address-level2", // City/Town
      "organization", // Company or organization name
      "address-level1", // Province/State (Standardized code if possible)
      "country-name", // Country name
      "postal-code", // Postal code
      "tel", // Phone number
      "email", // Email address
    ];

    for (let field in GROUP_FIELDS) {
      if (GROUP_FIELDS[field].includes(focusedFieldName)) {
        focusedFieldName = field;
        break;
      }
    }

    for (const currentFieldName of secondaryLabelOrder) {
      if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
        continue;
      }

      let matching = GROUP_FIELDS[currentFieldName]
        ? allFieldNames.some(fieldName =>
            GROUP_FIELDS[currentFieldName].includes(fieldName)
          )
        : allFieldNames.includes(currentFieldName);

      if (matching) {
        if (
          currentFieldName == "street-address" &&
          profile["-moz-street-address-one-line"]
        ) {
          return profile["-moz-street-address-one-line"];
        }
        return profile[currentFieldName];
      }
    }

    return ""; // Nothing matched.
  }

  _generateLabels(focusedFieldName, allFieldNames, profiles, fillCategories) {
    const manageLabel = lazy.l10n.formatValueSync(
      "autofill-manage-addresses-label"
    );

    let footerItem = {
      primary: manageLabel,
      secondary: "",
    };

    if (this._isInputAutofilled) {
      const clearLabel = lazy.l10n.formatValueSync("autofill-clear-form-label");

      let labels = [
        {
          primary: clearLabel,
        },
      ];
      labels.push(footerItem);
      return labels;
    }

    const focusedCategory =
      lazy.FormAutofillUtils.getCategoryFromFieldName(focusedFieldName);

    const labels = [];
    for (let idx = 0; idx < profiles.length; idx++) {
      const profile = profiles[idx];

      let primary = profile[focusedFieldName];
      // Skip results without a primary label.
      if (!primary) {
        continue;
      }

      if (
        focusedFieldName == "street-address" &&
        profile["-moz-street-address-one-line"]
      ) {
        primary = profile["-moz-street-address-one-line"];
      }

      const status = this.getStatusNote(fillCategories[idx], focusedCategory);
      const secondary = this._getSecondaryLabel(
        focusedFieldName,
        allFieldNames,
        profile
      );
      // Exclude empty chunks.
      const ariaLabel = [primary, secondary, status]
        .filter(chunk => !!chunk)
        .join(" ");

      labels.push({
        primary,
        secondary,
        status,
        ariaLabel,
      });
    }

    const allCategories =
      lazy.FormAutofillUtils.getCategoriesFromFieldNames(allFieldNames);

    if (allCategories?.length) {
      const statusItem = {
        primary: "",
        secondary: "",
        status: this.getStatusNote(allCategories, focusedCategory),
        style: "status",
      };
      labels.push(statusItem);
    }

    labels.push(footerItem);

    return labels;
  }

  getStatusNote(categories, focusedCategory) {
    if (!categories || !categories.length) {
      return "";
    }

    // If the length of categories is 1, that means all the fillable fields are in the same
    // category. We will change the way to inform user according to this flag. When the value
    // is true, we show "Also autofills ...", otherwise, show "Autofills ..." only.
    let hasExtraCategories = categories.length > 1;
    // Show the categories in certain order to conform with the spec.
    let orderedCategoryList = [
      "address",
      "name",
      "organization",
      "tel",
      "email",
    ];
    let showCategories = hasExtraCategories
      ? orderedCategoryList.filter(
          category =>
            categories.includes(category) && category != focusedCategory
        )
      : [orderedCategoryList.find(category => category == focusedCategory)];

    let formatter = new Intl.ListFormat(undefined, {
      style: "narrow",
    });

    let categoriesText = showCategories.map(category =>
      lazy.l10n.formatValueSync("autofill-category-" + category)
    );
    categoriesText = formatter.format(categoriesText);

    let statusTextTmplKey = hasExtraCategories
      ? "autofill-phishing-warningmessage-extracategory"
      : "autofill-phishing-warningmessage";
    return lazy.l10n.formatValueSync(statusTextTmplKey, {
      categories: categoriesText,
    });
  }
}

export class CreditCardResult extends ProfileAutoCompleteResult {
  _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
    const GROUP_FIELDS = {
      "cc-name": [
        "cc-name",
        "cc-given-name",
        "cc-additional-name",
        "cc-family-name",
      ],
      "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"],
    };

    const secondaryLabelOrder = [
      "cc-number", // Credit card number
      "cc-name", // Full name
      "cc-exp", // Expiration date
    ];

    for (let field in GROUP_FIELDS) {
      if (GROUP_FIELDS[field].includes(focusedFieldName)) {
        focusedFieldName = field;
        break;
      }
    }

    for (const currentFieldName of secondaryLabelOrder) {
      if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
        continue;
      }

      let matching = GROUP_FIELDS[currentFieldName]
        ? allFieldNames.some(fieldName =>
            GROUP_FIELDS[currentFieldName].includes(fieldName)
          )
        : allFieldNames.includes(currentFieldName);

      if (matching) {
        if (currentFieldName == "cc-number") {
          return lazy.CreditCard.formatMaskedNumber(profile[currentFieldName]);
        }
        return profile[currentFieldName];
      }
    }

    return ""; // Nothing matched.
  }

  _generateLabels(focusedFieldName, allFieldNames, profiles, _fillCategories) {
    if (!this._isSecure) {
      let brandName =
        lazy.FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");

      return [
        lazy.FormAutofillUtils.stringBundle.formatStringFromName(
          "insecureFieldWarningDescription",
          [brandName]
        ),
      ];
    }

    const manageLabel = lazy.l10n.formatValueSync(
      "autofill-manage-payment-methods-label"
    );

    let footerItem = {
      primary: manageLabel,
    };

    if (this._isInputAutofilled) {
      const clearLabel = lazy.l10n.formatValueSync("autofill-clear-form-label");

      let labels = [
        {
          primary: clearLabel,
        },
      ];
      labels.push(footerItem);
      return labels;
    }

    // Skip results without a primary label.
    let labels = profiles
      .filter(profile => {
        return !!profile[focusedFieldName];
      })
      .map(profile => {
        let primary = profile[focusedFieldName];

        if (focusedFieldName == "cc-number") {
          primary = lazy.CreditCard.formatMaskedNumber(primary);
        }
        const secondary = this._getSecondaryLabel(
          focusedFieldName,
          allFieldNames,
          profile
        );
        // The card type is displayed visually using an image. For a11y, we need
        // to expose it as text. We do this using aria-label. However,
        // aria-label overrides the text content, so we must include that also.
        const ccType = profile["cc-type"];
        const image = lazy.CreditCard.getCreditCardLogo(ccType);
        const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType);
        const ccTypeName = ccTypeL10nId
          ? lazy.l10n.formatValueSync(ccTypeL10nId)
          : (ccType ?? ""); // Unknown card type
        const ariaLabel = [
          ccTypeName,
          primary.toString().replaceAll("*", ""),
          secondary,
        ]
          .filter(chunk => !!chunk) // Exclude empty chunks.
          .join(" ");
        return {
          primary: primary.toString().replaceAll("*", "•"),
          secondary: secondary.toString().replaceAll("*", "•"),
          ariaLabel,
          image,
        };
      });

    labels.push(footerItem);

    return labels;
  }

  getTypeOfIndex(index) {
    if (!this._isSecure) {
      return "insecure";
    }

    return super.getTypeOfIndex(index);
  }
}
