package lu.tudor.santec.gecamed.core.utils;

import java.security.InvalidParameterException;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * @author jens.ferring(at)tudor.lu
 * 
 * @version
 * <br>$Log: MatriculeChecker.java,v $
 * <br>Revision 1.1  2014-01-22 14:41:12  ferring
 * <br>*** empty log message ***
 * <br>
 * 
 * 
 * This class provides functionality to check the 13-digits-SSN and 
 * to transform the old 11-digits-SSN into a 13-digits-SSN.
 */

public class SSNChecker
{
	/* ======================================== */
	// CONSTANTS
	/* ======================================== */

	public static final String		EMPTY_SSN_COMPACT			= "0000000000000";
	
	public static final String		EMPTY_SSN_FORMATTED			= "0000 00 00 000 00";
	
	private static final String		FILL_UP_SSN					= "0000 00 00 000   ";
	
	
	// Pattern
	private static final Pattern	PATTERN_SSN					= Pattern.compile (
			"^(\\d{4})\\s?(\\d{2})\\s?(\\d{2})\\s?(\\d{3})\\s?([\\d\\s]{2})?$",Pattern.CASE_INSENSITIVE);
	
	private static final Pattern	PATTERN_SSN13				= Pattern.compile (
			"^(\\d{4})\\s?(\\d{2})\\s?(\\d{2})\\s?(\\d{3})\\s?(\\d)?(\\d)?$",Pattern.CASE_INSENSITIVE);
	
	private static final Pattern	PATTERN_SSN11				= Pattern.compile(
			"^(\\d{4})\\s?(\\d{2})\\s?(\\d{2})\\s?(\\d{2})(\\d)$",Pattern.CASE_INSENSITIVE);
	
	public static final Pattern		PATTERN_SEARCH_WHITESPACES	= Pattern.compile("\\s");
	
	public static final Pattern		PATTERN_STARTS_WITH_8_DIGITS= Pattern.compile("\\d{8}.*");
	
	public static final Pattern		PATTERN_EMPTY_SSN			= Pattern.compile("[0\\s]*");
	
	private static int[]	tableINV							= 	{ 0, 4, 3, 2, 1, 5, 6, 7, 8, 9 };
	
	private static int[][]	tableD								= { { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 },
																	{ 1, 2, 3, 4, 0, 6, 7, 8, 9, 5 },
																	{ 2, 3, 4, 0, 1, 7, 8, 9, 5, 6 },
																	{ 3, 4, 0, 1, 2, 8, 9, 5, 6, 7 },
																	{ 4, 0, 1, 2, 3, 9, 5, 6, 7, 8 },
																	{ 5, 9, 8, 7, 6, 0, 4, 3, 2, 1 },
																	{ 6, 5, 9, 8, 7, 1, 0, 4, 3, 2 },
																	{ 7, 6, 5, 9, 8, 2, 1, 0, 4, 3 },
																	{ 8, 7, 6, 5, 9, 3, 2, 1, 0, 4 },
																	{ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 } };
	
	private static int[][]	tableP								= { { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 },
																	{ 1, 5, 7, 6, 2, 8, 3, 0, 9, 4 },
																	{ 5, 8, 0, 3, 7, 9, 6, 1, 4, 2 },
																	{ 8, 9, 1, 6, 0, 4, 3, 5, 2, 7 },
																	{ 9, 4, 5, 3, 1, 2, 6, 8, 7, 0 },
																	{ 4, 2, 8, 6, 5, 7, 3, 9, 0, 1 },
																	{ 2, 7, 9, 3, 8, 0, 6, 4, 1, 5 },
																	{ 7, 0, 4, 6, 9, 1, 3, 2, 5, 8 } };
	
	
	private static final int[]		WEIGHTING					= { 5,4,3,2,7,6,5,4,3,2 };
	
	
	
	/* ======================================== */
	// STATIC METHODS
	/* ======================================== */
	
