package me.despawningbone.discordbot;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.Stack;
import java.util.StringJoiner;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import javax.net.ssl.HttpsURLConnection;
import javax.security.auth.login.LoginException;

import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.PropertyConfigurator;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

//import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import me.despawningbone.discordbot.command.Command;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.entities.TextChannel;
import net.dv8tion.jda.api.requests.GatewayIntent;

public class DiscordBot {
	//TODO move to appropriate locations
	
	public static List<String> ModID = new ArrayList<String>();
	public static List<String> logExcemptID = new ArrayList<String>();

	//TODO synchronize below or change to concurrent  //but why? its thread safe since its read only
	public static HashMap<String, Command> commands = new HashMap<String, Command>();
	public static HashMap<String, Command> aliases = new HashMap<String, Command>();  //TODO temporary solution
	public static TreeMap<String, List<Command>> catCmds = new TreeMap<String, List<Command>>();  //order base on categories?

	public static ConcurrentHashMap<String, TextChannel> lastMusicCmd = new ConcurrentHashMap<String, TextChannel>();  //TODO store in guild configs  //nah its fine
	
	public static final String prefix = "!desp "; //DONE allow guild change prefix?

	public static JDA mainJDA = null;

	public static String BotID;
	public static String OwnerID;
	
	public static Properties tokens = new Properties();
	
	static final Logger logger = LoggerFactory.getLogger(DiscordBot.class);  //package private
	
	public static HikariDataSource db;  

