/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.wicket.validation.validator;

import org.apache.wicket.validation.IValidatable;
import org.apache.wicket.validation.IValidationError;
import org.apache.wicket.validation.IValidator;
import org.apache.wicket.validation.ValidationError;

/**
 * Checks if a credit card number is valid. The number will be checked for "American Express",
 * "China UnionPay", "Diners Club Carte Blanche", "Diners Club International",
 * "Diners Club US & Canada", "Discover Card", "JCB", "Laser", "Maestro", "MasterCard", "Solo",
 * "Switch", "Visa" and "Visa Electron". If none of those apply to the credit card number, the
 * credit card number is considered invalid.
 * 
 * <p>
 * Card prefixes and lengths have been taken from <a
 * href="http://en.wikipedia.org/w/index.php?title=Bank_card_number&oldid=322132931">Wikipedia</a>.
 * 
 * @author Johan Compagner
 * @author Joachim F. Rohde
 * @since 1.2.6
 */
public class CreditCardValidator implements IValidator<String>
{
	private static final long serialVersionUID = 1L;

	/** */
	public static enum CreditCard {
		/** */
		INVALID(null),
		/** */
		AMERICAN_EXPRESS("American Express"),
		/** */
		CHINA_UNIONPAY("China UnionPay"),
		/** */
		DINERS_CLUB_CARTE_BLANCHE("Diners Club Carte Blanche"),
		/** */
		DINERS_CLUB_INTERNATIONAL("Diners Club International"),
		/** */
		DINERS_CLUB_US_AND_CANADA("Diners Club US & Canada"),
		/** */
		DISCOVER_CARD("Discover Card"),
		/** */
		JCB("JCB"),
		/** */
		LASER("Laser"),
		/** */
		MAESTRO("Maestro"),
		/** */
		MASTERCARD("MasterCard"),
		/** */
		SOLO("Solo"),
		/** */
		SWITCH("Switch"),
		/** */
		VISA("Visa"),
		/** */
		VISA_ELECTRON("Visa Electron");

		private final String name;

		CreditCard(String name)
		{
			this.name = name;
		}
	}

	/** The ID which represents the credit card institute. */
	private CreditCard cardId = CreditCard.INVALID;

	private boolean failOnUnknown = true;

	/**
	 * Construct.
	 */
	public CreditCardValidator()
	{
	}

	/**
	 * Construct.
	 * 
	 * @param failOnUnknown
	 */
	public CreditCardValidator(final boolean failOnUnknown)
	{
		this.failOnUnknown = failOnUnknown;
	}

	/**
	 * 
	 * @return Credit card issuer
	 */
	public final CreditCard getCardId()
	{
		return cardId;
	}

	/**
	 * Allow subclasses to set the card id
	 * 
	 * @param cardId
	 */
	protected void setCardId(final CreditCard cardId)
	{
		this.cardId = cardId;
	}

	@Override
	public void validate(final IValidatable<String> validatable)
	{
		final String value = validatable.getValue();

		try
		{
			if (!isLengthAndPrefixCorrect(value))
			{
				validatable.error(decorate(new ValidationError(this), validatable));
			}
		}
		catch (final NumberFormatException ex)
		{
			validatable.error(decorate(new ValidationError(this), validatable));
		}
	}

	/**
	 * Allows subclasses to decorate reported errors
	 * 
	 * @param error
	 * @param validatable
	 * @return decorated error
	 */
	protected IValidationError decorate(IValidationError error, IValidatable<String> validatable)
	{
		return error;
	}

	/**
	 * Checks if the credit card number can be determined as a valid number.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return <code>TRUE</code> if the credit card number could be determined as a valid number,
	 *         else <code>FALSE</code> is returned
	 */
	protected boolean isLengthAndPrefixCorrect(String creditCardNumber)
	{
		if (creditCardNumber == null)
		{
			return false;
		}

		// strip spaces and dashes
		creditCardNumber = creditCardNumber.replaceAll("[ -]", "");

		// the length of the credit card number has to be between 12 and 19.
		// else the number is invalid.
		if ((creditCardNumber.length() >= 12) && (creditCardNumber.length() <= 19) &&
			isChecksumCorrect(creditCardNumber))
		{
			if ((failOnUnknown == false) ||
				(determineCardId(creditCardNumber) != CreditCard.INVALID))
			{
				return true;
			}
		}

		return false;
	}

