package me.despawningbone.arithtrain;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Iterator;
import java.util.List;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Scanner;
import java.util.TreeMap;


/**
 * The utility class for handling user account storage and operations, including score calculations and leveling.<br>
 * Third-party programs are expected to utilize this class to interact with the user database instead of their own codes.
 * 
 * @author despawningbone
 */
public class User {  //TODO test whole class; make it not reflection accessible and encrypt the database to prevent cheating/password editing?
	
	/**
	 * A map storing the levels in the form of {@link ExpressionGenerator} objects.<br>
	 * This allows modular addition of levels from external sources, namely the configuration.
	 * A TreeMap is utilized due to the strong ordering nature of levels and the ease of setting level thresholds. 
	 */
	public static TreeMap<Double, ExpressionGenerator> levels = new TreeMap<>(); 
	
	/**
	 * A static method for creating a user object upon register.<br>
	 * <i>Instantiating through {@link User#User(String, String)} should be done instead if the user already exists.</i>
	 * @param name the username of the new user
	 * @param pw the password of the new user, see {@link User#makePassword(String, String)}
	 * @param confirm a matching password for confirmation
	 * @return a new user object
	 * @throws IOException if the user database is not accessible
	 */
	public static User register(String name, String pw, String confirm) throws IOException {
		if(name == null || name.isEmpty()) throw new IllegalArgumentException("The username cannot be empty!");
		if(searchData(name) != null) throw new IllegalArgumentException("This username is already taken!"); 
		File pfile = new File(System.getProperty("user.dir") + File.separator + "users.data");
		FileOutputStream out = new FileOutputStream(pfile, true);
		BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
		writer.write(name.replaceAll("\\|", "\\\\|").toLowerCase() + "|"  //so that the delimiter is unique even if people use it in usernames; case insensitive
				+ makePassword(pw, confirm) + "|" //encrypt password with salted MD5; using $ as delimiter because hex strings will not contain it
				+ "0|"    //score
				+ "0\n" );  //answered (to compute accuracy)
		writer.close();
		out.close();
		return new User(name, pw);  //redundant login; but still a good check
	}
	
	/**
	 * Generates a salted and hashed password with MD5 for storage in the database.
	 * <p>
	 * Third-party programs are not expected to utilize this method, but they should access this method through reflection if they have to edit the database manually. 
	 * @param pw the password which should include both upper case and lower case letters, along with at least one number
	 * @param confirm a matching password for confirmation 
	 * @return the hashed string in hexadecimal form
	 */
	private static String makePassword(String pw, String confirm) {
		if(!pw.matches("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).*")) throw new IllegalArgumentException("The password entered is not strong enough!\nIt needs to have at least 1 lower case letter, 1 upper case letter and 1 number.");
		if(pw.equals(confirm)) {
			String salt = generateSalt(8);  //TODO make salt length configurable?
			return salt + "$" + hash(pw + salt);
		} else {
			throw new IllegalArgumentException("Passwords does not match!");
		}
	}

	/**
	 * Compares the hashed password with the password provided. 
	 * @param pw the plaintext password inputted
	 * @param hash the hashed password stored
	 * @return true if identical, false otherwise
	 */
	private static boolean matchPassword(String pw, String hash) {
		String[] split = hash.split("\\$");
		return hash(pw + split[0]).equals(split[1]);
	}
	
