package server;

import java.io.IOException;
import java.rmi.RemoteException;
import java.rmi.registry.*;
import java.rmi.server.UnicastRemoteObject;
import java.util.Vector;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.QueueingConsumer;
import com.rabbitmq.client.ShutdownSignalException;
import com.rabbitmq.client.AMQP.BasicProperties;
import com.rabbitmq.client.AMQP.BasicProperties.Builder;
import connectfour.*;
import dbms.*;
import console.*;
import shared.*;

public class Server extends UnicastRemoteObject implements DBMSListener, Runnable {
	
	private String m_serverQueueName;
	private Vector<User> m_users;
	private Vector<GameSession> m_sessions;
	private int m_registryPort;
	private Registry m_registry;
	private String m_brokerHostName;
	private ConnectionFactory m_factory;
	private Connection m_connection;
	private Channel m_channel;
	private QueueingConsumer m_consumer;
	public static DBMS database;
	public static SystemConsole console;
	public static ExtendedClassLoader classLoader;
	private boolean m_initialized;
	private boolean m_running;
	private boolean m_disconnectHandlerRunning;
	private Thread m_serverThread;
	private Thread m_disconnectHandlerThread;
	public static Server instance;
	final public static String DEFAULT_SERVER_QUEUE_NAME = "Matchmaking Server Queue";
	final public static String DATABASE_REGISTRY_NAME = "ConnectFourDatabase";
	final public static int DEFAULT_REGISTRY_PORT = 1099;
	final public static String DEFAULT_BROKER_HOSTNAME = "nitro404.dyndns.org";
	private static final long serialVersionUID = -206969195865338931L;
	
	public Server() throws RemoteException {
		instance = this;
		console = new SystemConsole();
		classLoader = new ExtendedClassLoader();
		m_users = new Vector<User>();
		m_sessions = new Vector<GameSession>();
		m_initialized = false;
		m_running = false;
	}
	
	// initialize the server using all default parameters
	public boolean initialize() {
		return initialize(DEFAULT_SERVER_QUEUE_NAME, DEFAULT_REGISTRY_PORT, DEFAULT_BROKER_HOSTNAME);
	}
	
	// initialize the server with the specified server name, default the rest of the parameters
	public boolean initialize(String serverName) {
		return initialize(serverName, DEFAULT_REGISTRY_PORT, DEFAULT_BROKER_HOSTNAME);
	}
	
	// initialize the server with the specified server name and registry port, default the broker host name
	public boolean initialize(String serverName, int registrtPort) {
		return initialize(serverName, registrtPort, DEFAULT_BROKER_HOSTNAME);
	}
	
