/*
 * opsu! - an open-source osu! client
 * Copyright (C) 2014-2017 Jeffrey Han
 *
 * opsu! is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * opsu! is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with opsu!.  If not, see <http://www.gnu.org/licenses/>.
 */

package itdelatrisu.opsu;

import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
import itdelatrisu.opsu.beatmap.HitObject;
import itdelatrisu.opsu.downloads.Download;
import itdelatrisu.opsu.downloads.DownloadNode;
import itdelatrisu.opsu.options.Options;
import itdelatrisu.opsu.replay.PlaybackSpeed;
import itdelatrisu.opsu.ui.Colors;
import itdelatrisu.opsu.ui.Fonts;
import itdelatrisu.opsu.ui.NotificationManager.NotificationListener;
import itdelatrisu.opsu.ui.UI;
import itdelatrisu.opsu.user.UserButton;
import itdelatrisu.opsu.user.UserList;

import java.awt.Desktop;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.StringSelection;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Scanner;
import java.util.jar.JarFile;

import javax.imageio.ImageIO;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.GL11;
import org.newdawn.slick.Animation;
import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
import org.newdawn.slick.Input;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.util.Log;

import com.sun.jna.platform.FileUtils;

import net.lingala.zip4j.core.ZipFile;
import net.lingala.zip4j.exception.ZipException;

/**
 * Contains miscellaneous utilities.
 */
public class Utils {
	/**
	 * List of illegal filename characters.
	 * @see #cleanFileName(String, char)
	 */
	private final static int[] illegalChars = {
		34, 60, 62, 124, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
		11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
		24, 25, 26, 27, 28, 29, 30, 31, 58, 42, 63, 92, 47
	};
	static {
		Arrays.sort(illegalChars);
	}

	/** Minimum memory used by the JVM (in bytes) before running "optional" garbage collection. */
	private static final long GC_MEMORY_THRESHOLD = 150 * 1_000_000L;  // 150MB

	/** Baseline memory used by the JVM (in bytes). */
	private static long baselineMemoryUsed = 0;

	// game-related variables
	private static Input input;

	// This class should not be instantiated.
	private Utils() {}

	/**
	 * Initializes game settings and class data.
	 * @param container the game container
	 * @param game the game object
	 */
	public static void init(GameContainer container, StateBasedGame game) {
		input = container.getInput();
		int width = container.getWidth();
		int height = container.getHeight();

		// game settings
		container.setTargetFrameRate(Options.getTargetFPS());
		container.setVSync(Options.getTargetFPS() == 60);
		container.setMusicVolume(Options.getMusicVolume() * Options.getMasterVolume());
		container.setShowFPS(false);
		container.getInput().enableKeyRepeat();
		container.setAlwaysRender(true);
		container.setUpdateOnlyWhenVisible(false);

		// record OpenGL version
		ErrorHandler.setGlString();

		// calculate UI scale
		GameImage.init(width, height);

		// create fonts
		try {
			Fonts.init();
		} catch (Exception e) {
			ErrorHandler.error("Failed to load fonts.", e, true);
		}

		// load skin
		Options.loadSkin();

		// initialize game images
		for (GameImage img : GameImage.values()) {
			if (img.isPreload())
				img.setDefaultImage();
		}

		// initialize game mods
		GameMod.init(width, height);

		// initialize playback buttons
		PlaybackSpeed.init(width, height);

		// initialize hit objects
		HitObject.init(width, height);

		// initialize download nodes
		DownloadNode.init(width, height);

		// initialize UI components
		UI.init(container, game);

		// build user list
		UserList.create();

		// initialize user button
		UserButton.init(width, height);

		// warn about software mode
		if (((Container) container).isSoftwareMode()) {
			UI.getNotificationManager().sendNotification(
				"WARNING:\n" +
				"Running in OpenGL software mode.\n" +
				"You may experience severely degraded performance.\n\n" +
				"This can usually be resolved by updating your graphics drivers.",
				Color.red
			);
		}
	}

	/**
	 * Draws an animation based on its center.
	 * @param anim the animation to draw
	 * @param x the center x coordinate
	 * @param y the center y coordinate
	 */
	public static void drawCentered(Animation anim, float x, float y) {
		anim.draw(x - (anim.getWidth() / 2f), y - (anim.getHeight() / 2f));
	}

	/**
	 * Returns the luminance of a color.
	 * @param c the color
	 */
	public static float getLuminance(Color c) {
		return 0.299f*c.r + 0.587f*c.g + 0.114f*c.b;
	}

