package jrummikub.control.network;

import java.awt.Color;
import java.io.Serializable;
import java.util.UUID;

import javax.swing.SwingUtilities;

import jrummikub.model.GameSettings;
import jrummikub.model.IHand;
import jrummikub.model.IRoundState;
import jrummikub.model.ITable;
import jrummikub.util.Event;
import jrummikub.util.Event1;
import jrummikub.util.Event2;
import jrummikub.util.Event3;
import jrummikub.util.GameData;
import jrummikub.util.IEvent;
import jrummikub.util.IEvent1;
import jrummikub.util.IEvent2;
import jrummikub.util.IEvent3;
import jrummikub.util.LoginData;
import jrummikub.view.LoginError;

import org.jivesoftware.smack.Connection;
import org.jivesoftware.smack.ConnectionConfiguration;
import org.jivesoftware.smack.PacketListener;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.filter.AndFilter;
import org.jivesoftware.smack.filter.PacketExtensionFilter;
import org.jivesoftware.smack.filter.PacketTypeFilter;
import org.jivesoftware.smack.packet.DefaultPacketExtension;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smack.packet.Packet;
import org.jivesoftware.smack.packet.PacketExtension;
import org.jivesoftware.smack.packet.XMPPError;
import org.jivesoftware.smack.packet.XMPPError.Type;
import org.jivesoftware.smack.util.Base64;
import org.jivesoftware.smack.util.StringUtils;
import org.jivesoftware.smackx.muc.DiscussionHistory;
import org.jivesoftware.smackx.muc.MultiUserChat;

/**
 * Connection control managing network connections, essages and events
 */
public class ConnectionControl implements IConnectionControl {
	private static class TurnEndData implements Serializable {
		private static final long serialVersionUID = -3800572117130220737L;

		private IHand oldHand;
		private ITable oldTable;
		private ITable newTable;

		TurnEndData(IHand oldHand, ITable oldTable, ITable newTable) {
			this.oldHand = oldHand;
			this.oldTable = oldTable;
			this.newTable = newTable;
		}

		IHand getOldHand() {
			return oldHand;
		}

		ITable getOldTable() {
			return oldTable;
		}

		ITable getNewTable() {
			return newTable;
		}
	}

	private final static String ELEMENT_NAME = "rummikub";
	private final static String NAMESPACE = "http://home.universe-factory.net/rummikub/";

	private final LoginData loginData;
	private volatile Connection connection;
	private volatile MultiUserChat muc;

	private Event connectedEvent = new Event();
	private Event1<LoginError> connectionFailedEvent = new Event1<LoginError>();

	private Event1<GameData> gameOfferEvent = new Event1<GameData>();
	private Event1<UUID> gameWithdrawalEvent = new Event1<UUID>();

	private Event1<String> gameJoinEvent = new Event1<String>();
	private Event1<String> gameLeaveEvent = new Event1<String>();

	private Event1<Boolean> gameJoinAckEvent = new Event1<Boolean>();

	private Event2<String, Color> changeColorEvent = new Event2<String, Color>();

	private Event gameStartEvent = new Event();
	private Event roundStartEvent = new Event();

	private Event1<ITable> tableUpdateEvent = new Event1<ITable>();
	private Event3<IHand, ITable, ITable> turnEndEvent = new Event3<IHand, ITable, ITable>();
	private Event1<IRoundState> turnStartEvent = new Event1<IRoundState>();

	private GameData currentGame;

	private volatile GameData offeredGame;

	/**
	 * Creates new connection control
	 * 
	 * @param loginData
	 *          player's login data
	 */
	public ConnectionControl(LoginData loginData) {
		this.loginData = loginData;
	}

	@Override
	public String getNickname() {
		return muc.getNickname();
	}

	@Override
	public void connect() {
		run(new ConnectRunner());
	}

