/* 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 list of known and supported credit card network ids ("types")
// This list mirrors the networks from dom/payments/BasicCardPayment.cpp
// and is defined by https://www.w3.org/Payments/card-network-ids
const SUPPORTED_NETWORKS = Object.freeze([
  "amex",
  "cartebancaire",
  "diners",
  "discover",
  "jcb",
  "mastercard",
  "mir",
  "unionpay",
  "visa",
]);

// This lists stores lower cased variations of popular credit card network
// names for matching against strings.
export const NETWORK_NAMES = {
  "american express": "amex",
  "master card": "mastercard",
  "union pay": "unionpay",
};

// Based on https://en.wikipedia.org/wiki/Payment_card_number
//
// Notice:
//   - CarteBancaire (`4035`, `4360`) must precede the generic Visa entry.
// This means that the order matters.
// First we'll try to match more specific card,
// and if that doesn't match we'll test against the more generic range.
const CREDIT_CARD_IIN = [
  { type: "amex", start: 34, end: 34, len: 15 },
  { type: "amex", start: 37, end: 37, len: 15 },
  { type: "cartebancaire", start: 4035, end: 4035, len: 16 },
  { type: "cartebancaire", start: 4360, end: 4360, len: 16 },
  // We diverge from Wikipedia here, because Diners card
  // support length of 14-19.
  { type: "diners", start: 300, end: 305, len: [14, 19] },
  { type: "diners", start: 3095, end: 3095, len: [14, 19] },
  { type: "diners", start: 36, end: 36, len: [14, 19] },
  { type: "diners", start: 38, end: 39, len: [14, 19] },
  { type: "discover", start: 6011, end: 6011, len: [16, 19] },
  { type: "discover", start: 644, end: 649, len: [16, 19] },
  { type: "discover", start: 65, end: 65, len: [16, 19] },
  { type: "jcb", start: 3528, end: 3589, len: [16, 19] },
  { type: "mastercard", start: 2221, end: 2720, len: 16 },
  { type: "mastercard", start: 51, end: 55, len: 16 },
  { type: "mir", start: 2200, end: 2204, len: [16, 19] },
  { type: "unionpay", start: 62, end: 62, len: [16, 19] },
  { type: "unionpay", start: 81, end: 81, len: [16, 19] },
  { type: "visa", start: 4, end: 4, len: [13, 19] },
].sort((a, b) => b.start - a.start);

export class CreditCard {
  /**
   * A CreditCard object represents a credit card, with
   * number, name, expiration, network, and CCV.
   * The number is the only required information when creating
   * an object, all other members are optional. The number
   * is validated during construction and will throw if invalid.
   *
   * @param {string} name, optional
   * @param {string} number
   * @param {string} expirationString, optional
   * @param {string|number} expirationMonth, optional
   * @param {string|number} expirationYear, optional
   * @param {string} network, optional
   * @param {string|number} ccv, optional
   * @param {string} encryptedNumber, optional
   * @throws if number is an invalid credit card number
   */
  constructor({
    name,
    number,
    expirationString,
    expirationMonth,
    expirationYear,
    network,
    ccv,
    encryptedNumber,
  }) {
    this._name = name;
    this._unmodifiedNumber = number;
    this._encryptedNumber = encryptedNumber;
    this._ccv = ccv;
    this.number = number;
    let { month, year } = CreditCard.normalizeExpiration({
      expirationString,
      expirationMonth,
      expirationYear,
    });
    this._expirationMonth = month;
    this._expirationYear = year;
    this.network = network;
  }

  set name(value) {
    this._name = value;
  }

  set expirationMonth(value) {
    if (typeof value == "undefined") {
      this._expirationMonth = undefined;
      return;
    }
    this._expirationMonth = CreditCard.normalizeExpirationMonth(value);
  }

  get expirationMonth() {
    return this._expirationMonth;
  }

  set expirationYear(value) {
    if (typeof value == "undefined") {
      this._expirationYear = undefined;
      return;
    }
    this._expirationYear = CreditCard.normalizeExpirationYear(value);
  }

  get expirationYear() {
    return this._expirationYear;
  }

  set expirationString(value) {
    let { month, year } = CreditCard.parseExpirationString(value);
    this.expirationMonth = month;
    this.expirationYear = year;
  }

  set ccv(value) {
    this._ccv = value;
  }

  get number() {
    return this._number;
  }

  /**
   * Sets the number member of a CreditCard object. If the number
   * is not valid according to the Luhn algorithm then the member
   * will get set to the empty string before throwing an exception.
   *
   * @param {string} value
   * @throws if the value is an invalid credit card number
   */
  set number(value) {
    if (value) {
      let normalizedNumber = CreditCard.normalizeCardNumber(value);
      // Based on the information on wiki[1], the shortest valid length should be
      // 12 digits (Diners Club Carte Blanche has 14, but some legacy networks go lower).
      // [1] https://en.wikipedia.org/wiki/Payment_card_number
      normalizedNumber = normalizedNumber.match(/^\d{12,}$/)
        ? normalizedNumber
        : "";
      this._number = normalizedNumber;
    } else {
      this._number = "";
    }

    if (value && !this.isValidNumber()) {
      this._number = "";
      throw new Error("Invalid credit card number");
    }
  }

  get network() {
    return this._network;
  }

  set network(value) {
    this._network = value || undefined;
  }

  // Implements the Luhn checksum algorithm as described at
  // http://wikipedia.org/wiki/Luhn_algorithm
  // Number digit lengths vary with network, but should fall within 12-19 range. [2]
  // More details at https://en.wikipedia.org/wiki/Payment_card_number
  isValidNumber() {
    if (!this._number) {
      return false;
    }

    // Remove dashes and whitespace
    const number = CreditCard.normalizeCardNumber(this._number);

    const len = number.length;
    if (len < 12 || len > 19) {
      return false;
    }

    if (!/^\d+$/.test(number)) {
      return false;
    }

    let total = 0;
    for (let i = 0; i < len; i++) {
      let ch = parseInt(number[len - i - 1], 10);
      if (i % 2 == 1) {
        // Double it, add digits together if > 10
        ch *= 2;
        if (ch > 9) {
          ch -= 9;
        }
      }
      total += ch;
    }
    return total % 10 == 0;
  }

  /**
   * Normalizes a credit card number.
   *
   * @param {string} number
   * @return {string | null}
   * @memberof CreditCard
   */
  static normalizeCardNumber(number) {
    if (!number) {
      return null;
    }
    return number.replace(/[\-\s]/g, "");
  }

  /**
   * Attempts to match the number against known network identifiers.
   *
   * @param {string} ccNumber Credit card number with no spaces or special characters in it.
   *
   * @returns {string|null}
   */
  static getType(ccNumber) {
    if (!ccNumber) {
      return null;
    }

    for (let i = 0; i < CREDIT_CARD_IIN.length; i++) {
      const range = CREDIT_CARD_IIN[i];
      if (typeof range.len == "number") {
        if (range.len != ccNumber.length) {
          continue;
        }
      } else if (
        ccNumber.length < range.len[0] ||
        ccNumber.length > range.len[1]
      ) {
        continue;
      }

      const prefixLength = Math.floor(Math.log10(range.start)) + 1;
      const prefix = parseInt(ccNumber.substring(0, prefixLength), 10);
      if (prefix >= range.start && prefix <= range.end) {
        return range.type;
      }
    }
    return null;
  }

  /**
   * Attempts to retrieve a card network identifier based
   * on a name.
   *
   * @param {string|undefined|null} name
   *
   * @returns {string|null}
   */
  static getNetworkFromName(name) {
    if (!name) {
      return null;
    }
    let lcName = name.trim().toLowerCase().normalize("NFKC");
    if (SUPPORTED_NETWORKS.includes(lcName)) {
      return lcName;
    }
    for (let term in NETWORK_NAMES) {
      if (lcName.includes(term)) {
        return NETWORK_NAMES[term];
      }
    }
    return null;
  }

  /**
   * Returns true if the card number is valid and the
   * expiration date has not passed. Otherwise false.
   *
   * @returns {boolean}
   */
  isValid() {
    if (!this.isValidNumber()) {
      return false;
    }

    let currentDate = new Date();
    let currentYear = currentDate.getFullYear();
    if (this._expirationYear > currentYear) {
      return true;
    }

    // getMonth is 0-based, so add 1 because credit cards are 1-based
    let currentMonth = currentDate.getMonth() + 1;
    return (
      this._expirationYear == currentYear &&
      this._expirationMonth >= currentMonth
    );
  }

  get maskedNumber() {
    return CreditCard.getMaskedNumber(this._number);
  }

  get longMaskedNumber() {
    return CreditCard.getLongMaskedNumber(this._number);
  }

  /**
   * Get credit card display label. It should display masked numbers, the
   * cardholder's name, and the expiration date, separated by a commas.
   * In addition, the card type is provided in the accessibility label.
   */
  static getLabelInfo({ number, name, month, year, type }) {
    let formatSelector = ["number"];
    if (name) {
      formatSelector.push("name");
    }
    if (month && year) {
      formatSelector.push("expiration");
    }
    let stringId = `credit-card-label-${formatSelector.join("-")}-2`;
    return {
      id: stringId,
      args: {
        number: CreditCard.getMaskedNumber(number),
        name,
        month: month?.toString(),
        year: year?.toString(),
        type,
      },
    };
  }

  /**
   * Please use getLabelInfo above, as it allows for localization.
   *
   * @deprecated
   */
  static getLabel({ number, name }) {
    let parts = [];

    if (number) {
      parts.push(CreditCard.getMaskedNumber(number));
    }
    if (name) {
      parts.push(name);
    }
    return parts.join(", ");
  }

  static normalizeExpirationMonth(month) {
    month = parseInt(month, 10);
    if (isNaN(month) || month < 1 || month > 12) {
      return undefined;
    }
    return month;
  }

  static normalizeExpirationYear(year) {
    year = parseInt(year, 10);
    if (isNaN(year) || year < 0) {
      return undefined;
    }
    if (year < 100) {
      year += 2000;
    }
    return year;
  }

  static parseExpirationString(expirationString) {
    let rules = [
      {
        regex: /(?:^|\D)(\d{2})(\d{2})(?!\d)/,
      },
      {
        regex: /(?:^|\D)(\d{4})[-/](\d{1,2})(?!\d)/,
        yearIndex: 0,
        monthIndex: 1,
      },
      {
        regex: /(?:^|\D)(\d{1,2})[-/](\d{4})(?!\d)/,
        yearIndex: 1,
        monthIndex: 0,
      },
      {
        regex: /(?:^|\D)(\d{1,2})[-/](\d{1,2})(?!\d)/,
      },
      {
        regex: /(?:^|\D)(\d{2})(\d{2})(?!\d)/,
      },
    ];

    expirationString = expirationString.replaceAll(" ", "");
    for (let rule of rules) {
      let result = rule.regex.exec(expirationString);
      if (!result) {
        continue;
      }

      let year, month;
      const parsedResults = [parseInt(result[1], 10), parseInt(result[2], 10)];
      if (!rule.yearIndex || !rule.monthIndex) {
        month = parsedResults[0];
        if (month > 12) {
          year = parsedResults[0];
          month = parsedResults[1];
        } else {
          year = parsedResults[1];
        }
      } else {
        year = parsedResults[rule.yearIndex];
        month = parsedResults[rule.monthIndex];
      }

      if (month >= 1 && month <= 12 && (year < 100 || year > 2000)) {
        return { month, year };
      }
    }
    return { month: undefined, year: undefined };
  }

  static normalizeExpiration({
    expirationString,
    expirationMonth,
    expirationYear,
  }) {
    // Only prefer the string version if missing one or both parsed formats.
    let parsedExpiration = {};
    if (expirationString && (!expirationMonth || !expirationYear)) {
      parsedExpiration = CreditCard.parseExpirationString(expirationString);
    }
    return {
      month: CreditCard.normalizeExpirationMonth(
        parsedExpiration.month || expirationMonth
      ),
      year: CreditCard.normalizeExpirationYear(
        parsedExpiration.year || expirationYear
      ),
    };
  }

  static formatMaskedNumber(maskedNumber) {
    return "•".repeat(4) + maskedNumber.substr(-4);
  }

  static getMaskedNumber(number) {
    return "•".repeat(4) + " " + number.substr(-4);
  }

  static getLongMaskedNumber(number) {
    return "•".repeat(number.length - 4) + number.substr(-4);
  }

  static getCreditCardLogo(network) {
    const PATH = "chrome://formautofill/content/";
    const THIRD_PARTY_PATH = PATH + "third-party/";
    switch (network) {
      case "amex":
        return THIRD_PARTY_PATH + "cc-logo-amex.png";
      case "cartebancaire":
        return THIRD_PARTY_PATH + "cc-logo-cartebancaire.png";
      case "diners":
        return THIRD_PARTY_PATH + "cc-logo-diners.svg";
      case "discover":
        return THIRD_PARTY_PATH + "cc-logo-discover.png";
      case "jcb":
        return THIRD_PARTY_PATH + "cc-logo-jcb.svg";
      case "mastercard":
        return THIRD_PARTY_PATH + "cc-logo-mastercard.svg";
      case "mir":
        return THIRD_PARTY_PATH + "cc-logo-mir.svg";
      case "unionpay":
        return THIRD_PARTY_PATH + "cc-logo-unionpay.svg";
      case "visa":
        return THIRD_PARTY_PATH + "cc-logo-visa.svg";
      default:
        return PATH + "icon-credit-card-generic.svg";
    }
  }

  /*
   * Validates the number according to the Luhn algorithm. This
   * method does not throw an exception if the number is invalid.
   */
  static isValidNumber(number) {
    try {
      new CreditCard({ number });
    } catch (ex) {
      return false;
    }
    return true;
  }

  static isValidNetwork(network) {
    return SUPPORTED_NETWORKS.includes(network);
  }

  static getSupportedNetworks() {
    return SUPPORTED_NETWORKS;
  }

  /**
   * Localised names for supported networks are available in
   * `browser/preferences/formAutofill.ftl`.
   */
  static getNetworkL10nId(network) {
    return this.isValidNetwork(network)
      ? `autofill-card-network-${network}`
      : null;
  }
}