	/**
	 * Clamps a value between a lower and upper bound.
	 * @param val the value to clamp
	 * @param low the lower bound
	 * @param high the upper bound
	 * @return the clamped value
	 * @author fluddokt
	 */
	public static int clamp(int val, int low, int high) {
		if (val < low)
			return low;
		if (val > high)
			return high;
		return val;
	}

	/**
	 * Clamps a value between a lower and upper bound.
	 * @param val the value to clamp
	 * @param low the lower bound
	 * @param high the upper bound
	 * @return the clamped value
	 * @author fluddokt
	 */
	public static float clamp(float val, float low, float high) {
		if (val < low)
			return low;
		if (val > high)
			return high;
		return val;
	}

	/**
	 * Clamps a value between a lower and upper bound.
	 * @param val the value to clamp
	 * @param low the lower bound
	 * @param high the upper bound
	 * @return the clamped value
	 */
	public static double clamp(double val, double low, double high) {
		if (val < low)
			return low;
		if (val > high)
			return high;
		return val;
	}

	/**
	 * Returns the distance between two points.
	 * @param x1 the x-component of the first point
	 * @param y1 the y-component of the first point
	 * @param x2 the x-component of the second point
	 * @param y2 the y-component of the second point
	 * @return the Euclidean distance between points (x1,y1) and (x2,y2)
	 */
	public static float distance(float x1, float y1, float x2, float y2) {
		float v1 = Math.abs(x1 - x2);
		float v2 = Math.abs(y1 - y2);
		return (float) Math.sqrt((v1 * v1) + (v2 * v2));
	}

	/**
	 * Linear interpolation of a and b at t.
	 */
	public static float lerp(float a, float b, float t) {
		return a * (1 - t) + b * t;
	}

	/**
	 * Calculates the standard deviation of the numbers in the list.
	 */
	public static double standardDeviation(List<Integer> list) {
		float avg = 0f;
		for (int i : list)
			avg += i;
		avg /= list.size();
		float var = 0f;
		for (int i : list)
			var += (i - avg) * (i - avg);
		var /= list.size();
		return Math.sqrt(var);
	}

	/**
	 * Maps a difficulty value to the given range.
	 * @param difficulty the difficulty value
	 * @param min the min
	 * @param mid the mid
	 * @param max the max
	 * @author peppy (ppy/osu-iPhone:OsuFunctions.m)
	 */
	public static float mapDifficultyRange(float difficulty, float min, float mid, float max) {
		if (difficulty > 5f)
			return mid + (max - mid) * (difficulty - 5f) / 5f;
		else if (difficulty < 5f)
			return mid - (mid - min) * (5f - difficulty) / 5f;
		else
			return mid;
	}