	@Override
	public void disconnect() {
		connectedEvent = new Event();
		connectionFailedEvent = new Event1<LoginError>();
		run(new Runnable() {
			@Override
			public void run() {
				synchronized (ConnectionControl.this) {
					if (connection != null) {
						connection.disconnect();
						connection = null;
					}
				}
			}
		});
	}

	@Override
	public IEvent getConnectedEvent() {
		return connectedEvent;
	}

	@Override
	public IEvent1<LoginError> getConnectionFailedEvent() {
		return connectionFailedEvent;
	}

	@Override
	public IEvent1<GameData> getGameOfferEvent() {
		return gameOfferEvent;
	}

	@Override
	public IEvent1<UUID> getGameWithdrawalEvent() {
		return gameWithdrawalEvent;
	}

	@Override
	public IEvent1<String> getGameJoinEvent() {
		return gameJoinEvent;
	}

	@Override
	public IEvent1<String> getGameLeaveEvent() {
		return gameLeaveEvent;
	}

	@Override
	public IEvent1<Boolean> getGameJoinAckEvent() {
		return gameJoinAckEvent;
	}

	@Override
	public IEvent2<String, Color> getChangeColorEvent() {
		return changeColorEvent;
	}

	@Override
	public IEvent getGameStartEvent() {
		return gameStartEvent;
	}

	@Override
	public IEvent getRoundStartEvent() {
		return roundStartEvent;
	}

	@Override
	public IEvent1<ITable> getTableUpdateEvent() {
		return tableUpdateEvent;
	}

	@Override
	public IEvent3<IHand, ITable, ITable> getTurnEndEvent() {
		return turnEndEvent;
	}

	@Override
	public IEvent1<IRoundState> getTurnStartEvent() {
		return turnStartEvent;
	}

	@Override
	public void offerGame(GameData data) {
		offeredGame = data;
		currentGame = data;
		sendGameOffer();
	}

