package clientP2P;

import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;
import java.io.IOException;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.nio.file.StandardCopyOption;
import localException.ProtocolError;
import localException.InternalError;
import localException.TransmissionError;
import localException.SizeError;
import localException.VersionError;
import remoteException.EmptyDirectory;
import remoteException.EmptyFile;
import remoteException.VersionRemoteError;
import remoteException.ProtocolRemoteError;
import remoteException.NotFound;
import remoteException.InternalRemoteError;
import protocolP2P.HashAlgorithm;
import protocolP2P.HashResponse;
import protocolP2P.HashRequest;
import protocolP2P.ProtocolP2PPacketUDP;
import protocolP2P.Payload;
import protocolP2P.FilePart;
import protocolP2P.LoadRequest;
import clientP2P.ClientDownloadPartUDP;
import tools.HostItem;
import tools.Logger;
import tools.LogLevel;


/** Class to download file from udp
* @author	Louis Royer
* @author	Flavien Haas
* @author	JS Auge
* @version	1.0
*/
public class ClientDownloadUDP implements Runnable {

	private List<HostItem> hostList;
	private String filename;
	private byte[] hash512;
	private List<ClientDownloadPartUDP> sockList = new ArrayList<ClientDownloadPartUDP>();
	private List<Long> offsetsToAsk = new ArrayList<Long>();
	private List<Long> offsetsPending = new ArrayList<Long>();
	private boolean stop;
	private long size;
	private static final long MAX_PARTIAL_SIZE = 4096;
	private String partsSubdir;
	private String dirStorage;
	private boolean success = false;
	private Logger logger;

	/** Constructor with parameters: filename, list of hosts, parts subdirectory and dirStorage
	 * @param filename	name of file to download
	 * @param hostList	list of servers
	 * @param partsSubdir	directory to store .part files
	 * @param dirStorage	directory to write assembled file
	 */
	public ClientDownloadUDP(String filename, List<HostItem> hostList, String partsSubdir, String dirStorage, Logger logger) {
		this.partsSubdir = partsSubdir;
		this.dirStorage = dirStorage;
		this.filename = filename;
		this.hostList = hostList;
		this.logger = logger;
		this.stop = false;
	}

	/** Asks thread to stop
	 */
	public void setStop() {
		stop = true;
	}

	/** Runnable implementation
	 */
	public void run() {
		try {
			init();
			if (stop) {
				System.err.println("File is smaller than part max size.");
				logger.writeUDP("File is smaller than part max size.", LogLevel.Info);
				hostList.get(0).closeUDPSocket();
			} else {
				System.err.println("File is bigger than part max size.");
				logger.writeUDP("File is bigger than part max size.", LogLevel.Info);
				purgeList();
				initThreads();
				while(!stop) {
					assignTasks();
					checkTasksStatus();

				}
			}
			System.err.println("Reassembling file parts.");
			logger.writeUDP("Reassembling file parts.", LogLevel.Info);
			reassembleFile();
		} catch(InternalError e) {
			System.err.println("Error while downloading file. Aborting.");
			logger.writeUDP("Error while downloading file. Aborting.", LogLevel.Error);
		} finally {
			stopTasks();
		}
	}

	/** Starts threads for each server in hostList.
	 */
	private void initThreads() {
		for(HostItem hostItem: hostList) {
			sockList.add(new ClientDownloadPartUDP(this, filename, hostItem.getUDPSocket(), partsSubdir, logger));
		}
		for(ClientDownloadPartUDP c: sockList) {
			Thread t = new Thread(c);
			t.start();
		}
		System.err.println("Threads initialized");
		logger.writeUDP("Threads initialized", LogLevel.Error);
	}

	/** Remove tasks from failed threads. Update done status.
	 * @throws	InternalError
	 */
	private void checkTasksStatus() throws InternalError {
		try {
			synchronized(this) {
				this.wait();
				List<ClientDownloadPartUDP> sockListCpy = new ArrayList<>(sockList);
				for(ClientDownloadPartUDP c: sockListCpy) {
					if (c.hasFailed() == true) {
						sockList.remove(c);
						offsetsPending.removeAll(c.getFailed());
						offsetsToAsk.addAll(c.getFailed());
					}
					try {
						offsetsPending.removeAll(c.getDone());
					} catch (InterruptedException e) {
						throw new InternalError();
					}
				}
				System.err.println("Task check status: " + offsetsToAsk.size() + " to asks, " + offsetsPending.size() + " pending");
				logger.writeUDP("Task check status: " + offsetsToAsk.size() + " to asks, " + offsetsPending.size() + " pending", LogLevel.Info);
				if (offsetsToAsk.isEmpty() && offsetsPending.isEmpty()) {
					stop = true;
				}
				if (sockList.size() == 0) {
					System.err.println("No thread working");
					logger.writeUDP("No thread working", LogLevel.Error);
					throw new InternalError();
				}
			}
		} catch (InterruptedException e) {
			throw new InternalError();
		}
	}