	/**
	 * Returns true if a game input key is pressed (mouse/keyboard left/right).
	 * @return true if pressed
	 */
	public static boolean isGameKeyPressed() {
		boolean mouseDown = !Options.isMouseDisabled() && (
				input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON) ||
				input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON));
		return (mouseDown ||
				input.isKeyDown(Options.getGameKeyLeft()) ||
				input.isKeyDown(Options.getGameKeyRight()));
	}

	/**
	 * Takes a screenshot.
	 * @author http://wiki.lwjgl.org/index.php?title=Taking_Screen_Shots
	 */
	public static void takeScreenShot() {
		// create the screenshot directory
		File dir = Options.getScreenshotDir();
		if (!dir.isDirectory() && !dir.mkdir()) {
			ErrorHandler.error(String.format("Failed to create screenshot directory at '%s'.", dir.getAbsolutePath()), null, false);
			return;
		}

		// create file name
		SimpleDateFormat date = new SimpleDateFormat("yyyyMMdd_HHmmss");
		final File file = new File(dir, String.format("screenshot_%s.%s",
				date.format(new Date()), Options.getScreenshotFormat()));

		SoundController.playSound(SoundEffect.SHUTTER);

		// copy the screen to file
		final int width = Display.getWidth();
		final int height = Display.getHeight();
		final int bpp = 3;  // assuming a 32-bit display with a byte each for red, green, blue, and alpha
		final ByteBuffer buffer = BufferUtils.createByteBuffer(width * height * bpp);
		GL11.glReadBuffer(GL11.GL_FRONT);
		GL11.glPixelStorei(GL11.GL_PACK_ALIGNMENT, 1);
		GL11.glReadPixels(0, 0, width, height, GL11.GL_RGB, GL11.GL_UNSIGNED_BYTE, buffer);
		new Thread() {
			@Override
			public void run() {
				try {
					BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
					for (int x = 0; x < width; x++) {
						for (int y = 0; y < height; y++) {
							int i = (x + (width * y)) * bpp;
							int r = buffer.get(i) & 0xFF;
							int g = buffer.get(i + 1) & 0xFF;
							int b = buffer.get(i + 2) & 0xFF;
							image.setRGB(x, height - (y + 1), (0xFF << 24) | (r << 16) | (g << 8) | b);
						}
					}
					ImageIO.write(image, Options.getScreenshotFormat(), file);
					UI.getNotificationManager().sendNotification(
						String.format("Saved screenshot to %s", file.getAbsolutePath()),
						Colors.PURPLE,
						new NotificationListener() {
							@Override
							public void click() {
								try {
									Utils.openInFileManager(file);
								} catch (IOException e) {
									Log.warn("Failed to open screenshot location.", e);
								}
							}
						}
					);
				} catch (Exception e) {
					ErrorHandler.error("Failed to take a screenshot.", e, true);
				}
			}
		}.start();
	}

	/**
	 * Returns a human-readable representation of a given number of bytes.
	 * @param bytes the number of bytes
	 * @return the string representation
	 * @author aioobe (http://stackoverflow.com/a/3758880)
	 */
	public static String bytesToString(long bytes) {
		if (bytes < 1024)
			return bytes + " B";
		int exp = (int) (Math.log(bytes) / Math.log(1024));
		char pre = "KMGTPE".charAt(exp - 1);
		return String.format("%.1f %cB", bytes / Math.pow(1024, exp), pre);
	}

	/**
	 * Cleans a file name.
	 * @param badFileName the original name string
	 * @param replace the character to replace illegal characters with (or 0 if none)
	 * @return the cleaned file name
	 * @author Sarel Botha (http://stackoverflow.com/a/5626340)
	 */
	public static String cleanFileName(String badFileName, char replace) {
		if (badFileName == null)
			return null;

		boolean doReplace = (replace > 0 && Arrays.binarySearch(illegalChars, replace) < 0);
		StringBuilder cleanName = new StringBuilder();
		for (int i = 0, n = badFileName.length(); i < n; i++) {
			int c = badFileName.charAt(i);
			if (Arrays.binarySearch(illegalChars, c) < 0)
				cleanName.append((char) c);
			else if (doReplace)
				cleanName.append(replace);
		}
		return cleanName.toString();
	}

	/**
	 * Extracts the contents of a ZIP archive to a destination.
	 * @param file the ZIP archive
	 * @param dest the destination directory
	 */
	public static void unzip(File file, File dest) {
		try {
			ZipFile zipFile = new ZipFile(file);
			zipFile.extractAll(dest.getAbsolutePath());
		} catch (ZipException e) {
			ErrorHandler.error(String.format("Failed to unzip file %s to dest %s.",
					file.getAbsolutePath(), dest.getAbsolutePath()), e, false);
		}
	}

	/**
	 * Deletes a file or directory.  If a system trash directory is available,
	 * the file or directory will be moved there instead.
	 * @param file the file or directory to delete
	 * @return true if moved to trash, and false if deleted
	 * @throws IOException if given file does not exist
	 */
	public static boolean deleteToTrash(File file) throws IOException {
		if (file == null)
			throw new IOException("File cannot be null.");
		if (!file.exists())
			throw new IOException(String.format("File '%s' does not exist.", file.getAbsolutePath()));

		// move to system trash, if possible
		FileUtils fileUtils = FileUtils.getInstance();
		if (fileUtils.hasTrash()) {
			try {
				fileUtils.moveToTrash(new File[] { file });
				return true;
			} catch (IOException e) {
				Log.warn(String.format("Failed to move file '%s' to trash.", file.getAbsolutePath()), e);
			}
		}

		// delete otherwise
		if (file.isDirectory())
			deleteDirectory(file);
		else
			file.delete();
		return false;
	}

	/**
	 * Recursively deletes all files and folders in a directory, then
	 * deletes the directory itself.
	 * @param dir the directory to delete
	 */
	public static void deleteDirectory(File dir) {
		if (dir == null || !dir.isDirectory())
			return;

		// recursively delete contents of directory
		File[] files = dir.listFiles();
		if (files != null && files.length > 0) {
			for (File file : files) {
				if (file.isDirectory())
					deleteDirectory(file);
				else
					file.delete();
			}
		}

		// delete the directory
		dir.delete();
	}

	/**
	 * Opens the file manager to the given location.
	 * If the location is a file, it will be highlighted if possible.
	 * @param file the file or directory
	 */
	public static void openInFileManager(File file) throws IOException {
		File f = file;

		// try to highlight the file (platform-specific)
		if (f.isFile()) {
			String osName = System.getProperty("os.name");
			if (osName.startsWith("Win")) {
				// windows: select in Explorer
				Runtime.getRuntime().exec("explorer.exe /select," + f.getAbsolutePath());
				return;
			} else if (osName.startsWith("Mac")) {
				// mac: reveal in Finder
				Runtime.getRuntime().exec("open -R " + f.getAbsolutePath());
				return;
			}
			f = f.getParentFile();
		}

		// open directory using Desktop API
		if (f.isDirectory()) {
			if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN))
				Desktop.getDesktop().open(f);
		}
	}

	/**
	 * Returns a the contents of a URL as a string.
	 * @param url the remote URL
	 * @return the contents as a string, or null if any error occurred
	 * @author Roland Illig (http://stackoverflow.com/a/4308662)
	 * @throws IOException if an I/O exception occurs
	 */
	public static String readDataFromUrl(URL url) throws IOException {
		// open connection
		HttpURLConnection conn = (HttpURLConnection) url.openConnection();
		conn.setConnectTimeout(Download.CONNECTION_TIMEOUT);
		conn.setReadTimeout(Download.READ_TIMEOUT);
		conn.setUseCaches(false);
		conn.setRequestProperty("User-Agent", Options.USER_AGENT);
		try {
			conn.connect();
		} catch (SocketTimeoutException e) {
			Log.warn("Connection to server timed out.", e);
			throw e;
		}

		if (Thread.interrupted())
			return null;

		// read contents
		try (InputStream in = conn.getInputStream()) {
			BufferedReader rd = new BufferedReader(new InputStreamReader(in));
			StringBuilder sb = new StringBuilder();
			int c;
			while ((c = rd.read()) != -1)
				sb.append((char) c);
			return sb.toString();
		} catch (SocketTimeoutException e) {
			Log.warn("Connection to server timed out.", e);
			throw e;
		}
	}

	/**
	 * Returns a JSON object from a URL.
	 * @param url the remote URL
	 * @return the JSON object, or null if an error occurred
	 * @throws IOException if an I/O exception occurs
	 * @throws JSONException if a JSON exception occurs
	 */
	public static JSONObject readJsonObjectFromUrl(URL url) throws IOException, JSONException {
		String s = Utils.readDataFromUrl(url);
		return s == null ? null : new JSONObject(s);
	}

	/**
	 * Returns a JSON array from a URL.
	 * @param url the remote URL
	 * @return the JSON array, or null if an error occurred
	 * @throws IOException if an I/O exception occurs
	 * @throws JSONException if a JSON exception occurs
	 */
	public static JSONArray readJsonArrayFromUrl(URL url) throws IOException, JSONException {
		String s = Utils.readDataFromUrl(url);
		return s == null ? null : new JSONArray(s);
	}

	/**
	 * Converts an input stream to a string.
	 * @param is the input stream
	 * @author Pavel Repin, earcam (http://stackoverflow.com/a/5445161)
	 */
	public static String convertStreamToString(InputStream is) {
		try (Scanner s = new Scanner(is)) {
			return s.useDelimiter("\\A").hasNext() ? s.next() : "";
		}
	}

	/**
	 * Returns the md5 hash of a file in hex form.
	 * @param file the file to hash
	 * @return the md5 hash
	 */
	public static String getMD5(File file) {
		try {
			InputStream in = new BufferedInputStream(new FileInputStream(file));
			MessageDigest md = MessageDigest.getInstance("MD5");
			byte[] buf = new byte[4096];

			while (true) {
				int len = in.read(buf);
				if (len < 0)
					break;
				md.update(buf, 0, len);
			}
			in.close();

			byte[] md5byte = md.digest();
			StringBuilder result = new StringBuilder();
			for (byte b : md5byte)
				result.append(String.format("%02x", b));
			return result.toString();
		} catch (NoSuchAlgorithmException | IOException e) {
			ErrorHandler.error("Failed to calculate MD5 hash.", e, true);
		}
		return null;
	}

	/**
	 * Returns a formatted time string for a given number of seconds.
	 * @param seconds the number of seconds
	 * @return the time as a readable string
	 */
	public static String getTimeString(int seconds) {
		if (seconds < 60)
			return (seconds == 1) ? "1 second" : String.format("%d seconds", seconds);
		else if (seconds < 3600)
			return String.format("%02d:%02d", seconds / 60, seconds % 60);
		else
			return String.format("%02d:%02d:%02d", seconds / 3600, (seconds / 60) % 60, seconds % 60);
	}

	/**
	 * Returns whether or not the application is running within a JAR.
	 * @return true if JAR, false if file
	 */
	public static boolean isJarRunning() {
		return Opsu.class.getResource(String.format("%s.class", Opsu.class.getSimpleName())).toString().startsWith("jar:");
	}

	/**
	 * Returns the JarFile for the application.
	 * @return the JAR file, or null if it could not be determined
	 */
	public static JarFile getJarFile() {
		if (!isJarRunning())
			return null;

		try {
			return new JarFile(new File(Opsu.class.getProtectionDomain().getCodeSource().getLocation().toURI()), false);
		} catch (URISyntaxException | IOException e) {
			Log.error("Could not determine the JAR file.", e);
			return null;
		}
	}

	/**
	 * Returns the directory where the application is being run.
	 * @return the directory, or null if it could not be determined
	 */
	public static File getRunningDirectory() {
		try {
			return new File(Opsu.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath());
		} catch (URISyntaxException e) {
			Log.error("Could not get the running directory.", e);
			return null;
		}
	}

	/**
	 * Returns the current working directory.
	 * @return the directory
	 */
	public static File getWorkingDirectory() {
		return Paths.get(".").toAbsolutePath().normalize().toFile();
	}

	/**
	 * Parses the integer string argument as a boolean:
	 * {@code 1} is {@code true}, and all other values are {@code false}.
	 * @param s the {@code String} containing the boolean representation to be parsed
	 * @return the boolean represented by the string argument
	 */
	public static boolean parseBoolean(String s) {
		return (Integer.parseInt(s) == 1);
	}

	/**
	 * Returns the git hash of the remote-tracking branch 'origin/master' from the
	 * most recent update to the working directory (e.g. fetch or successful push).
	 * @return the 40-character SHA-1 hash, or null if it could not be determined
	 */
	public static String getGitHash() {
		if (isJarRunning())
			return null;
		File f = new File(".git/refs/remotes/origin/master");
		if (!f.isFile())
			return null;
		try (BufferedReader in = new BufferedReader(new FileReader(f))) {
			char[] sha = new char[40];
			if (in.read(sha, 0, sha.length) < sha.length)
				return null;
			for (int i = 0; i < sha.length; i++) {
				if (Character.digit(sha[i], 16) == -1)
					return null;
			}
			return String.valueOf(sha);
		} catch (IOException e) {
			return null;
		}
	}

	/**
	 * Switches validation of SSL certificates on or off by installing a default
	 * all-trusting {@link TrustManager}.
	 * @param enabled whether to validate SSL certificates
	 * @author neu242 (http://stackoverflow.com/a/876785)
	 */
	public static void setSSLCertValidation(boolean enabled) {
		// create a trust manager that does not validate certificate chains
		TrustManager[] trustAllCerts = new TrustManager[]{
			new X509TrustManager() {
				@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
				@Override public void checkClientTrusted(X509Certificate[] certs, String authType) {}
				@Override public void checkServerTrusted(X509Certificate[] certs, String authType) {}
			}
		};

		// install the all-trusting trust manager
		try {
			SSLContext sc = SSLContext.getInstance("SSL");
			sc.init(null, enabled ? null : trustAllCerts, null);
			HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
		} catch (Exception e) {}
	}

	/**
	 * Runs the garbage collector.
	 * @param force if false, garbage collection will only run if current memory
	 *              usage is above a threshold
	 */
	public static void gc(boolean force) {
		if (!force && getUsedMemory() - baselineMemoryUsed < GC_MEMORY_THRESHOLD)
			return;

		System.gc();
		baselineMemoryUsed = getUsedMemory();
	}

	/** Returns the amount memory used by the JVM (in bytes). */
	public static long getUsedMemory() {
		Runtime r = Runtime.getRuntime();
		return r.totalMemory() - r.freeMemory();
	}

	/** Copies the text to the system clipboard. */
	public static void copyToClipboard(String text) {
		StringSelection selection = new StringSelection(text);
		Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
		clipboard.setContents(selection, selection);
	}

	/** Sets the execute permission for the given file. */
	public static void setExecutable(File file) {
		if (!Files.isExecutable(file.toPath())) {
			file.setExecutable(true);
		}
	}
}