	@Override
	public void withdrawGame() {
		offeredGame = null;
		final UUID uuid = currentGame.getGameID();
		currentGame = null;
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_withdrawal");
				extension.setValue("uuid", uuid.toString());
			}
		});
	}

	@Override
	public GameData getCurrentGame() {
		return currentGame;
	}

	@Override
	public void setCurrentGame(GameData game) {
		this.currentGame = game;
	}

	private void run(Runnable runner) {
		new Thread(runner).start();
	}

	@Override
	public void joinGame(final GameData game) {
		setCurrentGame(game);
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_join");
				extension.setValue("uuid", game.getGameID().toString());
			}
		});
	}

	@Override
	public void leaveGame() {
		final UUID uuid = currentGame.getGameID();
		currentGame = null;
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_leave");
				extension.setValue("uuid", uuid.toString());
			}
		});
	}

	@Override
	public void ackJoinGame(final String recipient, final boolean ack) {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_join_ack");
				extension.setValue("uuid", uuid.toString());
				extension.setValue("ack", Boolean.toString(ack));
			}

			@Override
			protected void modifyMessage(Message message) {
				message.setType(Message.Type.normal);
				message.setTo(muc.getRoom() + "/" + recipient);
			}
		});
	}

	@Override
	public void changeColor(final Color color) {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "change_color");
				extension.setValue("uuid", uuid.toString());
				extension.setValue("color", Base64.encodeObject(color, Base64.GZIP));
			}
		});
	}

	@Override
	public void startGame() {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_start");
				extension.setValue("uuid", uuid.toString());
			}
		});
	}

	@Override
	public void startRound() {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "round_start");
				extension.setValue("uuid", uuid.toString());
			}
		});
	}

	@Override
	public void updateTable(final ITable table) {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "table_update");
				extension.setValue("uuid", uuid.toString());
				extension.setValue("table", Base64.encodeObject(table, Base64.GZIP));
			}
		});
	}

	@Override
	public void endTurn(final IHand oldHand, final ITable oldTable,
			final ITable newTable) {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "turn_end");
				extension.setValue("uuid", uuid.toString());
				extension.setValue("data", Base64.encodeObject(new TurnEndData(oldHand,
						oldTable, newTable), Base64.GZIP));
			}
		});
	}

	@Override
	public void startTurn(final IRoundState state) {
		final UUID uuid = currentGame.getGameID();
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "turn_start");
				extension.setValue("uuid", uuid.toString());
				extension.setValue("state", Base64.encodeObject(state, Base64.GZIP));
			}
		});
	}

	private void sendGameOffer() {
		final GameData data = offeredGame;
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_offer");
				extension.setValue("uuid", data.getGameID().toString());
				extension.setValue("gameSettings",
						Base64.encodeObject(data.getGameSettings(), Base64.GZIP));
			}
		});
	}

	private void requestGames() {
		run(new SendRunner() {
			@Override
			protected void addData(DefaultPacketExtension extension) {
				extension.setValue("messageType", "game_request");
			}
		});
	}

	private static void emitLater(final Event event) {
		SwingUtilities.invokeLater(new Runnable() {
			@Override
			public void run() {
				event.emit();
			}
		});
	}

	private static <T> void emitLater(final Event1<T> event, final T arg) {
		SwingUtilities.invokeLater(new Runnable() {
			@Override
			public void run() {
				event.emit(arg);
			}
		});
	}

	private Message createMessage(PacketExtension extension) {
		Message message = muc.createMessage();
		message.addExtension(extension);
		return message;
	}

	private static DefaultPacketExtension createJRummikubExtension() {
		return new DefaultPacketExtension(ELEMENT_NAME, NAMESPACE);
	}

	private void processPacket(Packet packet) {
		DefaultPacketExtension extension = (DefaultPacketExtension) packet
				.getExtension(ELEMENT_NAME, NAMESPACE);

		if (((Message) packet).getType() == Message.Type.error) {
			System.err.println("Received error message from '" + packet.getFrom()
					+ "'");
			return;
		}

		String sender = packet.getFrom();
		sender = sender.substring(sender.indexOf('/') + 1);

		String messageType = extension.getValue("messageType");

		handleMessageTypes(extension, sender, messageType);
	}

	private void handleMessageTypes(DefaultPacketExtension extension,
			String sender, String messageType) {
		if (messageType.equals("game_offer")) {
			UUID uuid = UUID.fromString(extension.getValue("uuid"));
			GameSettings settings = (GameSettings) Base64.decodeToObject(extension
					.getValue("gameSettings"));

			GameData gameData = new GameData(uuid, settings, sender);
			gameOfferEvent.emit(gameData);
		} else if (messageType.equals("game_withdrawal")) {
			gameWithdrawalEvent.emit(UUID.fromString(extension.getValue("uuid")));
		} else if (messageType.equals("game_request")) {
			if (offeredGame != null) {
				sendGameOffer();
			}
		} else if (currentGame != null) {
			UUID uuid = UUID.fromString(extension.getValue("uuid"));
			if (!currentGame.getGameID().equals(uuid)) {
				return;
			}
			messagesDuringGame(extension, sender, messageType);
		}
	}

	private void messagesDuringGame(DefaultPacketExtension extension,
			String sender, String messageType) {
		if (messageType.equals("game_join")) {
			gameJoinEvent.emit(sender);
		} else if (messageType.equals("game_leave")) {
			gameLeaveEvent.emit(sender);
		} else if (messageType.equals("game_join_ack")) {
			gameJoinAckEvent.emit(Boolean.valueOf(extension.getValue("ack")));
		} else if (messageType.equals("change_color")) {
			changeColorEvent.emit(sender,
					(Color) Base64.decodeToObject(extension.getValue("color")));
		} else if (messageType.equals("game_start")) {
			gameStartEvent.emit();
		} else if (messageType.equals("round_start")) {
			roundStartEvent.emit();
		} else if (messageType.equals("table_update")) {
			tableUpdateEvent.emit((ITable) Base64.decodeToObject(extension
					.getValue("table")));
		} else if (messageType.equals("turn_end")) {
			TurnEndData data = (TurnEndData) Base64.decodeToObject(extension
					.getValue("data"));
			turnEndEvent.emit(data.getOldHand(), data.getOldTable(),
					data.getNewTable());
		} else if (messageType.equals("turn_start")) {
			turnStartEvent.emit((IRoundState) Base64.decodeToObject(extension
					.getValue("state")));
		} else {
			System.err.println("Received unrecognized message of type '"
					+ messageType + "'");
		}
	}

	private class ConnectRunner implements Runnable {
		@Override
		public void run() {
			synchronized (ConnectionControl.this) {
				ConnectionConfiguration config = new ConnectionConfiguration(
						loginData.getServerName());
				config.setSendPresence(false);
				config.setRosterLoadedAtLogin(false);
				config.setCompressionEnabled(true);

				connection = new XMPPConnection(config);

				connection.addPacketListener(new PacketListener() {
					@Override
					public void processPacket(final Packet packet) {
						SwingUtilities.invokeLater(new Runnable() {
							@Override
							public void run() {
								ConnectionControl.this.processPacket(packet);
							}
						});

					}
				}, new AndFilter(new PacketTypeFilter(Message.class),
						new PacketExtensionFilter(ELEMENT_NAME, NAMESPACE)));

				LoginError error = doConnect();
				if (error == null) {
					error = doLogin();
				}
				if (error == null) {
					error = doJoin();
				}

				if (error == null) {
					requestGames();
					emitLater(connectedEvent);
				} else {
					connection.disconnect();
					connection = null;

					emitLater(connectionFailedEvent, error);
				}
			}
		}

		private LoginError doConnect() {
			try {
				connection.connect();
				return null;
			} catch (XMPPException e) {
				XMPPError xmppError = e.getXMPPError();

				if (xmppError != null) {
					if (xmppError.getType() == Type.CANCEL && xmppError.getCode() == 504) {
						return LoginError.UNKNOWN_HOST;
					}
				}

				e.printStackTrace();
				return LoginError.UNKNOWN_ERROR;
			}
		}

		private LoginError doLogin() {
			try {
				connection.login(loginData.getUserName(), loginData.getPassword(),
						"JRummikub-" + StringUtils.randomString(8));
				return null;
			} catch (XMPPException e) {
				return LoginError.AUTHENTICATION_FAILED;
			}
		}

		private LoginError doJoin() {
			muc = new MultiUserChat(connection, loginData.getChannelName());
			DiscussionHistory history = new DiscussionHistory();
			history.setMaxStanzas(0);

			String nickname = loginData.getUserName();
			// Loop until a unused nickname is found
			while (true) {
				try {
					muc.join(nickname, null, history, 10000);
					return null; // Join was successful, break the loop
				} catch (XMPPException e) {
					XMPPError xmppError = e.getXMPPError();

					if (xmppError != null && xmppError.getType() == Type.CANCEL
							&& xmppError.getCode() == 409) {
						// There was a conflict, try again with another
						// nickname
						nickname += "_";
						continue;
					} else {
						// An unknown error has occurred, cancel connect
						if (xmppError != null && xmppError.getType() == Type.CANCEL
								&& xmppError.getCode() == 404) {
							return LoginError.UNKNOWN_CHANNEL;
						}

						e.printStackTrace();
						return LoginError.UNKNOWN_ERROR;
					}
				}
			}

		}
	}

	private abstract class SendRunner implements Runnable {
		@Override
		public void run() {
			synchronized (ConnectionControl.this) {
				if (connection != null) {
					DefaultPacketExtension extension = createJRummikubExtension();
					addData(extension);
					Message message = createMessage(extension);
					modifyMessage(message);
					connection.sendPacket(message);
				}
			}
		}

		abstract protected void addData(DefaultPacketExtension extension);

		protected void modifyMessage(Message message) {
		}
	}
}
