package me.despawningbone.discordbot.command.games;

import java.awt.Color;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.text.DateFormat;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import javax.xml.bind.DatatypeConverter;

import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.knowm.xchart.BitmapEncoder;
import org.knowm.xchart.XYChart;
import org.knowm.xchart.XYChartBuilder;
import org.knowm.xchart.BitmapEncoder.BitmapFormat;
import org.knowm.xchart.XYSeries.XYSeriesRenderStyle;
import org.knowm.xchart.style.Styler.LegendPosition;

import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.command.games.Koohii.Accuracy;
import me.despawningbone.discordbot.command.games.Koohii.DiffCalc;
import me.despawningbone.discordbot.command.games.Koohii.Map;
import me.despawningbone.discordbot.command.games.Koohii.PPv2;
import me.despawningbone.discordbot.command.games.Koohii.PlayParameters;
import me.despawningbone.discordbot.command.games.Koohii.MapStats;
import me.despawningbone.discordbot.command.games.Koohii.TaikoPP;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;

public class Osu extends Command {
	
	private static final String osuAPI = DiscordBot.tokens.getProperty("osu");

	private static final DecimalFormat df = new DecimalFormat("#.##");
	
	private static final String[] modes = new String[] {"osu!", "osu!taiko", "osu!catch", "osu!mania"};
	