	//DONE SQLite integration; check if program termination will screw up the connection
	//TODO SHARDING
	//DONE on shutdown alert those playing music or await?  //alerted
	public static void main(String[] args) {

		PropertyConfigurator.configure(System.getProperty("user.dir") + File.separator + "log4j.properties");
		
		shuffleCipherSuites();
		
		//init tokens file
		try(FileInputStream in = new FileInputStream(new File(System.getProperty("user.dir") + File.separator + "tokens.properties"))){
			tokens.load(in);	
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		initCmds();
		
		initDB();

		//login
		try {
			JDA jda = JDABuilder.create(tokens.getProperty("bot"), GatewayIntent.getIntents(GatewayIntent.ALL_INTENTS))
					//.setAudioSendFactory(new NativeAudioSendFactory())   //segfaults frequently somehow, disabling
					.addEventListeners(new me.despawningbone.discordbot.EventListener()).build();
			jda.getPresence().setActivity(Activity.watching("Ping me for info!"));
			// for user bots, see
			// https://discordapp.com/developers/docs/topics/gateway#update-status
			// and
			// https://gist.github.com/MrPowerGamerBR/b8acccb9951b0d78a5115024e3ee0d03
			
			//find owner id from bot owner
			BotID = jda.getSelfUser().getId();
			OwnerID = jda.retrieveApplicationInfo().complete().getOwner().getId();
		} catch (LoginException e) {
			e.printStackTrace();
			return;
		}
		
		//initiate logs //handled by log4j?
		File directory = new File(System.getProperty("user.dir") + File.separator + "logs" + File.separator);
		if (!directory.exists()) {
			directory.mkdir();
		}

		//DEPRECATED  //or is it? make unmodifiable instead
		for(String id : tokens.getProperty("botmod").split(",")) {
			ModID.add(id);
		}
		ModID.add(OwnerID);
		
		//TODO put into sql and make guild setting to opt out
		logExcemptID.add(BotID);
	}
	
	private static void initDB() {  //init guild settings when do !desp settings for the first time?  //dont really need coz im doing upsert for all values anyways
		HikariConfig dbConf = new HikariConfig();
		dbConf.setJdbcUrl("jdbc:sqlite:data.db");
        dbConf.setIdleTimeout(45000);
        dbConf.setMaxLifetime(60000);
        dbConf.setMaximumPoolSize(10);
        //dbConf.setMaximumPoolSize(25);  //for deployment in server
        db = new HikariDataSource(dbConf);
        
		try (Connection con = db.getConnection()){
			Statement s = con.createStatement();
			s.execute("CREATE TABLE IF NOT EXISTS settings"
					+ "(id       INTEGER PRIMARY KEY,"  //performance problem using text; integer can handle long anyways
					+ "prefix    TEXT    DEFAULT '!desp ',"
					+ "premium   INTEGER DEFAULT 0,"
					+ "mchannel  TEXT,"  //allow people to set this manually? By default, use last music cmd place
					+ "locale    TEXT    DEFAULT 'EN',"  //or int?  //user specific or guild specific, or user override guild?
					+ "shortcuts TEXT," //use another method?
					+ "votepct   INTEGER DEFAULT 50,"
					+ "looplimit INTEGER DEFAULT 1,"
					+ "volume    INTEGER DEFAULT 100,"  //premium?, is default actually 100?
					+ "helpdm    INTEGER DEFAULT 0);");   //send help to dm or not, excluding cmd help(?) 
					//add logexempt?
			
			//init perms table
			PreparedStatement pragma = con.prepareStatement("SELECT name FROM pragma_table_info('perms_' || ?);");   //NOTE: sqlite does not support changing default values, beware when adding new commands
			for(Entry<String, List<Command>> entry : catCmds.entrySet()) {
				pragma.setString(1, entry.getKey().toLowerCase());
				ResultSet rs = pragma.executeQuery();
				
				//traverse cmd tree to obtain node names
				boolean isEmpty = true;
				HashMap<String, EnumSet<Permission>> nodes = new HashMap<>();
				Stack<Command> tree = new Stack<>();
				for(Command cmd : entry.getValue()) tree.push(cmd);  //populate stack coz there is no root node
				while(!tree.empty()) {
					Command cmd = tree.pop();
					String name = cmd.getName();
					for(Command iterate = cmd.getParent(); iterate != null; iterate = iterate.getParent()) { 
						name = iterate.getName() + "." + name;
					}
					nodes.put(name, cmd.getDefaultPerms());
					
					for(String sub : cmd.getSubCommandNames()) {
						tree.push(cmd.getSubCommand(sub));
					}
				}
				
				while(rs.next()) {
					isEmpty = false;
					nodes.remove(rs.getString("name"));  //remove already added node names
				}
				if(isEmpty) {
					String create = "CREATE TABLE perms_" + entry.getKey().toLowerCase() + " (id INTEGER PRIMARY KEY, _GLOBAL_ TEXT DEFAULT '0\n', ";  //perms for a category are always default no restraints
					StringJoiner join = new StringJoiner(", ");
					for(Entry<String, EnumSet<Permission>> node : nodes.entrySet()) {
						join.add("\"" + node.getKey() + "\" TEXT DEFAULT '" + ((0L << 32) | (Permission.getRaw(node.getValue()) & 0xffffffffL)) + "\n'");  //initialize with no permission deny override, permissions allow  //needs to be text so that channel overrides delimiter can be stored
					}
					create += join.toString() + ");";
					s.execute(create);
					//DONE? create table
				} else {
					if(!nodes.isEmpty()) {  //which means there is new subCmd/cmd
						for(Entry<String, EnumSet<Permission>> node : nodes.entrySet()) {
							s.execute("ALTER TABLE perms_" + entry.getKey().toLowerCase() + " ADD COLUMN \"" + node.getKey() + "\" TEXT DEFAULT '" + ((0L << 32) | (Permission.getRaw(node.getValue()) & 0xffffffffL)) + "\n';");  //not able to prep statement coz its column name
							s.close();
						}
					}
				}
			}
			pragma.close();
			
			//init users table
			
			//DONE TEST generate the nodes, retrieve default from fields? //what about additional commands added, use ALTER TABLE ADD COLUMN DEFAULT? I dont need to sort it according to category at all, i am not and will not be bulk printing permissions anywhere  //actually i might be coz of list edited perms
			//fit subnodes into sub tables, split by category so its more balanced (category as table name)?
			//fields are gonna be sth like <channelid>:<perm>|<perm> etc?
			//DONE store the permissions with long merged; <32bit = deny, >32bit = allow
			s.execute("CREATE TABLE IF NOT EXISTS users"
					+ "(id       INTEGER PRIMARY KEY,"
					+ "reports   TEXT    DEFAULT '',"
					+ "game      TEXT);");   //disposable, i can change it anytime
					//add botmod field? if so, i can deprecate the hard coded list, but it would require querying to this table on perm checks  //i need to query it to check if the user is banned anyways  //subcmd need to query again, so i didnt do it; besides the modlist is gonna be too small to be significant anyways
					//add user specific locale? might be confusing
			s.close();
		} catch (SQLException e1) {
			e1.printStackTrace();
		}
	}

	private static void initCmds() {
		Reflections reflections = new Reflections("me.despawningbone.discordbot.command");
		Set<Class<? extends Command>> classes = reflections.getSubTypesOf(Command.class);
		for (Class<? extends Command> s : classes) {
			try {
				//skip abstract classes
				if (Modifier.isAbstract(s.getModifiers())) {
					continue;
				}
				
				String pkName = s.getPackage().getName();
				String cat = StringUtils.capitalize(pkName.substring(pkName.lastIndexOf(".") + 1));
				Command c = s.getConstructor().newInstance();  //DONE use constructor to put name and cat instead
				//dont skip disabled commands - they should still show up but disabled
				
				//init name and cat
				try {
					Field nameF = Command.class.getDeclaredField("name");
					nameF.setAccessible(true);
					if(nameF.get(c) == null) nameF.set(c, s.getSimpleName().toLowerCase());  //allow overrides
					Field catF = Command.class.getDeclaredField("cat");
					catF.setAccessible(true);
					catF.set(c, cat);
				} catch (NoSuchFieldException e) {   //should never throw
					e.printStackTrace();
				}
				
				//check disable whole command category?  //no non-perm-wise category wide disabling support
				List<Command> cmds = catCmds.get(cat);
				if(cmds == null) {
					cmds = new ArrayList<Command>(Arrays.asList(c));
				} else {
					cmds.add(c);
				}
				catCmds.put(cat, cmds);
				commands.put(c.getName(), c);
				List<String> l = c.getAliases();
				if(l != null) {
					for(int i = 0; i < l.size(); i++) {
						aliases.put(l.get(i), c);
					}	
				}
			} catch (ReflectiveOperationException e) {
				e.printStackTrace();
			}
		}
		
		//sort each category alphabetically
		for(Entry<String, List<Command>> entry : catCmds.entrySet()) {
			Collections.sort(entry.getValue(), (a, b) -> a.getName().compareTo(b.getName()));
		}
	}
	
	//shuffles SSL cipher suites for the default SSL socket factory to avoid fingerprinting used by some sites (e.g. cloudflare-backed sites)
	//current implementation is to disable renegotiation and shuffle, since it seems like at least cloudflare flags renegotiation
	//none of the endpoints in this bot should require mutual authentication, and it seems like major browsers also doesnt include it so should be fine
	//sun.security.ssl.SSLSocketFactoryImpl dependent
	private static void shuffleCipherSuites() {
		try {
			//obtain the SSLContext in use
			Field contextField = HttpsURLConnection.getDefaultSSLSocketFactory().getClass().getDeclaredField("context");
			contextField.setAccessible(true);
			Object context = contextField.get(HttpsURLConnection.getDefaultSSLSocketFactory());
			
			//find clientDefaultCipherSuites/clientDefaultCipherSuiteList, which is ultimately obtained by getDefaultCipherSuites(false)/getDefaultCipherSuiteList(false) called by SSLSocketFactoryImpl
			//recursively find getDefaultCipherSuiteList due to unknown class hierachy
			Class<?> contextClass = context.getClass();
			Field clientDefaultCipherSuitesField = null;
			do {
				for(Field field : contextClass.getDeclaredFields()) {
					if(field.getName().contains("clientDefaultCipherSuite")) {
						clientDefaultCipherSuitesField = field;
						break;
					}
				}
				contextClass = contextClass.getSuperclass();
			} while (clientDefaultCipherSuitesField == null);
			
			clientDefaultCipherSuitesField.setAccessible(true);
			Object cipherSuitesObj = clientDefaultCipherSuitesField.get(context);
			
			//obtain the actual list depending on what type it is
			Collection<?> cipherSuiteList;
			if(cipherSuitesObj.getClass().getSimpleName().equals("CipherSuiteList")) {
				Arrays.asList(cipherSuitesObj.getClass().getDeclaredFields()).forEach(f -> System.out.println(f.getName()));
				Field cipherSuiteCollection = cipherSuitesObj.getClass().getDeclaredField("cipherSuites");
				cipherSuiteCollection.setAccessible(true);
				//CipherSuiteList can contain any type of collections (ArrayList, TreeSet, etc)
				cipherSuiteList = (Collection<?>) cipherSuiteCollection.get(cipherSuitesObj);
				
				//reset name list so it can regenerate after shuffling
				Field suiteNamesField = cipherSuitesObj.getClass().getDeclaredField("suiteNames");
				suiteNamesField.setAccessible(true);
				suiteNamesField.set(cipherSuitesObj, null);
			} else {
				cipherSuiteList = (Collection<?>) cipherSuitesObj;
			}
			
			//remove renegotiation and shuffle
			cipherSuiteList.removeAll(cipherSuiteList.stream().filter(o -> o.toString().contains("TLS_EMPTY_RENEGOTIATION_INFO_SCSV")).collect(Collectors.toList()));
			if(cipherSuiteList instanceof List<?>)
				Collections.shuffle((List<?>) cipherSuiteList);  //only shuffle if it's a list - sets are unordered already
		} catch (Throwable e) {   //methodhandle.invoke throws throwable as checked exception
			logger.warn("Cannot shuffle SSL cipher suites - certain commands might not work due to SSL fingerprinting.", e);
		}
	}
}