	// intiialize the server with the specified parameters
	public boolean initialize(String serverQueueName, int registryPort, String brokerHostName) {
		// validate and store the server queue name
		if(serverQueueName == null || serverQueueName.trim().length() == 0) { m_serverQueueName = DEFAULT_SERVER_QUEUE_NAME; }
		else { m_serverQueueName = serverQueueName.trim(); }
		
		// validate and store the broker host name
		if(brokerHostName == null || brokerHostName.trim().length() == 0) { m_brokerHostName = DEFAULT_BROKER_HOSTNAME; }
		else { m_brokerHostName = brokerHostName.trim(); }
		
		// validate and store the RMI registry port
		m_registryPort = (registryPort < 1 || registryPort > 65535) ? DEFAULT_REGISTRY_PORT : registryPort;
		
		// locate the RMI registry
		try {
			m_registry = LocateRegistry.getRegistry(m_registryPort);
		}
		catch(Exception e) {
			System.err.println("Error locating registry: " + e.getMessage());
			return false;
		}
		
		// lookup the remote database object using the RMI registry
		try {
			database = (DBMS) m_registry.lookup(DATABASE_REGISTRY_NAME);
		}
		catch(Exception e) {
			System.err.println("Error retrieving database: " + e.getMessage());
			return false;
		}
		
		// add the matchmaking server as a database listener
		try {
			database.addListener(this);
		}
		catch(RemoteException e) {
			console.writeLine("Failed to add server as database listener: " + e.getMessage());
		}
		
		// initialize RabbitMQ connection and queues
		try {
			m_factory = new ConnectionFactory();
			m_factory.setHost(m_brokerHostName);
			m_connection = m_factory.newConnection();
			m_channel = m_connection.createChannel();
			m_channel.queueDeclare(m_serverQueueName, false, false, false, null);
			m_consumer = new QueueingConsumer(m_channel);
			m_channel.basicConsume(m_serverQueueName, true, m_consumer);
		}
		catch(IOException e) {
			System.err.println("Error initializing messaging service: " + e.getMessage());
			return false;
		}
		
		// set matchmaking server as initialized
		m_initialized = true;
		
		// create and initialize and start the main server thread
		m_serverThread = new Thread(this);
		m_serverThread.start();
		
		// create and initialize and start the disconnect handling thread
		m_disconnectHandlerThread = new Thread(new Runnable() {
			public void run() {
				if(!m_initialized) { return; }
				
				m_disconnectHandlerRunning = true;
				
				// indefinitely check for disconnected clients and finished game sessions and handle them
				while(m_disconnectHandlerRunning) {
					checkForDisconnects();
					
					try { Thread.sleep(500L); }
					catch(InterruptedException e) { }
				}
			}
		});
		m_disconnectHandlerThread.start();
		
		return true;
	}
	
	// return the number of connected users
	public int numberOfUsers() {
		return m_users.size();
	}
	
	// get a specific connected user
	public User getUser(int index) {
		if(index < 0 || index >= m_users.size()) { return null; }
		
		return m_users.elementAt(index);
	}
	
	// get a user by their queue name
	public User getUserByQueueName(String queueName) {
		if(queueName == null) { return null; }
		
		for(int i=0;i<m_users.size();i++) {
			if(queueName.equals(m_users.elementAt(i).getQueueName())) {
				return m_users.elementAt(i);
			}
		}
		return null;
	}
	
	// get a user by their name
	public User getUserByName(String userName) {
		if(userName == null) { return null; }
		
		for(int i=0;i<m_users.size();i++) {
			if(m_users.elementAt(i).getUserName() != null && userName.equals(m_users.elementAt(i).getUserName())) {
				return m_users.elementAt(i);
			}
		}
		return null;
	}
	
	// remove a user at a specified index
	public synchronized boolean removeUser(int index) {
		if(index < 0 || index >= m_users.size()) { return false; }
		
		// get the user's name
		String userName = m_users.elementAt(index).getUserName();
		
		// verify that the user is connected
		if(userName != null) {
			GameSession s = null;
			// iterate over all game sessions
			for(int i=0;i<m_sessions.size();i++) {
				s = m_sessions.elementAt(i);
				// if the user is in the current game sessions
				if(s.contains(userName)) {
					// remove the player from the session
					if(s.playerLeft(userName)) {
						// if the session is full, notify the other player that their opponent has left the session
						if(s.isFull()) {
							// generate the player left message
							Message playerLeft = new Message("Player Left");
							playerLeft.setAttribute("User Name", userName);
							
							// send it to the player's opponent
							sendMessageToClient(playerLeft, s.getOpponentPlayerQueueName(userName));
							
							console.writeLine("Removing player \"" + userName + "\" from session #" + s.getID());
						}
					}
				}
			}
		}
		
		// remove the user
		m_users.remove(index);
		
		return true;
	}
	
	// remove a user by their queue name
	public synchronized boolean removeUserByQueueName(String queueName) {
		if(queueName == null) { return false; }
		
		for(int i=0;i<m_users.size();i++) {
			if(queueName.equals(m_users.elementAt(i).getQueueName())) {
				return removeUser(i);
			}
		}
		return false;
	}
	