	public static boolean isValidSSN (String ssn)
	{
		if (ssn == null) return false;
		
		int digits = SSNChecker.PATTERN_SEARCH_WHITESPACES.matcher(ssn).replaceAll("").length();
		if (ssn == null || !PATTERN_SSN.matcher(ssn).matches())
			return false;
		else if (digits == 11)
			return SSNChecker.isValidSSN11(ssn);
		else if (digits == 13)
			return SSNChecker.isValidSSN13(ssn);
		else 
			return false;
	}
	
	
	public static String getCompactSSN (String ssn)
	{
		if (ssn == null)
			return ssn;
		else
		{
			Matcher	matcher	= PATTERN_SEARCH_WHITESPACES.matcher(ssn);
			return matcher.replaceAll("");
		}
	}
	
	
	public static String getFormattedSSN (String ssn, boolean fillUp)
	{
		if (ssn != null)
		{
			Matcher matcher = PATTERN_SSN.matcher(ssn);
			String	group;
			
			
			if (matcher.matches())
			{
				int groups = matcher.groupCount();
				StringBuilder ssnBuilder = new StringBuilder(18);
				
				int i;
				for (i = 1; i <= groups; i++)
				{
					group = matcher.group(i);
					if (group == null)
						break;
					ssnBuilder.append(group);
					if (i < groups)
						ssnBuilder.append(" ");
				}
				
				if (fillUp)
					ssnBuilder.append(FILL_UP_SSN.substring(ssnBuilder.length()));
				
				ssn	= ssnBuilder.toString();
			}
		}
		
		if (ssn == null)
		{
			if (fillUp)
				ssn = EMPTY_SSN_FORMATTED;
			else
				ssn = "";
		}
		return ssn;
	}
	
	
	/**
	 * Takes the first 11-digits of the given matricule, appends the 
	 * valid checksum and returns this result.
	 * 
	 * @param ssn The SSN to get the 13-digits-SSN from.
	 * @return A valid 13-digits-SSN.
	 * @throws InvalidParameterException If matricule is <code>null</code>
	 * 	or has less than 11 digits.
	 */
	public static String get13DigitsSSN (String ssn, boolean formatted)
	{
		if (ssn != null)
			// remove all whitespace characters
			ssn	= PATTERN_SEARCH_WHITESPACES.matcher(ssn).replaceAll("");
		
		Matcher matcher = PATTERN_SSN13.matcher(ssn);
		int groups = matcher.groupCount();
		if (matcher.matches() && groups >= 4)
		{
			StringBuilder builder = new StringBuilder(11);
			String	group;
			for (int i = 1; i <= 4; i++)
			{
				group = matcher.group(i);
				if (group == null)
					return ssn;
				
				builder.append(group);
			}
			String	root				= builder.toString();
			char	luhnChecksum		= (char) ('0' + computeLuhnChecksum(root));
			char	verhoeffChecksum	= (char) ('0' + computeVerhoeffChecksum(root));
			
			if (formatted)
			{
				builder = new StringBuilder(17);
				for (int i = 1; i <= groups-2; i++)
				{
					group = matcher.group(i);
					if (group == null)
						return ssn;
					
					builder.append(group);
//					if (i < groups)
						builder.append(" ");
				}
			}
			else
			{
				builder = new StringBuilder(13).append(root);
			}
			
			return builder.append(luhnChecksum)
					.append(verhoeffChecksum)
					.toString();
		}
		else
		{
			return ssn;
		}
	}
	
	
	
	/* ======================================== */
	// HELP METHODS
	/* ======================================== */
	
