/**
 * Copyright (c) 2001-2020 Mathew A. Nelson and Robocode contributors
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * https://robocode.sourceforge.io/license/epl-v10.html
 */
package net.sf.robocode.roborumble.netengine;


import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.Properties;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;

import static net.sf.robocode.roborumble.util.PropertiesUtil.getProperties;


/**
 * Utility class for downloading files from the net and copying files.
 * 
 * @author Flemming N. Larsen (original)
 */
public class FileTransfer {

	private final static int DEFAULT_CONNECTION_TIMEOUT = 10000; // 10 seconds
	private final static int DEFAULT_READ_TIMEOUT = 10000; // 10 seconds
	private final static int DEFAULT_SESSION_TIMEOUT = 10000; // 10 seconds

	private static int connectionTimeout = DEFAULT_CONNECTION_TIMEOUT;
	private static int readTimeout = DEFAULT_READ_TIMEOUT;
	private static int sessionTimeout = DEFAULT_SESSION_TIMEOUT;

	static {
		readProperties();
	}
	
	/**
	 * Represents the download status returned when downloading files.
	 */
	public enum DownloadStatus {
		OK, // The download was succesful
		COULD_NOT_CONNECT, // Connection problem
		FILE_NOT_FOUND, // The file to download was not found
	}


	/**
	 * Daemon worker thread containing a 'finish' flag for waiting and notifying when the thread has finished it's job.
	 */
	private static class WorkerThread extends Thread {

		final Object monitor = new Object();

		volatile boolean isFinished;

		public WorkerThread(String name) {
			super(name);
			setDaemon(true);
		}

		void notifyFinish() {
			// Notify that this thread is finish
			synchronized (monitor) {
				isFinished = true;
				monitor.notifyAll();
			}
		}
	}

	/*
	 * Returns a session id for keeping a session on a HTTP site.
	 *
	 * @param url is the url of the HTTP site.
	 *
	 * @return a session id for keeping a session on a HTTP site or null if no session is available.
	 */
	public static String getSessionId(String url) {
		HttpURLConnection conn = null;

		try {
			// Open connection
			conn = FileTransfer.connectToHttpInputConnection(new URL(url));
			if (conn == null) {
				throw new IOException("Could not open connection to '" + url + "'");
			}

			// Get a session id if available
			final GetSessionIdThread sessionIdThread = new GetSessionIdThread(conn);

			sessionIdThread.start();

			// Wait for the session id
			synchronized (sessionIdThread.monitor) {
				while (!sessionIdThread.isFinished) {
					try {
						sessionIdThread.monitor.wait(sessionTimeout);
						sessionIdThread.interrupt();
					} catch (InterruptedException e) {
						// Immediately reasserts the exception by interrupting the caller thread itself
						Thread.currentThread().interrupt();

						return null;
					}
				}
			}

			// Return the session id
			return sessionIdThread.sessionId;

		} catch (final IOException e) {
			return null;
		} finally {
			// Make sure the connection is disconnected.
			// This will cause threads using the connection to throw an exception
			// and thereby terminate if they were hanging.
			if (conn != null) {
				conn.disconnect();
			}
		}
	}

	/**
	 * Worker thread used for getting the session id of an already open HTTP connection.
	 */
	private final static class GetSessionIdThread extends WorkerThread {

		// The resulting session id to read out
		String sessionId;

		final HttpURLConnection conn;

		GetSessionIdThread(HttpURLConnection conn) {
			super("FileTransfer: Get session ID");
			this.conn = conn;
		}

		@Override
		public void run() {
			try {
				// Get the cookie value
				final String cookieVal = conn.getHeaderField("Set-Cookie");

				// Extract the session id from the cookie value
				if (cookieVal != null) {
					sessionId = cookieVal.substring(0, cookieVal.indexOf(";"));
				}
			} catch (final Exception e) {
				sessionId = null;
			}
			// Notify that this thread is finish
			notifyFinish();
		}
	}