	// remove a user by their name
	public synchronized boolean removeUserByName(String userName) {
		if(userName == null) { return false; }
		
		for(int i=0;i<m_users.size();i++) {
			if(m_users.elementAt(i).getUserName() != null && userName.equals(m_users.elementAt(i).getUserName())) {
				return removeUser(i);
			}
		}
		return false;
	}
	
	// remove the specified user
	public synchronized boolean removeUser(User u) {
		if(u == null) { return false; }
		
		return removeUser(m_users.indexOf(u));
	}
	
	// return the number of active game sessions
	public int numberOfSessions() {
		return m_sessions.size();
	}
	
	// return a the game session at the specified index
	public GameSession getSession(int index) {
		if(index < 0 || index >= m_sessions.size()) { return null; }
		
		return m_sessions.elementAt(index);
	}
	
	// remove the specified game session
	public synchronized boolean removeSession(GameSession session) {
		if(session == null || !session.isFinished()) { return false; }
		
		// remove the session
		boolean sessionRemoved = m_sessions.remove(session);
		
		// if the session was removed, display a message in the console indicating as such
		// and stop the session
		if(sessionRemoved) {
			console.writeLine("Removing session #" + session.getID());
			session.stop();
		}
		
		return sessionRemoved;
	}
	
	// send a message to the specified client
	public boolean sendMessageToClient(Message message, String clientQueueName) {
		if(message == null || clientQueueName == null) { return false; }
		
		// build the RabbitMQ message and send it
		try {
			Builder builder = new AMQP.BasicProperties.Builder();
			BasicProperties properties = builder.contentType("text/plain").replyTo(m_serverQueueName).build();
			m_channel.basicPublish("", clientQueueName, properties, Message.serializeMessage(message));
		}
		catch(Exception e) {
			Server.console.writeLine("Error sending message to client \"" + clientQueueName + "\": " + e.getMessage());
			return false;
		}
		
		return true;
	}
	