	private static boolean isValidSSN11 (String ssn)
	{
		Matcher matcher;
		String	ssnRoot;
		int		ssnChecksum;
		int		calculatedChecksum;
		
		
		if (ssn == null)
			return false;
		
		matcher		= PATTERN_SSN11.matcher(ssn.trim());
		
		if (matcher.matches() && matcher.groupCount() == 5)
		{
			ssnRoot = matcher.group(1) + matcher.group(2) + matcher.group(3) + matcher.group(4);
			calculatedChecksum = buildChecksum(ssnRoot);
			ssnChecksum = Integer.parseInt(matcher.group(5));
			if ((calculatedChecksum >= 0) && (calculatedChecksum == ssnChecksum))
				return true;
		}
		
		return false;
	}
	
	
	private static int buildChecksum (String ssn)
	{
		int index = 0;
		int product = 0;
		int digit = 0;
		int modulo = 0;
		int checksum = -1;
		
		
		try
		{
			product = 0;
			
			//================================================================
			//= Step 1 consists in multiplying every single digit of the first
			//= digits with a dedicated weighting factor. This step makes sure
			//= to catch erronous inversion of digits in number.
			//================================================================
			
			for (index = 0; index < WEIGHTING.length; index++)
			{
				digit = Character.getNumericValue(ssn.charAt(index));
				product += WEIGHTING[index] * digit;
			}
			
			//================================================================
			//= Step 2 determine the rest of a modulo 11 operation
			//================================================================
			
			modulo = product % 11;
			
			//================================================================
			//= Step 3 If Rest of Modulo operation is 0, then checksum is 0 to.
			//= If Rest is 1, then we surely have an error in number as running
			//= number, i.e. position 9 and 10 of Social Security Number are
			//= always chosen so that 1 should never occur. If Rest of Modulo
			//= operation is greater than 1, then checksum is 11 minus rest of
			//= modulo operation.
			//================================================================
			
			if (modulo == 0)
				return 0;
			
			if (modulo == 1)
				return -1;
			
			checksum = 11 - modulo;
		}
		catch (NumberFormatException p_Exception)
		{
			checksum = -1;
		}
		
		return checksum;
	}
	
	
	/**
	 * @param ssn The SSN to check.
	 * @return <code>true</code> if it is a valid 13-digits-SSN, 
	 * 	else <code>false</code> (even if matriule is <code>null</code>).
	 */
	private static boolean isValidSSN13 (String ssn)
	{
		if (ssn == null) 
			return false;
		
		// remove all whitespace characters
		ssn	= PATTERN_SEARCH_WHITESPACES.matcher(ssn).replaceAll("");
		
		if (ssn.length() != 13)
			return false;
		
		String	matricule11			= ssn.substring(0, 11);
		char	luhnCheckDigit		= (char) ('0' + computeLuhnChecksum(matricule11));
		char	verhoeffCheckDigit	= (char) ('0' + computeVerhoeffChecksum(matricule11));
		
		return ssn.charAt(11) == luhnCheckDigit
				&& ssn.charAt(12) == verhoeffCheckDigit;
	}
	
	
	/* ---------------------------------------- */
	// Luhn Checksum method
	/* ---------------------------------------- */
	
	/**
	 * Computes the checksum C according Luhn algorithm
	 * @param String charset to compute Luhn check digit
	 * @return the check digit
	 */
	private static int computeLuhnChecksum (String iNumber)
	{
		int checkSum = 0;
		int weight = 0;
		int weightedDigit = 0;
		for (int pos = 0; pos < iNumber.length(); pos++)
		{
			weight = (pos % 2 == 0) ? 2 : 1;
			weightedDigit = Character.digit(iNumber.charAt(iNumber.length() - pos - 1), 10) * weight;
			checkSum += (weightedDigit > 9 ? weightedDigit - 9 : weightedDigit);
		}
		return (10 - checkSum % 10) % 10;
	}
	
	
	/* ---------------------------------------- */
	// Verhoeff Checksum method
	/* ---------------------------------------- */
	