	/**
	 * Downloads a file from a HTTP site.
	 * 
	 * @param url is the url of the HTTP site to download the file from.
	 * @param filename is the filename of the destination file.
	 * @param sessionId is an optional session id if the download is session based.
	 * @return the download status, which is DownloadStatus.OK if the download completed successfully; otherwise an
	 * 	       error occurred.
	 */
	public static DownloadStatus download(String url, String filename, String sessionId) {
		HttpURLConnection conn = null;

		try {
			// Create connection
			conn = connectToHttpInputConnection(new URL(url), sessionId);
			if (conn == null) {
				throw new IOException("Could not open connection to: " + url);
			}

			// Begin the download
			final DownloadThread downloadThread = new DownloadThread(conn, filename);

			downloadThread.start();

			// Wait for the download to complete
			synchronized (downloadThread.monitor) {
				while (!downloadThread.isFinished) {
					try {
						downloadThread.monitor.wait();
					} catch (InterruptedException e) {
						return DownloadStatus.COULD_NOT_CONNECT;
					}
				}
			}

			// Return the download status
			return downloadThread.status;

		} catch (final IOException e) {
			return DownloadStatus.COULD_NOT_CONNECT;
		} finally {
			// Make sure the connection is disconnected.
			// This will cause threads using the connection to throw an exception
			// and thereby terminate if they were hanging.
			try {
				if (conn != null) {
					conn.disconnect();
				}
			} catch (Throwable ignore) {// we expect this, right ?
			}
		}
	}

	/**
	 * Worker thread used for downloading a file from an already open HTTP connection.
	 */
	private final static class DownloadThread extends WorkerThread {

		// The download status to be read out
		DownloadStatus status = DownloadStatus.COULD_NOT_CONNECT; // Default error

		final HttpURLConnection conn;
		final String filename;

		InputStream in;
		OutputStream out;

		DownloadThread(HttpURLConnection conn, String filename) {
			super("FileTransfer: Download");
			this.conn = conn;
			this.filename = filename;
		}