	/**
	 * Checks if the credit card number can be determined as a valid number.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return <code>TRUE</code> if the credit card number could be determined as a valid number,
	 *         else <code>FALSE</code> is returned
	 */
	public final CreditCard determineCardId(String creditCardNumber)
	{
		if (creditCardNumber == null)
		{
			return CreditCard.INVALID;
		}

		// strip spaces and dashes
		creditCardNumber = creditCardNumber.replaceAll("[ -]", "");

		// the length of the credit card number has to be between 12 and 19.
		// else the number is invalid.
		if ((creditCardNumber.length() >= 12) && (creditCardNumber.length() <= 19) &&
			isChecksumCorrect(creditCardNumber))
		{
			cardId = CreditCard.INVALID;
			if (cardId == CreditCard.INVALID)
			{
				cardId = isAmericanExpress(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isChinaUnionPay(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isDinersClubCarteBlanche(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isDinersClubInternational(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isDinersClubUsAndCanada(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isDiscoverCard(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isJCB(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isLaser(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isMaestro(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isMastercard(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isSolo(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isSwitch(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isVisa(creditCardNumber);
			}
			if (cardId == CreditCard.INVALID)
			{
				cardId = isVisaElectron(creditCardNumber);
			}
		}
		else
		{
			cardId = isUnknown(creditCardNumber);
		}

		return cardId;
	}

	/**
	 * Can be used (subclassed) to extend the test with a credit card not yet known by the
	 * validator.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	protected CreditCard isUnknown(String creditCardNumber)
	{
		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is an American Express. An American Express number has to start with
	 * 34 or 37 and has to have a length of 15. The number has to be validated with the Luhn
	 * algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isAmericanExpress(String creditCardNumber)
	{
		if (creditCardNumber.length() == 15 &&
			(creditCardNumber.startsWith("34") || creditCardNumber.startsWith("37")))
		{
			return CreditCard.AMERICAN_EXPRESS;
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a China UnionPay. A China UnionPay number has to start with 622
	 * (622126-622925) and has to have a length between 16 and 19. No further validation takes
	 * place.<br/>
	 * <br/>
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isChinaUnionPay(String creditCardNumber)
	{
		if ((creditCardNumber.length() >= 16 && creditCardNumber.length() <= 19) &&
			(creditCardNumber.startsWith("622")))
		{
			int firstDigits = Integer.parseInt(creditCardNumber.substring(0, 6));
			if (firstDigits >= 622126 && firstDigits <= 622925)
			{
				return CreditCard.CHINA_UNIONPAY;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Diners Club Carte Blanche. A Diners Club Carte Blanche number
	 * has to start with a number between 300 and 305 and has to have a length of 14. The number has
	 * to be validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isDinersClubCarteBlanche(String creditCardNumber)
	{
		if (creditCardNumber.length() == 14 && creditCardNumber.startsWith("30"))
		{
			int firstDigits = Integer.parseInt(creditCardNumber.substring(0, 3));
			if (firstDigits >= 300 && firstDigits <= 305)
			{
				return CreditCard.DINERS_CLUB_CARTE_BLANCHE;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Diners Club International. A Diners Club International number
	 * has to start with the number 36 and has to have a length of 14. The number has to be
	 * validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isDinersClubInternational(String creditCardNumber)
	{
		if (creditCardNumber.length() == 14 && creditCardNumber.startsWith("36"))
		{
			return CreditCard.DINERS_CLUB_INTERNATIONAL;
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Diners Club US & Canada. A Diners Club US & Canada number has
	 * to start with the number 54 or 55 and has to have a length of 16. The number has to be
	 * validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isDinersClubUsAndCanada(String creditCardNumber)
	{
		if (creditCardNumber.length() == 16 &&
			(creditCardNumber.startsWith("54") || creditCardNumber.startsWith("55")))
		{
			return CreditCard.DINERS_CLUB_US_AND_CANADA;
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Discover Card. A Discover Card number has to start with 6011,
	 * 622126-622925, 644-649 or 65 and has to have a length of 16. The number has to be validated
	 * with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isDiscoverCard(String creditCardNumber)
	{
		if (creditCardNumber.length() == 16 && creditCardNumber.startsWith("6"))
		{
			int firstThreeDigits = Integer.parseInt(creditCardNumber.substring(0, 3));
			int firstSixDigits = Integer.parseInt(creditCardNumber.substring(0, 6));
			if (creditCardNumber.startsWith("6011") || creditCardNumber.startsWith("65") ||
				(firstThreeDigits >= 644 && firstThreeDigits <= 649) ||
				(firstSixDigits >= 622126 && firstSixDigits <= 622925))
			{
				return CreditCard.DISCOVER_CARD;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a JCB. A JCB number has to start with a number between 3528 and
	 * 3589 and has to have a length of 16. The number has to be validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isJCB(String creditCardNumber)
	{
		if (creditCardNumber.length() == 16)
		{
			int firstFourDigits = Integer.parseInt(creditCardNumber.substring(0, 4));
			if (firstFourDigits >= 3528 && firstFourDigits <= 3589)
			{
				return CreditCard.JCB;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Laser. A Laser number has to start with 6304, 6706, 6771 or
	 * 6709 and has to have a length between 16 and 19 digits. The number has to be validated with
	 * the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isLaser(String creditCardNumber)
	{
		if (creditCardNumber.length() >= 16 && creditCardNumber.length() <= 19)
		{
			if (creditCardNumber.startsWith("6304") || creditCardNumber.startsWith("6706") ||
				creditCardNumber.startsWith("6771") || creditCardNumber.startsWith("6709"))
			{
				return CreditCard.LASER;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Maestro. A Maestro number has to start with
	 * 5018,5020,5038,6304,6759,6761 or 6763 and has to have a length between 12 and 19 digits. The
	 * number has to be validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isMaestro(String creditCardNumber)
	{
		if (creditCardNumber.length() >= 12 && creditCardNumber.length() <= 19)
		{
			if (creditCardNumber.startsWith("5018") || creditCardNumber.startsWith("5020") ||
				creditCardNumber.startsWith("5038") || creditCardNumber.startsWith("6304") ||
				creditCardNumber.startsWith("6759") || creditCardNumber.startsWith("6761") ||
				creditCardNumber.startsWith("6763"))
			{
				return CreditCard.MAESTRO;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Solo. A Solo number has to start with 6334 or 6767 and has to
	 * have a length of 16, 18 or 19 digits. The number has to be validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isSolo(String creditCardNumber)
	{
		if ((creditCardNumber.length() == 16) || (creditCardNumber.length() == 18) ||
			(creditCardNumber.length() == 19))
		{
			if (creditCardNumber.startsWith("6334") || creditCardNumber.startsWith("6767"))
			{
				return CreditCard.SOLO;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Switch. A Switch number has to start with
	 * 4903,4905,4911,4936,564182,633110,6333 or 6759 and has to have a length of 16, 18 or 19
	 * digits. The number has to be validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isSwitch(String creditCardNumber)
	{
		if ((creditCardNumber.length() == 16 || creditCardNumber.length() == 18 || creditCardNumber.length() == 19))
		{
			if (creditCardNumber.startsWith("4903") || creditCardNumber.startsWith("4905") ||
				creditCardNumber.startsWith("4911") || creditCardNumber.startsWith("4936") ||
				creditCardNumber.startsWith("564182") || creditCardNumber.startsWith("633110") ||
				creditCardNumber.startsWith("6333") || creditCardNumber.startsWith("6759"))
			{
				return CreditCard.SWITCH;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Visa. A Visa number has to start with a 4 and has to have a
	 * length of 13 or 16 digits. The number has to be validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isVisa(String creditCardNumber)
	{
		if (creditCardNumber.length() == 13 || creditCardNumber.length() == 16)
		{
			if (creditCardNumber.startsWith("4"))
			{
				return CreditCard.VISA;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Visa Electron. A Visa Electron number has to start with
	 * 417500,4917,4913,4508 or 4844 and has to have a length of 16 digits. The number has to be
	 * validated with the Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isVisaElectron(String creditCardNumber)
	{
		if (creditCardNumber.length() == 16 &&
			(creditCardNumber.startsWith("417500") || creditCardNumber.startsWith("4917") ||
				creditCardNumber.startsWith("4913") || creditCardNumber.startsWith("4508") || creditCardNumber.startsWith("4844")))
		{
			return CreditCard.VISA_ELECTRON;
		}

		return CreditCard.INVALID;
	}

	/**
	 * Check if the credit card is a Mastercard. A Mastercard number has to start with a number
	 * between 51 and 55 and has to have a length of 16. The number has to be validated with the
	 * Luhn algorithm.
	 * 
	 * @param creditCardNumber
	 *            the credit card number as a string
	 * @return The credit card id of the issuer
	 */
	private CreditCard isMastercard(String creditCardNumber)
	{
		if (creditCardNumber.length() == 16)
		{
			int firstTwoDigits = Integer.parseInt(creditCardNumber.substring(0, 2));
			if (firstTwoDigits >= 51 && firstTwoDigits <= 55)
			{
				return CreditCard.MASTERCARD;
			}
		}

		return CreditCard.INVALID;
	}

	/**
	 * Calculates the checksum of a credit card number using the Luhn algorithm (the so-called
	 * "mod 10" algorithm).
	 * 
	 * @param creditCardNumber
	 *            the credit card number for which the checksum should be calculated
	 * @return <code>TRUE</code> if the checksum for the given credit card number is valid, else
	 *         return <code>FALSE</code>
	 * @see <a href="http://en.wikipedia.org/wiki/Luhn_algorithm">Wikipedie - Luhn algorithm</a>
	 */
	protected final boolean isChecksumCorrect(final String creditCardNumber)
	{
		int nulOffset = '0';
		int sum = 0;
		for (int i = 1; i <= creditCardNumber.length(); i++)
		{
			int currentDigit = creditCardNumber.charAt(creditCardNumber.length() - i) - nulOffset;
			if ((i % 2) == 0)
			{
				currentDigit *= 2;
				currentDigit = currentDigit > 9 ? currentDigit - 9 : currentDigit;
				sum += currentDigit;
			}
			else
			{
				sum += currentDigit;
			}
		}

		return (sum % 10) == 0;
	}
}