	// handle incoming messages from users
	public void handleMessage(QueueingConsumer.Delivery delivery) {
		// extract and verify the message stored in the delivery
		if(delivery == null) { return; }
		Message message = null;
		try { message = Message.deserializeMessage(delivery.getBody()); }
		catch(Exception e) { return; }
		if(message == null) { return; }
		
		// if this is a new user, store them in the connected users list
		if(getUserByQueueName(delivery.getProperties().getReplyTo()) == null) {
			m_users.add(new User(delivery.getProperties().getReplyTo()));
		}
		
		// handle ping messages
		if(message.getType().equalsIgnoreCase("Ping")) {
			// send a pong response to the user
			sendMessageToClient(new Message("Pong"), delivery.getProperties().getReplyTo());
		}
		// handle pong messages
		else if(message.getType().equalsIgnoreCase("Pong")) {
			// update the ping-related variables after a pong is received from the user
			getUserByQueueName(delivery.getProperties().getReplyTo()).pong();
		}
		// handle account creation messages
		else if(message.getType().equalsIgnoreCase("Create Account")) {
			String userName = (String) message.getAttribute("User Name");
			String password = (String) message.getAttribute("Password");
			
			try {
				// if the user account was created, send a reply indicating as such
				if(database.createUser(userName, password)) {
					Message reply = new Message("Account Created");
					reply.setAttribute("User Name", userName);
					sendMessageToClient(reply, delivery.getProperties().getReplyTo());
					
					console.writeLine("Created account for user: \"" + userName + "\"");
				}
				// if the user account was not created, send a reply indicating as such
				else {
					Message reply = new Message("Account Not Created");
					reply.setAttribute("User Name", userName);
					sendMessageToClient(reply, delivery.getProperties().getReplyTo());
					
					console.writeLine("Unable to create account for user: \"" + userName + "\"");
				}
			}
			catch(RemoteException e) {
				console.writeLine("Error creating account for user: \"" + userName + "\"");
			}
		}
		// handle login messages
		else if(message.getType().equalsIgnoreCase("Login")) {
			// get the user's credentials
			String userName = (String) message.getAttribute("User Name");
			String password = (String) message.getAttribute("Password");
			
			try {
				// verify the user's credentials
				if(database.userLogin(userName, password)) {
					// generate a response indicating the user was logged in
					Message reply = new Message("Logged In");
					reply.setAttribute("User Name", userName);
					
					// send the message to the user
					sendMessageToClient(reply, delivery.getProperties().getReplyTo());
					
					// get the user
					getUserByQueueName(delivery.getProperties().getReplyTo()).setUserName(userName);
					
					console.writeLine("User \"" + userName + "\" logged in");
					
					// retrieve the player's stats
					int[] statsData = database.getPlayerStats(userName);
					if(statsData == null) {
						console.writeLine("Failed to retrieve stats for user \"" + userName + "\"");
						return;
					}
					
					// generate a message with the player's stats
					Message stats = new Message("Player Stats");
					stats.setAttribute("User Name", userName);
					stats.setAttribute("Wins", Integer.toString(statsData[0]));
					stats.setAttribute("Losses", Integer.toString(statsData[1]));
					stats.setAttribute("Draws", Integer.toString(statsData[2]));
					
					// send the message to the user
					sendMessageToClient(stats, delivery.getProperties().getReplyTo());
					
					console.writeLine("Sending stats to user \"" + userName + "\"");
				}
				else {
					// generate a response indicating that the user was not logged in
					Message reply = new Message("Not Logged In");
					reply.setAttribute("User Name", userName);
					
					// send the message to the user
					sendMessageToClient(reply, delivery.getProperties().getReplyTo());
					
					console.writeLine("User \"" + userName + "\" failed to log in with valid credentials");
				}
			}
			catch(RemoteException e) {
				console.writeLine("Error authenticating user: " + userName);
			}
		}
		// handle find game messages
		else if(message.getType().equalsIgnoreCase("Find Game")) {
			// get the user name
			String userName = (String) message.getAttribute("User Name");
			
			synchronized(this) {
				console.writeLine("User \"" + userName + "\" is requesting a match");
				
				GameSession session = null;
				
				// obtain the first empty session
				for(int i=0;i<m_sessions.size();i++) {
					if(!m_sessions.elementAt(i).isFull()) {
						session = m_sessions.elementAt(i);
					}
				}
				
				// verify that the user is not already in the session
				boolean sessionWithSelf = false;
				for(int i=0;i<m_sessions.size();i++) {
					if(m_sessions.elementAt(i).contains(userName) && !m_sessions.elementAt(i).isFull()) {
						sessionWithSelf = true;
					}
				}
				
				// if no empty sessions are available or there is only a session with itself available
				if(session == null || sessionWithSelf) {
					// create a new game session and initialize it
					session = new GameSession();
					if(!session.initialize(m_brokerHostName)) {
						console.writeLine("Failed to create session for user \"" + userName + "\"");
						return;
					}
					
					// add the user to the session, and store the session
					session.addPlayer(userName, delivery.getProperties().getReplyTo());
					m_sessions.add(session);
					
					console.writeLine("Created session #" + session.getID() + " for user \"" + userName + "\"");
				}
				// otherwise if another session is waiting for an opponent
				else {
					// add the user to the session
					session.addPlayer(userName, delivery.getProperties().getReplyTo());
					
					console.writeLine("User \"" + userName + "\" added to session #" + session.getID());
					
					// if the session is full, start the session
					if(session.isFull()) {
						console.writeLine("Starting session #" + session.getID());
						
						session.startGame();
					}
				}
			}
		}
		// handle players leaving sessions
		else if(message.getType().equalsIgnoreCase("Left Session")) {
			// get the user name
			String userName = (String) message.getAttribute("User Name");
			
			// iterate over all active game sessions
			GameSession s = null;
			for(int i=0;i<m_sessions.size();i++) {
				s = m_sessions.elementAt(i);
				
				// if the user is in the current game session
				if(s.contains(userName)) {
					// generate a message that the player has left the session
					Message playerLeft = new Message("Player Left");
					playerLeft.setAttribute("User Name", userName);
					
					// send the message to the user
					sendMessageToClient(playerLeft, s.getOpponentPlayerQueueName(userName));
					
					console.writeLine("Player \"" + userName + "\" left session #" + s.getID());
					
					// stop the session and remove it
					s.stop();
					removeSession(s);
					i--;
				}
			}
		}
	}
	