	/**
	 * A helper method for applying MD5 hashing on any unicode passwords.
	 * @param pw the password to be hashed
	 * @return the hashed password in hexadecimal form 
	 */
	private static String hash(String pw) {
		try {
			
			return new BigInteger(1, MessageDigest.getInstance("MD5").digest(pw.getBytes("UTF-8"))).toString(16);
		} catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
			e.printStackTrace();  //should not be possible to have an exception here
			return null;
		}
	}
	
	private static final char[] alphanumeric = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
	
	/**
	 * Generates a variable length alphanumeric salt for use in hashing along with {@link User#hash(String)}.
	 * @param saltLength the desired character length of the salt
	 * @return the salt
	 */
	private static String generateSalt(int saltLength) {
		SecureRandom ran = new SecureRandom();
		char[] chars = new char[saltLength];
		for(int i = 0; i < saltLength; i++) {
			chars[i] = alphanumeric[ran.nextInt(62)];
		}
		return new String(chars);
	}
	
	/**
	 * Searches the user database and returns the user data.
	 * @param name the username of the user to be searched
	 * @return a string array containing the user account details
	 * @throws IOException if the user database is not accessible 
	 */
	private static String[] searchData(String name) throws IOException {
		if(name == null) return null;
		name = name.replaceAll("\\|", "\\\\|");
		Scanner scanner = new Scanner(new File(System.getProperty("user.dir") + File.separator + "users.data"), "UTF-8");
		while(scanner.hasNextLine()) {  //linear search; not putting the file in memory with Files.readLines() because it can be really big
			String entry = scanner.nextLine();
			String[] split = entry.split("(?<=[^\\\\])\\|");
			if(split[0].equalsIgnoreCase(name)) {
				scanner.close();
				return split;
			}
		}
		scanner.close();
		return null;
	}
	
	//END STATIC METHODS
	
	private long score;
	private long answered;
	private String name;
	private ExpressionGenerator gen;
	private int level;
	
	/**
	 * Attempts to log a user in, instantiating a {@link User} object on success.
	 * @param name the username of the user to be logged in
	 * @param pw the password of the user to be logged in
	 * @throws IOException if the user database is not accessible
	 */
	public User(String name, String pw) throws IOException {  //perform login
		String[] userData = searchData(name);
		if(userData == null) {
			throw new NoSuchElementException("This user does not exist!");
		}

		if(!matchPassword(pw, userData[1])) throw new IllegalArgumentException("Wrong password!");
		
		this.score = Long.parseLong(userData[2]);
		this.answered = Long.parseLong(userData[3]);
		this.name = name;
		this.gen = levels.floorEntry(score * getAccuracyRaw()).getValue();
		this.level = getLevel();
	}

	/**
	 * Gets the user's name.
	 * @return the username string
	 */
	public String getName() {
		return name;
	}
	
	/**
	 * Gets the display accuracy in percentage form.
	 * @return the display accuracy
	 */
	public double getAccuracy() {
		return getAccuracyRaw() * 100;
	}
	
	/**
	 * Computes the raw accuracy of the user (from 0-1).
	 * @return the raw accuracy
	 */
	public double getAccuracyRaw() {
		return answered == 0 ? 1 : ((double) score / answered);
	}
	
	/**
	 * Returns the correct answer count of the user.
	 * @return the user's score
	 */
	public long getScore() {
		return score;
	}
	
	/**
	 * Returns the {@link ExpressionGenerator} object of the level the user is currently in. 
	 * @return the expression generator
	 */
	public ExpressionGenerator getCurrentGenerator() {
		return gen;
	}
	
	/**
	 * Computes the current percentage from reaching the next level,<br>
	 * and returns infinity if there is no level after the current one.
	 * @return the percentage
	 */
	public double getNextLevelPercent() {
		if(levels.ceilingKey(score * getAccuracyRaw()) != null) {
			return (score * getAccuracyRaw() - levels.floorKey(score * getAccuracyRaw())) / (levels.ceilingKey(score * getAccuracyRaw()) - levels.floorKey(score * getAccuracyRaw()));  //so its the actual range not the whole range			
		} else {
			return Double.POSITIVE_INFINITY;
		}
	}
	
	/**
	 * Saves the user object into the user database.
	 * @throws IOException if the user database is not accessible
	 */
	public void save() throws IOException {  //NOTE: SAVING IS NOT THREAD SAFE!
		editData( this.name.replaceAll("\\|", "\\\\|").toLowerCase() + "|"  //so that the delimiter is unique even if people use it in usernames; case insensitive
				+ searchData(this.name)[1] + "|" //encrypt password with salted MD5; using $ as delimiter because hex strings will not contain it
				+ this.score + "|"    //score
				+ this.answered);  //answered (to compute accuracy)
	}
	
	/**
	 * Updates the user's password.<br>
	 * This function is currently unused in the main arithmetic trainer, however third-party programs are free to utilize this method. 
	 * @param oldPw the original password of the user
	 * @param newPw the new password of the user, see {@link User#makePassword(String, String)}
	 * @param confirm a matching password for confirmation 
	 * @throws IOException if the user database is not accessible
	 */
	public void updatePassword(String oldPw, String newPw, String confirm) throws IOException {
		String[] values = searchData(this.name);  //searchData should never return null because this is a valid user object
		if(matchPassword(oldPw, values[1])) {
			values[1] = makePassword(newPw, confirm);
			editData(String.join("|", values));	
		} else {
			throw new IllegalArgumentException("Wrong password!");
		}
	}
	
	/**
	 * Updates the user's score according to whether he answered correctly or not.
	 * @param success whether the user succeeded in answering the question correctly
	 * @return the level the user should be in after the update, can be higher, lower or unchanged.
	 */
	public int updateScore(boolean success) {  //update score based on success; also updates level if needed
		if(success) score++;
		answered++;
		ExpressionGenerator newGen = levels.floorEntry(score * getAccuracyRaw()).getValue();
		if(newGen != this.gen) {
			this.gen = newGen;
			this.level = getLevel();
		}
		return level;  //tells back if the level changed; it can be lower or higher
	}
	
	/**
	 * An internal method for editing the user database directly, changing the entry to be updated according to the username provided in the update string.
	 * @param update the updated user entry for replacement 
	 * @throws IOException if the user database is not accessible
	 */
	private void editData(String update) throws IOException {  //Files.readLines() is utilized because all methods need to put the file into memory anyways
		Path path = Paths.get(System.getProperty("user.dir") + File.separator + "users.data");
		 List<String> lines = Files.readAllLines(Paths.get(System.getProperty("user.dir") + File.separator + "users.data"), StandardCharsets.UTF_8);
		 for(int i = 0; i < lines.size(); i++) {
			 String[] split = lines.get(i).split("(?<=[^\\\\])\\|");
			 if(split[0].equalsIgnoreCase(this.name.replaceAll("\\|", "\\\\|"))) {
				 lines.set(i, update);
				 Files.write(path, lines, StandardCharsets.UTF_8);
				 return;
			 }
		 }
		 throw new IllegalArgumentException("This user does not exist!");
	}
	
	/**
	 * Gets the current level of the user in integer form.
	 * @return the level
	 */
	public int getLevel() {
		Iterator<Entry<Double, ExpressionGenerator>> iterator = levels.entrySet().iterator();
		int i = 1;
		while(iterator.hasNext()) {
			if(iterator.next().getValue() == this.gen) {
				return i;
			}
			i++;
		}
		throw new IllegalStateException("Invalid level!");  //as score * acc will never go down to 0, and the lowest key is 0, reaching this means something has gone wrong 
	}
	
}