	/** Assign tasks randomly to threads.
	 * @throws	InternalError
	 */
	private void assignTasks() throws InternalError {
		Random rand = new Random();
		for(long offset : offsetsToAsk) {
			try {
				sockList.get(rand.nextInt(sockList.size())).assignTask(offset);
				offsetsPending.add(offset);
				System.err.println("Assigned task "+ offset);
				logger.writeUDP("Assigned task "+ offset, LogLevel.Info);
			} catch(InterruptedException e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			}
		}
		offsetsToAsk.removeAll(offsetsPending);
	}

	/** Stop threads */
	private void stopTasks() {
		for(ClientDownloadPartUDP c : sockList) {
			try {
				c.setStop();
			} catch (InterruptedException e) {
				logger.writeUDP(e, LogLevel.Error);
			}
		}
	}

	/** Get hashsum from server.
	 * @param hostItem	server to ask hash
	 * @return	hash512sum
	 * @throws	InternalError
	 */
	private byte[] getHashSum512(HostItem hostItem) throws InternalError {
		byte[] hash;
		HashAlgorithm[] hashesAlgo = new HashAlgorithm[1];
		hashesAlgo[0] = HashAlgorithm.SHA512;
		ProtocolP2PPacketUDP d = new ProtocolP2PPacketUDP((Payload) new HashRequest(filename, hashesAlgo));
		try {
			d.sendRequest((Object)hostItem.getUDPSocket());
			try {
				Payload pHash = d.receiveResponse().getPayload();
				assert pHash instanceof HashResponse : "This payload must be instance of HashResponse";
				if (!(pHash instanceof HashResponse)) {
					throw new InternalError();
				} else {
					hash = ((HashResponse)pHash).getHash(HashAlgorithm.SHA512);
				}
			} catch (EmptyDirectory e) {
				logger.writeUDP(e, LogLevel.Error);
				hash = new byte[0];
			} catch (NotFound e) {
				logger.writeUDP(e, LogLevel.Error);
				hash = new byte[0];
				// TODO: use more specific errors
			} catch (EmptyFile e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (ProtocolError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (InternalRemoteError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (VersionRemoteError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (ProtocolRemoteError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (TransmissionError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (VersionError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (SizeError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			}
			return hash;
		} catch (IOException e) {
			logger.writeUDP(e, LogLevel.Error);
			throw new InternalError();
		}
	}

	/** Removes servers not owning the correct file to download from list.
	 * This is done by comparing hash512sum.
	 * @throws	InternalError
	 */
	private void purgeList() throws InternalError {
		List<HostItem> blackList = new ArrayList<HostItem>();
		boolean first = false;
		byte[] hashsum;
		for(HostItem host: hostList) {
			// already have hashsum from 1st server
			if (!first) {
				first = true;
				continue;
			}
			// ask hashsum
			hashsum = getHashSum512(host);
			if (!Arrays.equals(hash512, hashsum)) {
				blackList.add(host);
			}
		}
		// purge list
		for(HostItem host: blackList) {
			hostList.remove(host);
		}
		System.err.println("Host list purge: done");
		logger.writeUDP("Host list purge: done", LogLevel.Info);
	}

	/** Getter for hash512sum
	 * @return	hash512sum
	 */
	public byte[] getHashSum512() {
		return hash512;
	}

	/** Initialize infos about file to download (size, hash512sum, partslist to dl).
	 * Also download first partfile (to get size).
	 * @throws	InternalError
	 */
	private void init() throws InternalError {
		// get size
		setSize();

		// get hashsum from 1st server in list
		hash512 = getHashSum512(hostList.get(0));
		if (hash512.length == 0) {
			System.err.println("Error: no hash512sum support.");
			logger.writeUDP("no hash512sum support.", LogLevel.Error);
			throw new InternalError();
		}

		// Add tasks
		if (!stop) {
			for(long i=MAX_PARTIAL_SIZE; i<size;i+=MAX_PARTIAL_SIZE) {
				offsetsToAsk.add(Long.valueOf(i));
			}
			System.err.println("Adding tasks: done");
			logger.writeUDP("Adding tasks: done", LogLevel.Info);
		}
	}

	/** Set size of file to download. Also download first file part.
	 * @throws InternalError
	 */
	private void setSize() throws InternalError {
		ProtocolP2PPacketUDP d = new ProtocolP2PPacketUDP((Payload) new LoadRequest(filename, 0, MAX_PARTIAL_SIZE));
		try {
			d.sendRequest((Object)hostList.get(0).getUDPSocket());
			try {
				Payload p = d.receiveResponse().getPayload();
				assert p instanceof FilePart : "This payload must be instance of FilePart";
				if (!(p instanceof FilePart)) {
					System.err.println("Error: cannot get size.");
					logger.writeUDP("cannot get size.", LogLevel.Error);
					throw new InternalError();
				} else {
					FilePart fp = (FilePart)p;
					if (!fp.getFilename().equals(filename)) {
						System.err.println("Error: wrong file received: `" + fp.getFilename() + "`");
						logger.writeUDP("wrong file received: `" + fp.getFilename() + "`", LogLevel.Error);
						throw new ProtocolError();
					}
					if (fp.getOffset() == 0) {
						try {
							Files.write(new File(partsSubdir + filename + "_0.part").toPath(), fp.getPartialContent());
						} catch (IOException e) {
							System.err.println("Error: cannot write file (" + partsSubdir + filename + "_0.part)");
							logger.writeUDP("cannot write file (" + partsSubdir + filename + "_0.part)", LogLevel.Error);
						}
						size = fp.getTotalSize();
						if (fp.getPartialContent().length == size) {
							stop = true;
						}
					} else {
						System.err.println("Error: wrong file part received.");
						logger.writeUDP("wrong file part received.", LogLevel.Error);
						throw new InternalError();
					}
				}
			} catch (EmptyDirectory e) {
				System.err.println("Error: empty directory.");
				logger.writeUDP("empty directory.", LogLevel.Error);
				throw new InternalError();
			} catch (EmptyFile e) {
				logger.writeUDP(e, LogLevel.Error);
				// TODO: use more specific errors
				throw new InternalError();
			} catch (ProtocolError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (InternalRemoteError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (VersionRemoteError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (ProtocolRemoteError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (TransmissionError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (VersionError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (SizeError e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			} catch (NotFound e) {
				logger.writeUDP(e, LogLevel.Error);
				throw new InternalError();
			}
		} catch (IOException e) {
			logger.writeUDP(e, LogLevel.Error);
			throw new InternalError();
		}
	}

	/** Success getter.
	 * @return	true when file have successfully been reassembled.
	 */
	public boolean getSuccess() {
		return success;
	}

	/** Reassemble file from file parts.
	 * Set success to true if file is reassembled successfully.
	 */
	private void reassembleFile() {
		boolean firstPart = true;
		boolean abort = false;
		long nextOffset = 0;
		do {
			if (firstPart) {
				System.err.println("Reassembling: First part");
				logger.writeUDP("Reassembling: First part", LogLevel.Info);
				try {
					// create file
					Files.copy(new File(partsSubdir + filename + "_" + nextOffset + ".part").toPath(), new File(dirStorage + filename).toPath(), StandardCopyOption.REPLACE_EXISTING);
					nextOffset = (new File(dirStorage + filename)).length();
					firstPart = false;
				} catch (IOException e) {
					System.err.println("Reassembling: aborting on first part");
					logger.writeUDP("Reassembling: aborting on first part", LogLevel.Warning);
					abort = true;
				}
			} else if (nextOffset >= size) {
				success = true;
				System.err.println("Reassembling: success");
				logger.writeUDP("Reassembling: success", LogLevel.Info);
			} else {
				// append to file
				try {
					Files.write(new File(dirStorage + filename).toPath(), Files.readAllBytes(new File(partsSubdir + filename + "_" + nextOffset + ".part").toPath()), StandardOpenOption.APPEND);
					nextOffset = (new File(dirStorage + filename)).length();
				} catch (IOException e) {
					abort = true;
					System.err.println("Aborting: bad number " + nextOffset);
					logger.writeUDP("Aborting: bad number " + nextOffset, LogLevel.Error);
				}
			}
		} while((!success) && (!abort));
	}
}