	// handle notifications from the database when stats are updated
	public void statsUpdated(String userName, int wins, int losses, int draws) {
		// get the user's name and verify that they're connected
		User u = getUserByName(userName);
		if(u == null) { return; }
		
		// generate a message with the user's stats
		Message stats = new Message("Player Stats");
		stats.setAttribute("User Name", userName);
		stats.setAttribute("Wins", Integer.toString(wins));
		stats.setAttribute("Losses", Integer.toString(losses));
		stats.setAttribute("Draws", Integer.toString(draws));
		
		// send the message to the user
		sendMessageToClient(stats, u.getQueueName());
		
		console.writeLine("Sending updated stats to user \"" + userName + "\"");
	}
	
	// other messages from database, not used
	public void userLogin(String userName) throws RemoteException { }
	public void userLogout(String userName) throws RemoteException { }
	
	// stop the matchmaking server
	public void stop() {
		// reset initialization variables
		m_initialized = false;
		m_running = false;
		m_disconnectHandlerRunning = false;
		
		// remove listener from database
		try { database.removeListener(this); } catch(RemoteException e) { }
		
		// stop all active game sessions
		for(int i=0;i<m_sessions.size();i++) {
			m_sessions.elementAt(i).stop();
		}
		
		// stop all active threads and close all messaging queues and connections
		try { m_disconnectHandlerThread.interrupt(); } catch(Exception e) { }
		try { m_serverThread.interrupt(); } catch(Exception e) { }
		try { m_channel.close(); } catch(Exception e) { }
		try { m_connection.close(); } catch(Exception e) { }
	}
	
	// check for disconnected users
	public void checkForDisconnects() {
		// iterate over all users
		User u = null;
		for(int i=0;i<m_users.size();i++) {
			u = m_users.elementAt(i);
			
			// if the current user should be pinged, send them a ping message
			if(u.ping()) {
				sendMessageToClient(new Message("Ping"), u.getQueueName());
			}
			
			// check if the current user has timed out
			if(u.checkTimeout()) {
				if(u.getUserName() == null) {
					console.writeLine("Client with queue name \"" + u.getQueueName() + "\" timed out.");
				}
				else {
					console.writeLine("User \"" + u.getUserName() + "\" timed out.");
				}
				
				// remove the user if they have timed out
				removeUser(i);
				i--;
			}
		}
		
		// iterate over all active game sessions
		GameSession s = null;
		for(int i=0;i<m_sessions.size();i++) {
			// if the current game session is finished
			if(m_sessions.elementAt(i).isFinished()) {
				console.writeLine("Removing finished session #" + m_sessions.elementAt(i).getID());
				
				// stop the current game session and remove it
				s = m_sessions.elementAt(i);
				if(m_sessions.contains(s)) {
					m_sessions.remove(i);
					s.stop();
					i--;
				}
			}
		}
	}
	
	// indefinitely listen for messages from users
	public void run() {
		if(!m_initialized) { return; }
		
		m_running = true;
		
		// listen for messages from users and handle them
		while(m_running) {
			try {
				handleMessage(m_consumer.nextDelivery());
			}
			catch(InterruptedException e) {
				stop();
			}
			catch(ShutdownSignalException e) {
				stop();
			}
			catch(Exception e) {
				Server.console.writeLine("Critical error, server shutting down.");
				e.printStackTrace();
				stop();
			}
		}
	}
	
}