	public Osu() {  //TODO add back the todos to respective sub command, automate desc for subcmds, add back typing; CLOSE ALL STREAMS
		this.desc = "All the info you need with osu!";
		this.usage = "<subcommands>";
		
		//TODO add a command to parse replays?
	
		registerSubCommand("pp", Arrays.asList("map"), (channel, user, msg, words) -> {
			List<String> amend = new ArrayList<String>(Arrays.asList(words));
			int wParamIndex = amend.indexOf("-w");
			
			//parse weight param and uid; doesnt work if -w is immediately followed by pp params like 100x etc
			String uid = null;
			boolean weight = wParamIndex != -1;
			if(weight) {
				try {
					int wParamLength = 1;
					
					uid = amend.get(wParamIndex + 1);
					if(!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://"))) {  //url thats not user url means its most likely a beatmap, aka no username param
						uid = getPlayer(user);
					} else {
						wParamLength = 2;  //has username
					}
					amend.subList(wParamIndex, wParamIndex + wParamLength).clear();  //remove
				} catch (IndexOutOfBoundsException e) {
					uid = getPlayer(user);
				}
			}
			
			String initmap;
			try {
				initmap = amend.get(0);
			} catch (IndexOutOfBoundsException e) {
				initmap = "null";  //dud
			}
			
			CompletableFuture<String> msgId = new CompletableFuture<>();
			
			//check if no map input, use discord rich presence
			if (!initmap.startsWith("https://") && !initmap.startsWith("http://")) {
				
				//get map name from status
				String details = null;
				try (Connection con = DiscordBot.db.getConnection()) {
					ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";");
					if(rs.next()) {
						details = rs.getString(1).substring(rs.getString(1).indexOf("||"));
					}
					rs.close();
				} catch (SQLException e) {
					return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
				}
				
				channel.sendMessage("Trying to retrieve map from discord status...").queue(m -> msgId.complete(m.getId()));   //DONE custom status masks presence now, will not be as effective; any way to get manually?   //JDA 4 API change fixed this
				channel.sendTyping().queue();
				
				//parse map name and search
				if (details != null) {   //TODO if name is sth like `Feryquitous - (S).0ngs//---::compilation.[TQR-f3] [-[//mission:#FC.0011-excindell.defer.abferibus]-]` it breaks (but reasonable break tbh); also breaks if difficulty has " in it
					try {
						String title = URLEncoder.encode(details.substring(details.indexOf(" - ") + 3, details.lastIndexOf("[")).trim(), "UTF-8");
						String diff = URLEncoder.encode(details.substring(details.lastIndexOf("[") + 1, details.lastIndexOf("]")).trim(), "UTF-8");
						
						String url = "https://osusearch.com/query/?title=" + title + "&diff_name=" + diff + "&query_order=play_count&offset=0";
						URLConnection stream = new URL(url).openConnection();
						stream.addRequestProperty("User-Agent", "Mozilla/4.0");
						JSONTokener tokener = new JSONTokener(stream.getInputStream());
						initmap = "https://osu.ppy.sh/beatmaps/" + new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmap_id");
					} catch (IOException e) {
						return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
					} catch (JSONException e) {
						e.printStackTrace();
						System.out.println(details);
						return new CommandResult(CommandResultType.NORESULT);  //returns if osusearch isnt updated fast enough
					}
				} else {
					return new CommandResult(CommandResultType.FAILURE, "There is no account of your rich presence, therefore I cannot get the beatmap from your status.");
				}
			}
			
			//if still dud aka no presence nor url
			if(initmap.equals("null")) {   //shouldnt throw at all
				return new CommandResult(CommandResultType.FAILURE, "You haven't played any maps I can recognize yet!");
			}
			
			//parse beatmap
			Map beatmap = null;
			try {
				beatmap = new Koohii.Parser()
						.map(new BufferedReader(new InputStreamReader(getMap(initmap), "UTF-8")));
			} catch (IOException e1) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1));
			} catch (UnsupportedOperationException e1) {
				return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported.");
			} catch (IllegalArgumentException e1) {
				return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage());
			}
			
			if (beatmap.title.isEmpty()) {
				return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap.");
			}
			
			//parse pp params
			double dacc = 100;
			int combo = beatmap.max_combo(), mods = 0, miss = 0;
			try {
				for (int i = 0; i < amend.size(); i++) {
					String param = amend.get(i);
					if (param.startsWith("+")) {
						mods = Koohii.mods_from_str(param.substring(1).toUpperCase());
						//invalid mods just get ignored
						
					} else if (param.toLowerCase().endsWith("m")) {
						miss = Integer.parseInt(param.substring(0, param.length() - 1));
						if(miss < 0 || miss > beatmap.objects.size())
							return new CommandResult(CommandResultType.INVALIDARGS, "Invalid miss count specified.");
						
					} else if (param.endsWith("%")) {
						dacc = Double.parseDouble(param.substring(0, param.length() - 1));
						if(dacc < 0)
							return new CommandResult(CommandResultType.INVALIDARGS, "Invalid accuracy specified.");
						
					} else if (param.toLowerCase().endsWith("x")) {
						combo = Integer.parseInt(param.substring(0, param.length() - 1));
						if(combo < 0 || combo > beatmap.max_combo())
							return new CommandResult(CommandResultType.INVALIDARGS, "Invalid combo specified.");
					}
				}
			} catch (NumberFormatException e) {
				return new CommandResult(CommandResultType.INVALIDARGS, "Invalid params specified.");
			}
			
			//compute pp
			Accuracy acc = null;
			JSONObject jbm = null;
			try {
				acc = new Accuracy(dacc, miss, beatmap);
				jbm = computePP(beatmap, initmap.substring(initmap.lastIndexOf("/") + 1).split("&")[0], 
						mods, acc.n50, acc.n100, acc.n300, miss, combo);
			} catch (IOException e) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
			} catch (IllegalArgumentException e) {
				return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage());
			}
			
			//build embed
			EmbedBuilder eb = new EmbedBuilder();
			if(jbm.has("source") && !jbm.getString("source").isEmpty()) eb.setTitle("Source: " + jbm.getString("source"));
			eb.setDescription("Mode: " + modes[beatmap.mode]);
			eb.setAuthor("PP information for " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode),
					initmap, "https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg");
			
			eb.addField("Artist", (beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode), true);
			eb.addField("Created by", beatmap.creator, true);
			
			String totaldur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("total_length") / jbm.getDouble("speed"))));
			String draindur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("hit_length") / jbm.getDouble("speed"))));
			eb.addField("Duration", totaldur + " | Drain " + draindur, true);
			
			eb.addField("Difficulty", beatmap.version, true);
			eb.addField("Mods", (mods == 0 ? "None" : Koohii.mods_str(mods)), true);
			eb.addField("BPM", df.format(jbm.getDouble("bpm") * jbm.getDouble("speed")), true);
			
			eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + "%", true);
			eb.addField("Combo", String.valueOf(combo) + "x", true);
			eb.addField("Misses", String.valueOf(miss), true);
			eb.addField("300", String.valueOf(acc.n300), true);
			eb.addField("100", String.valueOf(acc.n100), true);
			eb.addField("50", String.valueOf(acc.n50), true);
			
			eb.addField("PP", df.format(jbm.getDouble("ppVal")), true);
			
			if(weight) {
				try {
					JSONObject res = getPlayerData("get_user_best", uid, beatmap.mode, user);
					if(res.has("result")) {
						String gain = df.format(weightForPP(jbm.getDouble("ppVal"), jbm.getString("beatmap_id"), res.getJSONArray("result")));
						eb.addField("Actual pp gain for " + uid, (gain.equals("-1") ? "User has a better score than this" : gain + "pp"), true);
					} else {
						channel.sendMessage("Unknown user `" + res.getString("search") + "` specified for pp checking, ignoring...").queue();
					}
				} catch (IOException e) {
					return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
				}
			}
			
			eb.setFooter("Stars: " + df.format(jbm.getDouble("starsVal")) + " | CS: " + df.format(jbm.getDouble("cs")) + " | HP: " + df.format(jbm.getDouble("hp"))
					+ " | AR: " + df.format(jbm.getDouble("ar")) + " | OD: " + df.format(jbm.getDouble("od")), null);
			eb.setColor(new Color(239, 109, 167));
			
			//remove retrieve msg if sent
			if(msgId.isDone()) {
				msgId.thenAccept(m -> channel.deleteMessageById(m).queue());
			}
			
			channel.sendMessageEmbeds(eb.build()).queue();
			return new CommandResult(CommandResultType.SUCCESS);
		}, "[beatmap URL] [+|%|x|m] [-w [username]]",  Arrays.asList("-w", "+DT", "https://osu.ppy.sh/b/1817768 93.43% -w", "https://osu.ppy.sh/beatmaps/1154509 +HDDT 96.05% 147x 2m -w despawningbone"),
				"Check PP information about that beatmap!", Arrays.asList(
				"  * Available parameters: +[mod alias], [accuracy]%, [combo]x, [misses]m",
				"  * specify the `-w` parameter with a username (or none for your own) to check how many actual pp the play will get them!",
				"  * You can also not input the URL, if you are currently playing osu! and discord sensed it."));
		
		
		registerSubCommand("weight", Arrays.asList("w"), (channel, user, msg, words) -> {
			List<String> params = new ArrayList<>(Arrays.asList(words));
			int modeId = getMode(params);
			
			final double precision = 1e-4, iterations = 500;
			
			//get user
			String uid;
			int index = params.size() < 2 ? 0 : 1;
			try {
				uid = params.get(index - 1);
				if(index == 0 || (!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://")))) {
					uid = getPlayer(user);
				}
			} catch (IndexOutOfBoundsException e) {
				uid = getPlayer(user);
			}
			
			//get user top
			JSONArray arr = null;
			try {
				JSONObject res = getPlayerData("get_user_best", uid.trim(), modeId, user);
				if(!res.has("result")) 
					return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
				
				arr = res.getJSONArray("result");
			} catch (IOException e) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
			}
			
			double limit = arr.getJSONObject(0).getDouble("pp") + 100;  //fricc those who wanna get pp over 100pp more than they can lmao
			
			//perform bisection search
			try {
				double targetPP = params.size() > index ? Double.parseDouble(params.get(index)) : 1;
				int i = 0; double minPP = 0, maxPP = limit, mid = 0;
				while(i < iterations && Math.abs(maxPP - minPP) > precision) {
					mid = (maxPP + minPP) / 2;
					double temp = weightForPP(mid, null, arr);
					if(temp > targetPP) {
						maxPP = mid;
					} else {
						minPP = mid;
					}
					i++;
				}
				
				if(!df.format(mid).equals(df.format(limit))) {
					channel.sendMessage("For " + uid + " to actually gain " + df.format(targetPP) + "pp in " + modes[modeId] + ", they have to play a map worth approximately **" + df.format(mid) + "pp** raw.").queue();
					return new CommandResult(CommandResultType.SUCCESS);
				} else {
					return new CommandResult(CommandResultType.INVALIDARGS, "You can't really achieve such large pp jumps without people thinking you are hacking :P");
				}
			} catch (NumberFormatException e) {
				return new CommandResult(CommandResultType.INVALIDARGS, "Invalid target pp specified.");
			}
		}, "[username] [wished pp gain]", Arrays.asList("", "10", "despawningbone 20"),
				"Estimate how much raw pp a map needs to have in order to gain you pp!",
				Arrays.asList("  * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
		
		
		//DONE NEED EXTREME TIDYING UP
		//DONE merge parts of the code with getRecent() and getPP();
		registerSubCommand("recent", Arrays.asList("r"), (channel, user, msg, words) -> {
			List<String> params = new ArrayList<>(Arrays.asList(words));
			int modeId = getMode(params);
			if(modeId > 1) modeId = 0;  //ignore unsupported modes and default to standard
			
			boolean passOnly = params.removeAll(Collections.singleton("-p"));  //no need space, as spaced ones are split (if a player has a name with spaced -p it might be a problem)
			
			//parse params
			String[] split = String.join(" ", params).split("\\|");
			String search = split[0].trim();
			int nrecent = 0;
			try {
				nrecent = Integer.parseInt(split[1].trim()) - 1;
			} catch (NumberFormatException e) {
				//print to channel?
			} catch (ArrayIndexOutOfBoundsException e) {
				;  //nothing special about this
			}
			if(nrecent > 50) {
				return new CommandResult(CommandResultType.FAILURE, "Only the 50 most recent plays can be recalled!");
			}

			//fetch recent plays
			JSONArray array;
			String name;
			try {
				JSONObject res = getPlayerData("get_user_recent", search, modeId, user);
				if(!res.has("result")) 
					return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "` or the player has not been playing in the last 24h.");
				
				array = res.getJSONArray("result");
				
				if(array.length() == 0)
					return new CommandResult(CommandResultType.FAILURE, "You have no recent plays in this 24h!");
				
				//set name according to supported formats
				name = res.getBoolean("isId") ?  //isId might return true on cases inputted as https://osu.ppy.sh/users/despawningbone for example, which would make the fetching redundant but still works 
						getPlayerData("get_user", search, 0, user).getJSONArray("result").getJSONObject(0).getString("username"):
				        res.getString("search");
			} catch (IOException e) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
			}
			
			//get which play to return
			JSONObject mostRecent = null;
			if(passOnly) {
				int times = 0;
				for(int i = 0; i < array.length(); i++) {
					if(!array.getJSONObject(i).getString("rank").equals("F")) {
						times++;
						if(times - 1 >= nrecent) {
							mostRecent = array.getJSONObject(i);
							nrecent = i;
							break;
						}
					}
				}
				if(mostRecent == null) {
					return new CommandResult(CommandResultType.INVALIDARGS, "You did not have this much passed plays in the recent 50 plays!");
				}
			} else {
				try {
					mostRecent = array.getJSONObject(nrecent);
				} catch (JSONException e) {
					return new CommandResult(CommandResultType.FAILURE, "You only played " + array.length() + " times recently!");
				}
			}
			
			String bid = mostRecent.getString("beatmap_id");
			
			//get consecutive tries
			int i;
			for(i = nrecent + 1; i < array.length(); i++) {
				if(!array.getJSONObject(i).getString("beatmap_id").equals(bid)) {
					break;
				}
			}
			//if we reached the end of the list, there might be more that we missed since we can only fetch 50 recent plays
			String more = "";
			if(i == array.length()) {
				more = " (or more)";
			}
			
			//parse map of the recent play and compute pp
			int n50 = mostRecent.getInt("count50"), n100 = mostRecent.getInt("count100"), n300 = mostRecent.getInt("count300"),
					miss = mostRecent.getInt("countmiss"), combo = mostRecent.getInt("maxcombo"), mods = mostRecent.getInt("enabled_mods");
			Map beatmap = null;
			JSONObject jbm = null;
			try {
				beatmap = new Koohii.Parser()
						.map(new BufferedReader(new InputStreamReader(getMap(bid), "UTF-8")));
				jbm = computePP(beatmap, bid, mods, n50, n100, n300, miss, combo);
			} catch (IOException e1) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1));
			} catch (UnsupportedOperationException e1) {
				return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported.");
			} catch (IllegalArgumentException e1) {
				return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage());
			}
			
			//build embed
			String modStr = Koohii.mods_str(mods);
			EmbedBuilder eb = new EmbedBuilder();
			eb.setColor(new Color(0, 0, 0));
			eb.setTitle((beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode)
					+ " - " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode) + " [" + beatmap.version + "]"
					+ (modStr.isEmpty() ? "" : " +" + modStr) + " (" + df.format(jbm.getDouble("starsVal")) + "*)"
					, "https://osu.ppy.sh/beatmaps/" + bid);
			eb.setAuthor(MiscUtils.ordinal(nrecent + 1) + " most recent " + modes[modeId] + " play by " + name,
					"https://osu.ppy.sh/users/" + mostRecent.getString("user_id"), "https://a.ppy.sh/" + mostRecent.getString("user_id"));
			
			eb.setDescription(MiscUtils.ordinal(i - nrecent) + more + " consecutive try\n"
					+ "Ranking: " + mostRecent.getString("rank").replaceAll("H", "+").replaceAll("X", "SS")
					+ (mostRecent.getString("rank").equals("F") ? " (Estimated completion percentage: " +  df.format((n50 + n100 + n300 + miss) * 100.0 / beatmap.objects.size()) + "%)" : ""));
			
			eb.setThumbnail("https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg");
			eb.addField("Score", String.valueOf(mostRecent.getInt("score")), true);
			eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + " (" + n300 + "/" + n100 + "/" + n50 + "/" + miss + ")", true);
			eb.addField("Combo", (mostRecent.getInt("perfect") == 0 ? combo + "x / " + beatmap.max_combo() + "x" : "FC (" + combo + "x)"), true);
			eb.addField("PP", df.format(jbm.getDouble("ppVal")) + "pp / " + df.format(jbm.getDouble("ppMax")) + "pp", true);
			eb.setFooter("Score submitted", null);
			eb.setTimestamp(OffsetDateTime.parse(mostRecent.getString("date").replace(" ", "T") + "+00:00"));
			
			channel.sendMessageEmbeds(eb.build()).queue();
			return new CommandResult(CommandResultType.SUCCESS);
		}, "[-p] [username] [| index]", Arrays.asList("", "-p | 2", "-p despawningbone"),
				"Check a user's recent play!", Arrays.asList(
				"  * Specifying the `-p` parameter will only search for plays that did not fail.",
				"  * Supports `-t` for taiko (Defaults to standard)."));
		
		
		//DONE tidy up first part, merge with getTopPlays()?
		registerSubCommand("ppchart", Arrays.asList("ppc"), (channel, user, msg, words) -> {
			if(words.length < 30) {
				List<String> params = new ArrayList<>(Arrays.asList(words));
				int modeId = getMode(params);
				if(params.size() < 1) {
					params.add(getPlayer(user));
				}
				
				//get all usernames
				String[] split = String.join(" ", params).split("\\|");
				String concName = String.join(" | ", split);
				
				//init chart
				XYChart chart = new XYChartBuilder().width(800).height(600).title("Top PP plays for " + concName + " (" + modes[modeId] + ")").yAxisTitle("PP").xAxisTitle("Plays (100 = top)").build();
				chart.getStyler().setDefaultSeriesRenderStyle(XYSeriesRenderStyle.Scatter);
				chart.getStyler().setLegendPosition(LegendPosition.InsideSE);
				chart.getStyler().setPlotContentSize(0.91);
				chart.getStyler().setMarkerSize(7);
				chart.getStyler().setChartTitlePadding(12);
				chart.getStyler().setChartPadding(12);
				chart.getStyler().setChartBackgroundColor(new Color(200, 220, 230));
				
				//plot each user's top plays onto chart
				for(String name : split) {
					List<Double> pps = new ArrayList<>();
					try {
						JSONObject res = getPlayerData("get_user_best", name.trim(), modeId, user);
						if(!res.has("result")) 
							return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
						
						JSONArray array = res.getJSONArray("result");
						for(int i = 0; i < array.length(); i++) {
							JSONObject obj = array.getJSONObject(i);
							pps.add(obj.getDouble("pp"));
						}
					} catch (IOException e) {
						return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
					}
					
					Collections.reverse(pps);  //so it orders from low to high left to right
					chart.addSeries(name.trim() + "'s PP", pps);
				}
				
				//write to png
				ByteArrayOutputStream os = new ByteArrayOutputStream();
				try {
					BitmapEncoder.saveBitmap(chart, os, BitmapFormat.PNG);
				} catch (IOException e) {
					return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
				}
				channel.sendFile(new ByteArrayInputStream(os.toByteArray()), "ppchart.png").queue();
				return new CommandResult(CommandResultType.SUCCESS);
			} else {
				return new CommandResult(CommandResultType.INVALIDARGS, "Please do not include so many players at once.");
			}
		}, "[mode] [username] [| usernames]...", Arrays.asList("", "taiko", "FlyingTuna | Rafis | despawningbone"),
				"Get a graph with the users' top plays!", Arrays.asList(
				"  * You can specify up to 30 players at once, seperated by `|`."
				+ "  * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
		
		
		//TODO reaction based paging?
		registerSubCommand("set", Arrays.asList("s"), (channel, user, msg, words) -> {
			int page = 1;
			if (words.length < 1) {
				return new CommandResult(CommandResultType.INVALIDARGS, "Please enter search words or an URL.");
			}
			
			String id = "", set = "";
			try {
				new URL(words[0]);
			} catch (MalformedURLException e) {
				//if not url, search
				try {
					String[] split = String.join(" ", words).split("\\|");
					String search = URLEncoder.encode(split[0].trim(), "UTF-8");
					try {
						if(split.length > 1) page = Integer.parseInt(split[1].trim());
					} catch (NumberFormatException e1) {
						return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid page number.");
					}
					String url = "https://osusearch.com/query/?title=" + search + "&query_order=play_count&offset=0";
					URLConnection stream = new URL(url).openConnection();
					stream.addRequestProperty("User-Agent", "Mozilla/4.0");
					JSONTokener tokener = new JSONTokener(stream.getInputStream());
					id = String.valueOf(new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmapset_id"));
					set = "https://osu.ppy.sh/beatmapset/" + id;
				} catch (IOException e1) {
					return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1));
				} catch (JSONException e1) {
					return new CommandResult(CommandResultType.NORESULT);
				}
			}
			
			//empty id means words[0] was a valid url - accept and parse
			if(id.equals("")) {
				set = words[0];
				id = set.substring(set.lastIndexOf("/") + 1);
				if (set.contains("/beatmapsets/")) {
					id = set.substring(set.lastIndexOf("/beatmapsets/"));
					id = id.substring(id.lastIndexOf("/") + 1);
				} else {  //old urls
					if (set.contains("/b/") || set.contains("#")) {
						return new CommandResult(CommandResultType.INVALIDARGS, "This is a specific beatmap, not a beatmap set.");
					}
					id = id.split("&")[0];
				}	
			}
			
			//get beatmap set info
			InputStream stream = null;
			try {
				stream = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&s=" + id).openStream();
			} catch (IOException ex) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex));
			}
			JSONArray arr = new JSONArray(new JSONTokener(stream));
			
			//convert it to a list of object for streaming
			List<JSONObject> obj = new ArrayList<JSONObject>();
			for (int i = 0; i < arr.length(); i++) {
				obj.add(arr.getJSONObject(i));
			}
			//group by mode sorted by difficulty and flatten back to a list
			List<JSONObject> fobj = obj.stream().sorted(Comparator.comparing(e -> e.getDouble("difficultyrating")))
					.collect(Collectors.groupingBy(e -> e.getInt("mode"))).values().stream().flatMap(List::stream)
					.collect(Collectors.toList());
			
			
			EmbedBuilder em = new EmbedBuilder();
			
			JSONObject fo;  //use first object's metadata for universal info like author
			try {
				fo = fobj.get(0);
			} catch (IndexOutOfBoundsException e) {
				return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap URL.");
			}
			
			if(page > Math.ceil((double) fobj.size() / 3)) {  //each page is 3 entries
				return new CommandResult(CommandResultType.INVALIDARGS, "The beatmap set does not have this many difficulties!");
			}
			
			//build embed page heading
			em.setAuthor("Beatmap set information of " + fo.getString("title"), set,
					"https://b.ppy.sh/thumb/" + id + ".jpg");
			em.setDescription("Author: " + fo.getString("artist"));
			em.addField("BPM", fo.getString("bpm"), true);
			em.addField("Creator", fo.getString("creator"), true);
			em.addField("Approved/Ranked", (fo.getInt("approved") >= 1 ? "Yes" : "No"), true);
			em.addBlankField(false);
			
			//build each entry
			int n = ((page - 1) * 3);
			for (int i = n; (n + 3 <= fobj.size() ? i < n + 3 : i < fobj.size()); i++) {
				JSONObject json = fobj.get(i);
				em.addField("Difficulty", "[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")" + " ([Preview](https://jmir.xyz/osu/#" + json.getString("beatmap_id") + "))", false);  //bloodcat is no longer a thing but someone hosted a copy for previewing here
				em.addField("Max combo", json.get("max_combo").toString().replaceAll("null", "N/A"), true);
				em.addField("Stars", df.format(json.getDouble("difficultyrating")), true);
				em.addField("Mode", modes[json.getInt("mode")], true);
			}
			
			//build footer and send
			em.setFooter("Page: " + String.valueOf(page) + "/" + String.valueOf((int) Math.ceil((double) fobj.size() / 3)), null);
			channel.sendMessageEmbeds(em.build()).queue();
			return new CommandResult(CommandResultType.SUCCESS);
		}, "<beatmap set URL/search words> [| page]\n", Arrays.asList("big black", "high free spirits | 2"),
				"Check info about a beatmap set!", Arrays.asList(
				"  * Useful to get the specific difficulty URL for !desp osu pp."));
		
		
		//TODO add userset for setting username to db so getPlayer wont be wrong?
		registerSubCommand("user", Arrays.asList("u"), (channel, user, msg, words) -> {
			List<String> params = new ArrayList<>(Arrays.asList(words));
			int modeId = getMode(params);
			String search = String.join(" ", params);
			
			//fetch user data
			JSONObject usr;
			try {
				JSONObject res = getPlayerData("get_user", search, modeId, user);
				if(!res.has("result")) 
					return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
				
				usr = res.getJSONArray("result").getJSONObject(0);
			} catch (IOException e) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
			}
			
			String id = usr.getString("user_id");  //no matter if its url or not user_id is still gonna be the most accurate one
			
			//build embed
			EmbedBuilder em = new EmbedBuilder();
			em.setAuthor("User info of " + usr.getString("username") + " (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id);
			
			//check if pfp exists, if not fallback to blank
			String img = "https://a.ppy.sh/" + id;
			try {
				new URL(img).openStream();
			} catch (IOException e) {
				img = "https://s.ppy.sh/images/blank.jpg";
			}
			em.setThumbnail(img);
			
			//if user doesnt have pp, there wont be any data pretty much - also might be a result of inactivity (?)
			if(usr.isNull("pp_raw"))
				return new CommandResult(CommandResultType.FAILURE, "This user has not played any map in " + modes[modeId] + " before.");
			
			em.appendDescription("Joined " + usr.getString("join_date"));
			em.addField("PP", usr.getDouble("pp_raw") == 0 ? "No Recent Plays" : usr.getString("pp_raw"), true);
			em.addField("Accuracy", df.format(usr.getDouble("accuracy")), true);
			em.addField("Rank", (usr.getInt("pp_rank") == 0 ? "N/A" : "#" + usr.getString("pp_rank")) + 
					" | " + MiscUtils.countryNameToUnicode(usr.getString("country")) + " #" + usr.getString("pp_country_rank"), true);
			em.addField("Play count", usr.getString("playcount") + 
					" (" + df.format(Long.parseLong(usr.getString("total_seconds_played")) / 3600.0) + "h)", true);
			em.addField("Total score", usr.getString("total_score"), true);
			em.addField("Ranked score", usr.getString("ranked_score") + 
					" (" + df.format((usr.getDouble("ranked_score") / usr.getDouble("total_score")) * 100) + "%)", true);
			em.addField("Level", df.format(usr.getDouble("level")), true);
			em.addField("S+", usr.getString("count_rank_sh"), true);
			em.addField("SS+", usr.getString("count_rank_ssh"), true);
			em.addField("A", usr.getString("count_rank_a"), true);
			em.addField("S", usr.getString("count_rank_s"), true);
			em.addField("SS", usr.getString("count_rank_ss"), true);
			
			em.setColor(new Color(66, 134, 244));
			em.setFooter("300: " + usr.getString("count300") + " | 100: " + usr.getString("count100") + " | 50: " + usr.getString("count50"), null); //TODO display percentage?
			channel.sendMessageEmbeds(em.build()).queue();
			return new CommandResult(CommandResultType.SUCCESS);
		}, "[gamemode] [user URL|keyword]", Arrays.asList("", "despawningbone", "-t despawningbone"),
				"Check info about a user!", Arrays.asList(
				"  * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
		

		//DONE api demanding; need to add cooldown  //nvm i changed it to use XHR now
		//parts mergeable with getRecent() 
		registerSubCommand("topplays", Arrays.asList("top", "t"), (channel, user, msg, words) -> {
			List<String> params = new ArrayList<>(Arrays.asList(words));
			int modeId = getMode(params), page;
			
			//get page number
			String[] split = String.join(" ", params).split("\\|");
			String search = split[0];
			try {
				page = Integer.parseInt(split[1].trim());
				if(page > 10) {
					return new CommandResult(CommandResultType.INVALIDARGS, "You can only request your top 100 plays!");
				}
			} catch (NumberFormatException e) {
				return new CommandResult(CommandResultType.INVALIDARGS, "Invalid page number!");
			} catch (ArrayIndexOutOfBoundsException e) {
				page = 1;  //its normal for people to not input index
			}
			
			//get user id then fetch top plays from XHR req
			JSONArray array;
			String name, id;
			try {
				JSONObject res = getPlayerData("get_user", search, modeId, user);
				if(!res.has("result")) 
					return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
				
				JSONObject usr = res.getJSONArray("result").getJSONObject(0);
				name = usr.getString("username");
				id = usr.getString("user_id");
				
				//top plays internal api use different names for modes than listed
				String[] m = new String[]{"osu", "taiko", "fruits", "mania"};
				
				InputStream stream = new URL("https://osu.ppy.sh/users/" + id + "/scores/best"
						+ "?mode=" + m[modeId] + "&offset=" + (page - 1) * 10 + "&limit=10").openStream();
				
				if (stream.available() < 4) {
					return new CommandResult(CommandResultType.FAILURE, "This player does not have this many plays in this gamemode!");
				}
				
				array = new JSONArray(new JSONTokener(stream));
			} catch (IOException e) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
			}
			
			//build embed
			EmbedBuilder eb = new EmbedBuilder();
			eb.setAuthor(name + "'s top plays (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id);
			DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

			for(int i = 0; i < array.length(); i++) {
				JSONObject obj = array.getJSONObject(i);
				String mods = obj.getJSONArray("mods").join("").replaceAll("\"", "");
				JSONObject stats = obj.getJSONObject("statistics");
				JSONObject bInfo = obj.getJSONObject("beatmap");
				JSONObject bsInfo = obj.getJSONObject("beatmapset");
				int bId = bInfo.getInt("id");
				
				String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "**"
						+ (!mods.isEmpty() ? " **" + mods + "** " : " ")
						+ df.format(bInfo.getDouble("difficulty_rating")) + "*"
						+ " ([map link](https://osu.ppy.sh/beatmaps/" + bId + "))"
						+ "\nScore: " + obj.getInt("score")
						+ "\n**" + (obj.getDouble("pp") + "pp** | Weighted " + df.format(obj.getJSONObject("weight").getDouble("pp")) + "pp (" + df.format(obj.getJSONObject("weight").getDouble("percentage")) + "%)"
						+ "\n" + df.format(obj.getDouble("accuracy") * 100) + "% " + (obj.getBoolean("perfect") ? "FC " + obj.getInt("max_combo") + "x" : obj.getInt("max_combo") + "x") + " (" + stats.getInt("count_300") + "/" + stats.getInt("count_100") + "/" + stats.getInt("count_50") + "/" + stats.getInt("count_miss") + ")") 
						+ "\nPlayed on " + format.format(DatatypeConverter.parseDateTime(obj.getString("created_at")).getTime());
				
				eb.addField(((page - 1) * 10 + (i + 1)) + ". " + bsInfo.getString("artist") + " - " + bsInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false);  //no unicode unfortunately*/
			}
			
			eb.setFooter("Page: " + page, null);
			eb.setColor(new Color(5, 255, 162));
			channel.sendMessageEmbeds(eb.build()).queue();
			return new CommandResult(CommandResultType.SUCCESS);
		}, "[username] [|page]", Arrays.asList("", "| 2", "despawningbone", "-m despawningbone"),
				"Get the top plays of a user!", Arrays.asList(
				"  * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
		

		registerSubCommand("compare", Arrays.asList("c", "comp"), (channel, user, msg, words) -> {
			String map = "", name = "", img = "", mode = "";
			
			//get which card to compare with, starting from newest to oldest
			int index = 1;
			String[] param = String.join(" ", words).split("\\|");
			try {
				if(param.length > 1) index = Integer.parseInt(param[1].trim());	
			} catch(NumberFormatException e) {
				channel.sendMessage("Invalid index specified. Defaulting to the most recent scorecard...").queue();
			}
			
			//iterate through recent 100 messsages
			for(Message card : channel.getHistory().retrievePast(100).complete()) {
				List<MessageEmbed> embeds = card.getEmbeds();
				if(embeds.size() > 0) {
					MessageEmbed embed = embeds.get(0);
					if(embed.getAuthor() == null) continue;
					String author = embed.getAuthor().getName();
					
					//our own cards
					if(card.getAuthor().getId().equals(DiscordBot.BotID)) {
						//recent
						if(author.contains("most recent osu!")) {
							map = embed.getUrl();
							name = embed.getTitle().lastIndexOf("+") == -1 ? embed.getTitle().substring(0, embed.getTitle().lastIndexOf("(")) : embed.getTitle().substring(0, embed.getTitle().lastIndexOf("+"));
							img = embed.getThumbnail().getUrl();
							mode = author.split("most recent osu!")[1].split(" play")[0];
						
						//pp
						} else if(author.startsWith("PP information for")) {
							map = embed.getAuthor().getUrl();
							name = author.split("PP information for ")[1] + " [" +embed.getFields().get(3).getValue() + "]";
							img = embed.getAuthor().getIconUrl();
							mode = embed.getDescription().replace("Mode: osu!", "");
						}
						
					//owobot support
					} else if(card.getAuthor().getId().equals("289066747443675143")) {
						//new score tracker
						if(author.startsWith("New") && author.contains("in osu!")) {
							String markdown = embed.getDescription().substring(7, embed.getDescription().indexOf("\n")).trim();
							map = markdown.substring(markdown.lastIndexOf("(") + 1, markdown.length() - 2);
							name = markdown.substring(0, markdown.indexOf("__**]"));
							img = embed.getThumbnail().getUrl();
							mode = author.substring(author.lastIndexOf("in osu! "), author.length()).trim();
						
						//>rs
						} else if(card.getContentDisplay().contains("Recent ")) {
							map = embed.getAuthor().getUrl();
							name = author.substring(0, author.lastIndexOf("+"));
							img = embed.getThumbnail().getUrl();
							mode = card.getContentDisplay().contains("Mania") ? "mania" : card.getContentDisplay().split("Recent ")[1].split(" ")[0];  //ye fucking blame owobot for being so inconsistent
							
						//map url response
						} else if(card.getContentDisplay().contains("map(s).")) {
							map = embed.getAuthor().getUrl();
							map = map.substring(0, map.length() - (map.endsWith("/") ? 1 : 0));   //fucking hell the url actually changes according to how ppl trigger it
							name = author.split(" – ")[1];
							String[] split = embed.getFields().get(0).getName().split("__", 3);
							name = name.substring(0, name.lastIndexOf(" by ")) + " [" + split[1] + "]";
							img = "https://b.ppy.sh/thumb/" + embed.getDescription().split("\\[map\\]\\(https:\\/\\/osu\\.ppy\\.sh\\/d\\/", 2)[1].split("\\)", 2)[0] + ".jpg";
							mode = split[2].isEmpty() ? "std" : split[2].substring(2, split[2].lastIndexOf("]") + 1);
						}
					}
					
					//if card hit check index and decrement + reset if neded
					if(index > 1 && !map.isEmpty()) {
						index--;
						map = "";
					}
					
					//short circuit if found
					if(!map.isEmpty()) break;
				}
			}
			
			//yet another set of mode names so we have to parse
			int modeId = 0;
			if(mode.equalsIgnoreCase("taiko")) modeId = 1;
			else if(mode.equalsIgnoreCase("catch") || mode.equals("ctb")) modeId = 2;
			else if(mode.equalsIgnoreCase("mania")) modeId = 3;
			
			//if still doesnt exist after iteration, fail
			if(map.isEmpty()) return new CommandResult(CommandResultType.FAILURE, "There are no recent map/score cards to compare with in the past 100 messages!");

			//if name or id is provided in the command use it else fallback to requester
			String uid = param[0].isEmpty() ? getPlayer(user) : param[0].replaceAll(" ", "%20");
			
			//get scores to compare
			try {
				URL url = new URL("https://osu.ppy.sh/api/get_scores?k=" + osuAPI + "&b=" + map.substring(map.lastIndexOf("/") + 1) + "&u=" + uid + "&m=" + modeId); 
				JSONArray arr = new JSONArray(new JSONTokener(url.openStream()));
				
				//build embed
				EmbedBuilder eb = new EmbedBuilder();
				eb.setTitle("Top plays for " + arr.getJSONObject(0).getString("username") + " on " + name);
				eb.setColor(new Color(237, 154, 70));
				eb.setThumbnail(img);
				
				//iterate through existing scores for this beatmap and add it to the embed
				for(int i = 0; i < arr.length(); i++) {
					JSONObject score = arr.getJSONObject(i); 
					String mods = Koohii.mods_str(score.getInt("enabled_mods"));
					Accuracy acc = new Accuracy(score.getInt("count300"), score.getInt("count100"), score.getInt("count50"), score.getInt("countmiss"));
					
					String info = "**`" + (mods.isEmpty() ? "No mods" : mods) + "` Score:\n"
							+ score.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + " " + (score.isNull("pp") ? "No " : score.getDouble("pp")) + "pp** | Score **" + score.getInt("score") + "**\n"
							+ df.format(acc.value(modeId) * 100) + "% " + (score.getInt("perfect") == 1 ? "FC " + score.getInt("maxcombo") + "x" : score.getInt("maxcombo") + "x") + " (" + acc.n300 + "/" + acc.n100 + "/" + acc.n50 + "/" + acc.nmisses + ")\n"
									+ "Played on " + score.getString("date") + (score.getInt("replay_available") == 1 ? " (Replay available)" : "");
					eb.appendDescription(info + "\n\n");
				}
				
				channel.sendMessageEmbeds(eb.build()).queue();
				return new CommandResult(CommandResultType.SUCCESS);
			} catch (IOException e) {
				return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
			} catch (JSONException e) {
				e.printStackTrace();
				return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + uid + "` or the player has no scores on this map.");
			}
		}, "[username] [| index]", null, "Compare your score with the most recent map/score card in the channel!", Arrays.asList("  * Specify the index to skip to the nth recent score card in the channel."));
		
	}
	
	private static double weightForPP(double pp, String ibid, JSONArray userBest) {
		double net = 0, change = -1;
		List<Double> pps = new ArrayList<>();
		
		//get new top pp list, replacing old score if needed
		for(int i = 0; i < userBest.length(); i++) {   //something like a treemap might work more elegantly, but i need to add pp to the list anyways so its fine i guess
			JSONObject obj = userBest.getJSONObject(i);
			double v = obj.getDouble("pp");
			String bid = obj.getString("beatmap_id");
			if(bid.equals(ibid)) {
				if(v >= pp) {
					return -1;
				} else {
					change = v;
				}
			}
			if(v <= pp && !pps.contains(pp)) {  //insert on first smaller occurence
				pps.add(pp);
			}
			pps.add(v);
		}
		
		//doesnt even get on top list - no pp gain
		if(pps.indexOf(pp) == -1) {
			return 0;
		}
		
		//calculate net weighted gain
		if(change == -1) {
			double last = pps.size() > 100 ? pps.remove(100) * (Math.pow(0.95, 99)): 0;
			for(int i = pps.indexOf(pp); i < pps.size(); i++) {
				double c = pps.get(i);
				double w = c*(Math.pow(0.95, i));
				if(i == pps.indexOf(pp)) {
					net += w;
				} else {
					net += c*(Math.pow(0.95, i - 1)) * (0.95 - 1);
				}
			}
			net -= last;  //because the last one is completely not counted, not just shifted to a lower value
		} else {
			int old = pps.indexOf(change) - 1;
			pps.remove(change);
			for(int i = pps.indexOf(pp); i <= old; i++) {
				double c = pps.get(i);
				double w = c*(Math.pow(0.95, i));
				if(c == pp) {
					net += w - change*(Math.pow(0.95, old));	
				} else {
					net += w * (0.95 - 1);
				}
			}
		}	
		return net; 
	}
	
	//note: stripped ids are considered usernames - if you are sure that its an id (eg result from api) then wrap it in an url
	private static JSONObject getPlayerData(String api, String input, int modeId, User requester) throws IOException {
		//check if user url provided, if not assume username
		String id = "";
		boolean noturl = false;
		try {
			new URL(input);
			id = input.substring(input.lastIndexOf("/") + 1);
		} catch (MalformedURLException e) {
			noturl = true;
			id = input;
		}
		
		//if empty fallback to own
		if(id.isEmpty()) {
			id = getPlayer(requester);
		}
		
		//set params and fetch
		String addparam = "";
		if (noturl) {  //search as usernames
			addparam = "&type=string";
		}
		if (modeId != 0) {  //change mode
			addparam += ("&m=" + modeId);
		}
		
		//limit either gets ignored or truncated to highest - is fine if i leave it as 100 (highest of any apis i use)
		JSONArray a = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/" + api + "?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100").openStream()));
		
		//always return the val we did the search with, and only result if it exists
		JSONObject wrap = new JSONObject("{\"search\": \"" + id + "\", \"isId\": " + !noturl + "}");
		if (a.length() > 0) {
			wrap.put("result", a);
		}
		
		return wrap;
	}
	
	//TODO find a way to parallelize map fetching and meta fetching if possible?
	private static JSONObject computePP(Map beatmap, String bid, int mods, int n50, int n100, int n300, int miss, int combo) throws IllegalArgumentException, IOException {
		//set osu-wide beatmap params (non mode specific)
		PlayParameters p = new Koohii.PlayParameters();
		p.beatmap = beatmap;
		p.n50 = n50;
		p.n100 = n100;
		p.n300 = n300;
		p.nmiss = miss;
		p.nobjects = p.n300 + p.n100 + p.n50 + p.nmiss;
		p.mods = mods;
		if (combo > 0) {
			p.combo = combo;
		} else {
			p.combo = p.beatmap.max_combo();
		}
		
		//get beatmap metadata
		String addparam = "";
		if(p.beatmap.mode == 1) {  //these mods alter metadata that taiko needs
				if((mods & Koohii.MODS_HT) != 0) addparam = "&mods=256";
				if((mods & (Koohii.MODS_DT | Koohii.MODS_NC)) != 0) if(addparam.isEmpty()) addparam = "&mods=64";
				else addparam = "";
		}
		URL url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + bid + addparam);
		JSONObject jbm = new JSONArray(new JSONTokener(url.openStream())).getJSONObject(0);
		
		//finally calc pp and put it in metadata
		MapStats stats = new MapStats(p.beatmap);
		switch(p.beatmap.mode) {
		case 0:  //osu
			//std specific params - calc diff and acc
			DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods);
			p.speed_stars = stars.speed;
			p.aim_stars = stars.aim;
			
			PPv2 oPP = new PPv2(p);
			jbm.put("ppVal", oPP.total);
			jbm.put("accVal", oPP.computed_accuracy.value(0));  //use computed
			jbm.put("starsVal", stars.total);
			jbm.put("ppMax", constructPP(stars.aim, stars.speed, p.beatmap, mods).total);
			Koohii.mods_apply(mods, stats, 1|2|4|8);
			break;
		case 1:  //taiko
			//taiko specific params - get diff from metadata since theres no calculating mechanism
			p.speed_stars = jbm.getDouble("difficultyrating");
			
			TaikoPP tPP = new TaikoPP(p);
			jbm.put("ppVal", tPP.pp);
			jbm.put("accVal", tPP.acc);
			jbm.put("starsVal", p.speed_stars);
			jbm.put("ppMax", new TaikoPP(p.speed_stars, p.max_combo, mods, 1, 0, p.beatmap).pp);
			tPP.taiko_mods_apply(mods, stats);
			break;
		default:
			throw new UnsupportedOperationException("This gamemode is not yet supported.");
		}
		
		//mods_apply updates the values, so we cant use beatmap.cs etc and we gotta give back via jbm
		jbm.put("cs", stats.cs);
		jbm.put("hp", stats.hp);
		jbm.put("ar", stats.ar);
		jbm.put("od", stats.od);
		jbm.put("speed", stats.speed);
		
		return jbm;
	}
	
	private static PPv2 constructPP(double aim_stars, double speed_stars, Map b, int mods) {
		PlayParameters p = new PlayParameters();
		p.aim_stars = aim_stars;
		p.speed_stars = speed_stars;
		p.beatmap = b;
		p.nobjects = b.objects.size();
		p.mods = mods;
		return new PPv2(p);
	}
	
	private static String getPlayer(User user) {  //say im getting from presence/name?
		try (Connection con = DiscordBot.db.getConnection()){
			ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";");
			String player = rs.next() ? player = rs.getString(1).substring(0, rs.getString(1).indexOf("||")) : user.getName();
			rs.close();
			return player;  
		} catch (SQLException e) {
			return user.getName();
		}
	}
	
	private static int getMode(List<String> params) {
		int modeId = 0;
		params.replaceAll(String::toLowerCase);  //all searching should be case insensitive anyways, is fine if we change the entire thing
		
		//pass by "ref" so it removes from the list;
		//iterates over all mode params and remove, giving priority according to descending id and defaulting to standard (0)
		if(params.removeAll(Collections.singleton("-t")))
			modeId = 1;
		
		if(params.removeAll(Collections.singleton("-c")))
			modeId = 2;
		
		if(params.removeAll(Collections.singleton("-m")))
			modeId = 3;
		
		return modeId;
	}
	
	private static InputStream getMap(String origURL) throws IOException {
		//get beatmap id
		String id = origURL.substring(origURL.lastIndexOf("/") + 1);
		if (origURL.contains("/beatmapsets/")) {  //new urls with this format must include # to specify beatmap, or else its a set
			if (!origURL.contains("#")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL.");
		} else {  //either /beatmap/ or old url /b/ /s/, latter of which we dont want
			if (origURL.contains("/s/")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL.");
			id = id.split("&")[0];  //remove things like &m=0, which is no need
		}
		
		//fetch map
		URLConnection mapURL;
		try {
			mapURL = new URL("https://osu.ppy.sh/osu/" + id).openConnection();  //no longer has any IOExceptions, always return 200 no matter what
			mapURL.setRequestProperty("User-Agent", "Mozilla/4.0");
			
			InputStream stream = mapURL.getInputStream();
			
			if (stream.available() < 4) throw new IOException();   //to fallback
			
			return stream;
		} catch (IOException e) {  //try fallback regardless of exception reason
			//fallback to bloodcat successor - much more convoluted and slower to fetch a single map now
			//get map info
			mapURL = new URL("https://api.chimu.moe/v1/map/" + id).openConnection();
			mapURL.setRequestProperty("User-Agent", "Mozilla/4.0");
			
			JSONObject mapData = new JSONObject(new JSONTokener(mapURL.getInputStream())).getJSONObject("data");
			
			if(mapData.length() == 0) throw new IOException(e);  //invalid beatmap

			String mapName = mapData.getString("OsuFile").replaceAll("[\\\\/:*?\"<>|]", "");  //apparently the name returned isnt windows safe, so sanitize
			
			//fetch beatmapset
			mapURL = new URL("https://chimu.moe" + mapData.getString("DownloadPath")).openConnection();
			mapURL.setRequestProperty("User-Agent", "Mozilla/4.0");

			ZipInputStream set = new ZipInputStream(mapURL.getInputStream());
			
			//iterate to get correct map
			ZipEntry map = null;
			while((map = set.getNextEntry()) != null)  //assume always have the file specified in map info
				if(map.getName().equals(mapName))
					return set;  //found at input stream current pointer
			
			throw new IOException(e);  //not catching this, if even this doesnt work just throw something went wrong.
		}
	}
	
}