		@Override
		public void run() {
			try {
				// Start getting the response code
				final GetResponseCodeThread responseThread = new GetResponseCodeThread(conn);

				responseThread.start();

				// Wait for the response to finish
				synchronized (responseThread.monitor) {
					while (!responseThread.isFinished) {
						try {
							responseThread.monitor.wait(sessionTimeout);
							responseThread.interrupt();
						} catch (InterruptedException e) {
							notifyFinish();
							return;
						}
					}
				}

				final int responseCode = responseThread.responseCode;

				if (responseCode == -1) {
					// Terminate if we did not get the response code
					notifyFinish();
					return;

				} else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
					// Terminate if the HTTP page containing the file was not found
					status = DownloadStatus.FILE_NOT_FOUND;
					notifyFinish();
					return;

				} else if (responseCode != HttpURLConnection.HTTP_OK) {
					// Generally, terminate if did not receive a OK response
					notifyFinish();
					return;
				}

				// Start getting the size of the file to download
				final GetContentLengthThread contentLengthThread = new GetContentLengthThread(conn);

				contentLengthThread.start();

				// Wait for the file size
				synchronized (contentLengthThread.monitor) {
					while (!contentLengthThread.isFinished) {
						try {
							contentLengthThread.monitor.wait(sessionTimeout);
							contentLengthThread.interrupt();
						} catch (InterruptedException e) {
							notifyFinish();
							return;
						}
					}
				}

				final int size = contentLengthThread.contentLength;

				if (size == -1) {
					// Terminate if we did not get the content length
					notifyFinish();
					return;
				}

				// Get the input stream from the connection
				in = getInputStream(conn);

				// Prepare the output stream for the file output
				out = new FileOutputStream(filename);

				// Download the file

				final byte[] buf = new byte[4096];

				int totalRead = 0;
				int bytesRead;

				// Start thread for reading bytes into the buffer

				while (totalRead < size) {
					// Start reading bytes into the buffer
					final ReadInputStreamToBufferThread readThread = new ReadInputStreamToBufferThread(in, buf);

					readThread.start();

					// Wait for the reading to finish
					synchronized (readThread.monitor) {
						while (!readThread.isFinished) {
							try {
								readThread.monitor.wait(sessionTimeout);
								readThread.interrupt();
							} catch (InterruptedException e) {
								notifyFinish();
								return;
							}
						}
					}

					bytesRead = readThread.bytesRead;
					if (bytesRead == -1) {
						// Read completed has completed
						notifyFinish();
						break;
					}

					// Write the byte buffer to the output
					out.write(buf, 0, bytesRead);

					totalRead += bytesRead;
				}

				// If we reached this point, the download was successful
				status = DownloadStatus.OK;

				notifyFinish();

			} catch (final IOException e) {
				status = DownloadStatus.COULD_NOT_CONNECT;
			} finally {
				// Make sure the input stream is closed
				if (in != null) {
					try {
						in.close();
					} catch (final IOException e) {
						e.printStackTrace();
					}
				}
				// Make sure the output stream is closed
				if (out != null) {
					try {
						out.close();
					} catch (final IOException e) {
						e.printStackTrace();
					}
				}
			}
		}
	}


	/**
	 * Worker thread used for getting the response code of an already open HTTP connection.
	 */
	final static class GetResponseCodeThread extends WorkerThread {

		// The response code to read out
		int responseCode;

		final HttpURLConnection conn;

		GetResponseCodeThread(HttpURLConnection conn) {
			super("FileTransfer: Get response code");
			this.conn = conn;
		}

		@Override
		public void run() {
			try {
				// Get the response code
				responseCode = conn.getResponseCode();
			} catch (final Exception e) {
				responseCode = -1;
			}
			// Notify that this thread is finish
			notifyFinish();
		}
	}


	/**
	 * Worker thread used for getting the content length of an already open HTTP connection.
	 */
	final static class GetContentLengthThread extends WorkerThread {

		// The content length to read out
		int contentLength;

		final HttpURLConnection conn;

		GetContentLengthThread(HttpURLConnection conn) {
			super("FileTransfer: Get content length");
			this.conn = conn;
		}

		@Override
		public void run() {
			try {
				// Get the content length
				contentLength = conn.getContentLength();
			} catch (final Exception e) {
				contentLength = -1;
			}
			// Notify that this thread is finish
			notifyFinish();
		}
	}


	/**
	 * Worker thread used for reading bytes from an already open input stream into a byte buffer.
	 */
	final static class ReadInputStreamToBufferThread extends WorkerThread {

		int bytesRead;

		final InputStream in;

		final byte[] buf;

		ReadInputStreamToBufferThread(InputStream in, byte[] buf) {
			super("FileTransfer: Read input stream to buffer");
			this.in = in;
			this.buf = buf;
		}

		@Override
		public void run() {
			try {
				// Read bytes into the buffer
				bytesRead = in.read(buf);
			} catch (final Exception e) {
				bytesRead = -1;
			}
			// Notify that this thread is finish
			notifyFinish();
		}
	}

	/**
	 * Copies a file into another file.
	 *
	 * @param srcFile is the filename of the source file to copy.
	 * @param destFile is the filename of the destination file to copy the file into.
	 * @return true if the file was copied; false otherwise
	 */
	public static boolean copy(String srcFile, String destFile) {
		FileInputStream in = null;
		FileOutputStream out = null;

		try {
			if (srcFile.equals(destFile)) {
				throw new IOException("You cannot copy a file onto itself");
			}
			final byte[] buf = new byte[4096];

			in = new FileInputStream(srcFile);
			out = new FileOutputStream(destFile);

			while (in.available() > 0) {
				out.write(buf, 0, in.read(buf, 0, buf.length));
			}
		} catch (final IOException e) {
			return false;
		} finally {
			if (in != null) {
				try {
					in.close();
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
			if (out != null) {
				try {
					out.close();
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
		}
		return true;
	}

	/**
	 * Opens and connects to a {@link java.net.HttpURLConnection} for input only, and where the connection timeout and
	 * read timeout are controlled by properties.
	 * 
	 * @param url is the URL to open a connection to.
	 * @return a HttpURLConnection intended for reading input only.
	 * @throws IOException if an I/O exception occurs.
	 */
	public static HttpURLConnection connectToHttpInputConnection(URL url) throws IOException {
		return connectToHttpInputConnection(url, null);
	}

	/**
	 * Opens and connects to a {@link java.net.HttpURLConnection} for input only, and where the connection timeout and
	 * read timeout are controlled by properties.
	 * 
	 * @param url is the URL to open a connection to.
	 * @param sessionId is a optional session id.
	 * @return a HttpURLConnection intended for reading input only.
	 * @throws IOException if an I/O exception occurs.
	 */
	public static HttpURLConnection connectToHttpInputConnection(URL url, String sessionId) throws IOException {
		HttpURLConnection conn = (HttpURLConnection) openURLConnection(url, false); // not for output

		conn.setRequestMethod("GET");
		if (sessionId != null) {
			conn.setRequestProperty("Cookie", sessionId);
		}
		conn.connect();
		return conn;
	}

	/**
	 * Opens a {link {@link java.net.URLConnection} for output (and input) where the connection timeout and read timeout
	 * are controlled by properties.
	 *
	 * @param url is the URL to open.
	 * @return a URLConnection for output.
	 * @throws IOException if an I/O exception occurs.
	 */
	public static URLConnection openOutputURLConnection(URL url) throws IOException {
		return openURLConnection(url, true); // for output
	}

	/**
	 * Convenient method used for getting an input stream from an URLConnection.
	 * This method checks if a GZIPInputStream or InflaterInputStream should be used to wrap the input stream from the
	 * URLConnection depending on the content encoding.
	 * 
	 * @param conn is the URLConnection
	 * @return an input stream from the URLConnection, which can be a GZIPInputStream or InflaterInputStream.
	 * @throws IOException if an I/O exception occurs.
	 */
	public static InputStream getInputStream(URLConnection conn) throws IOException {
		// Get input stream
		InputStream in = conn.getInputStream();

		// Get the encoding returned by the server
		String encoding = conn.getContentEncoding();
		
		// Check if we need to use a gzip or inflater input stream depending on the content encoding  
		if ("gzip".equalsIgnoreCase(encoding)) {
			in = new GZIPInputStream(in);
		} else if ("deflate".equalsIgnoreCase(encoding)) {
			in = new InflaterInputStream(in);
		}
		return in;
	}

	/**
	 * Convenient method used for getting an output stream from an URLConnection.
	 * This method checks if a GZIPOutputStream or DeflaterOutputStream should be used to wrap the output stream from
	 * the URLConnection depending on the content encoding.
	 * 
	 * @param conn is the URLConnection
	 * @return an output stream from the URLConnection, which can be a GZIPOutputStream or DeflaterOutputStream.
	 * @throws IOException if an I/O exception occurs.
	 */
	public static OutputStream getOutputStream(URLConnection conn) throws IOException {
		// Get output stream
		OutputStream out = conn.getOutputStream();

		// // Get the encoding returned by the server
		// String encoding = conn.getContentEncoding();
		//
		// // Check if we need to use a gzip or inflater input stream depending on the content encoding  
		// if ("gzip".equalsIgnoreCase(encoding)) {
		// out = new GZIPOutputStream(out);
		// } else if ("deflate".equalsIgnoreCase(encoding)) {
		// out = new DeflaterOutputStream(out);
		// }
		return out;
	}

	/**
	 * Opens a {link {@link java.net.URLConnection} for input and optional output where the connection timeout and read
	 * timeout are controlled by properties.
	 *
	 * @param url is the URL to open.
	 * @param isOutput is a flag specifying if the opened connection is for output.
	 * @return a URLConnection.
	 * @throws IOException if an I/O exception occurs.
	 */
	public static URLConnection openURLConnection(URL url, boolean isOutput) throws IOException {
		URLConnection conn = url.openConnection();

		conn.setDoInput(true);
		conn.setDoOutput(isOutput);

		conn.setConnectTimeout(connectionTimeout);
		conn.setReadTimeout(readTimeout);

		if (!isOutput) {
			// Allow both GZip and Deflate (ZLib) encodings
			conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
			conn.setRequestProperty("User-Agent", "RoboRumble@Home - gzip, deflate");
		}
		return conn;
	}

	/**
	 * Reads the roborumble.properties file and stores property values into global variables.
	 */
	private static void readProperties() {
		Properties props = getProperties("./roborumble/roborumble.properties");

		// Get connection timeout
		String value = props.getProperty("connection.open.timeout");

		if (value != null) {
			try {
				connectionTimeout = Integer.parseInt(value);
			} catch (NumberFormatException ignore) {}
		}

		// Get connection read timeout
		value = props.getProperty("connection.read.timeout");
		if (value != null) {
			try {
				readTimeout = Integer.parseInt(value);
			} catch (NumberFormatException ignore) {}
		}

		// Get download session timeout
		value = props.getProperty("download.session.timeout");
		if (value != null) {
			try {
				sessionTimeout = Integer.parseInt(value);
			} catch (NumberFormatException ignore) {}
		}
	}
}