	/**
	 * Computes the checksum C as
	 * C = inv(F_n (a_n)×F_(n-1) (a_(n-1) )×… ×F_1 (a_1 ) )
	 * (with × being the multiplication in D_5)
	 * @param String charset to compute Verhoeff check digit
	 * @return the check digit
	 */
	private static int computeVerhoeffChecksum (String iNumber)
	{
		int checksum = 0;
		for (int pos = 0; pos < iNumber.length(); pos++)
		{
			checksum = tableD[checksum][tableP[(pos + 1) % 8][Character.digit(iNumber.charAt(iNumber.length() - pos - 1), 10)]];
		}
		return tableINV[checksum];
	}
	
	
	
	/* ======================================== */
	// MAIN
	/* ======================================== */
	
	public static void main (String[] args)
	{
		String ssn = EMPTY_SSN_FORMATTED;
//		String ssn = FILL_UP_SSN;
		Matcher m = PATTERN_SSN.matcher(ssn);
		
		System.out.println("MATCHES? "+m.matches());
		for (int i = 0; i <= m.groupCount(); i++)
			System.out.println("GROUP "+i+" = '"+m.group(i)+"'");
	}


	/**
	 * Checks, whether or not the first 8 chars are digits 
	 * and whether or not they represent a valid date.<br>
	 * If a valid date is found, this date is returned, 
	 * otherwise <code>null</code> is returned.<br>
	 * <br>
	 * If interpretateFirstDigit is set to <code>true</code> and the first digit 
	 * is higher than 2, the date is calculated with 1 as first digit, if the date is more than
	 * 100 years in the furture or else with 2 as first digit.<br>
	 * <br>
	 * <b>WARNING: If GECAMed is still being used after the <i>year 3000<i>, this needs to
	 * be changed. In this case, please ask Commander Data to do so or just let the Borg 
	 * cope with it.
	 * 
	 * @param ssn The String to check
	 * @param interpretateFirstDigit Flag to indicate, if the first digit might not show the real millennium.
	 * @return The valid Date or <code>null</code>.
	 */
	public static Date getValidDate (String ssn, boolean interpretFirstDigit)
	{
		Calendar	cal;
		
		
		if (!PATTERN_STARTS_WITH_8_DIGITS.matcher(ssn).matches())
			return null;
		
		if (interpretFirstDigit && (ssn.charAt(0) - '0') > 2)
		{
			// interpret the first digit, because the flag is set and the first digit is higher than 2
			Calendar in100Years = new GregorianCalendar();
			in100Years.add(Calendar.YEAR, 100);
			
			// first try the 3rd millennium 
			// (and no I mean the 3rd, because the 1st was 0YYY, the 2nd was 1YYY and the 3rd was 2YYY)
			cal = new GregorianCalendar(
					Integer.parseInt('2' + ssn.substring(1, 4)),
					Integer.parseInt(ssn.substring(4, 6)) - 1,
					Integer.parseInt(ssn.substring(6, 8)));
			if (cal.after(in100Years))
			{
				// looks like it is supposed to be in the last / 2nd millennium
				cal.set(Integer.parseInt('1' + ssn.substring(1, 4)),
						Integer.parseInt(ssn.substring(4, 6)) - 1,
						Integer.parseInt(ssn.substring(6, 8)));
				ssn	= '1' + ssn.substring(1);
			}
			else
			{
				ssn = '2' + ssn.substring(0);
			}
		}
		else
		{
			cal = new GregorianCalendar(
					Integer.parseInt(ssn.substring(0, 4)),
					Integer.parseInt(ssn.substring(4, 6)) - 1,
					Integer.parseInt(ssn.substring(6, 8)));
		}
		
		if (GECAMedUtils.getDateFormatter(GECAMedUtils.DATE_FORMAT_ONESTRING_DATE)
				.format(cal.getTime()).equals(ssn.subSequence(0, 8)))
		{
			// If these 2 Strings are equal, the entered date does exist and is valid
			return cal.getTime();
		}
		else
		{
			/* Something has changed - month or year was increased, because the day 
			 * or month is out of range for this month or year.
			 * Therefore the entered date is not valid.
			 */
			return null;
		}
	}
}
