package fr.insa.clavardator.client.network;

import fr.insa.clavardator.lib.util.ErrorCallback;
import fr.insa.clavardator.lib.util.Log;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;

import static fr.insa.clavardator.client.network.NetUtil.isLocalAddress;

public class NetDiscoverer {
	private static final short DISCOVERY_PORT = 31593;
	private static final short RESPONSE_PORT = 31594;
	private static final int BROADCAST_BUFFER_SIZE = 50;
	private static final int RESPONSE_BUFFER_SIZE = 50;

	private BroadcastSender broadcastSender;
	private BroadcastListener broadcastListener;
	private ResponseSender responseSender;
	private ResponseListener responseListener;

	public NetDiscoverer() {
	}

	/**
	 * Discovers all active users in the network, and call the callback for each of them
	 *
	 * @param broadcastMessage The message to send as broadcast
	 * @param callback         function to call when a new user is discovered
	 * @param errorCallback    function to call on error
	 */
	public void discoverActiveUsers(String broadcastMessage, ResponseReceivedCallback callback, ErrorCallback errorCallback) {
		Log.v(this.getClass().getSimpleName(), "Starting user discovery");
		if (responseListener != null)
			responseListener.stopListening();
		responseListener = new ResponseListener(callback, errorCallback);
		responseListener.start();
		broadcastSender = new BroadcastSender(broadcastMessage, errorCallback);
		broadcastSender.start();
	}

	/**
	 * Starts to listen for discovery broadcasts and answers them
	 *
	 * @param responseMessage     The message to send in response to broadcasts
	 * @param onBroadcastReceived The function to call on success
	 * @param errorCallback       The function to call on error
	 */
	public void startDiscoveryListening(String responseMessage, @Nullable BroadcastReceivedCallback onBroadcastReceived, @Nullable ErrorCallback errorCallback) {
		Log.v(this.getClass().getSimpleName(), "Starting broadcast listening");
		if (broadcastListener != null)
			broadcastListener.stopListening();

		broadcastListener = new BroadcastListener((ipAddr, data) -> {
			try {
				if (!isLocalAddress(ipAddr)) {
					if (onBroadcastReceived != null)
						onBroadcastReceived.onBroadcastReceived(ipAddr, data);
					responseSender = new ResponseSender(ipAddr, responseMessage, errorCallback);
					responseSender.start();
				} else {
					Log.w(this.getClass().getSimpleName(), "Received broadcast from self");
				}
			} catch (SocketException e) {
				Log.e(this.getClass().getSimpleName(), "Error getting local adresses", e);
				if (errorCallback != null)
					errorCallback.onError(e);
			}
		}, e -> {
			if (errorCallback != null)
				errorCallback.onError(e);
		});

		broadcastListener.start();
	}

	/**
	 * Stop network discovery and listening
	 */
	public void stopDiscovery() {
		if (broadcastListener != null)
			broadcastListener.stopListening();
		if (responseListener != null)
			responseListener.stopListening();
	}


	private interface BroadcastReceivedCallback {
		void onBroadcastReceived(InetAddress ipAddr, String data);
	}

	public interface ResponseReceivedCallback {
		void onResponseReceived(InetAddress ipAddr, String data);
	}

	private static class BroadcastSender extends Thread {
		private final String broadcastMessage;
		private final ErrorCallback errorCallback;

		/**
		 * Constructs a thread that sends a broadcast over the network
		 *
		 * @param broadcastMessage The message to send
		 * @param errorCallback    The function to call on error
		 */
		public BroadcastSender(String broadcastMessage, ErrorCallback errorCallback) {
			this.broadcastMessage = broadcastMessage;
			this.errorCallback = errorCallback;
		}

		@Override
		public void run() {
			byte[] buf = broadcastMessage.getBytes();
			DatagramSocket broadcastSocket = null;
			try {
				for (InetAddress broadcastAddr : NetUtil.listAllBroadcastAddresses()) {
					broadcastSocket = new DatagramSocket();
					broadcastSocket.setBroadcast(true);
					broadcastSocket.send(new DatagramPacket(buf, buf.length, broadcastAddr, DISCOVERY_PORT));
					Log.v(this.getClass().getSimpleName(), "Broadcast sent on address " + broadcastAddr.toString());
					broadcastSocket.close();
				}
			} catch (IOException e) {
				Log.e(this.getClass().getSimpleName(), "Error sending broadcast", e);
				errorCallback.onError(e);
			} finally {
				if (broadcastSocket != null) {
					broadcastSocket.close();
				}
			}
		}
	}

	private static class BroadcastListener extends Thread {
		private final BroadcastReceivedCallback callback;
		private final ErrorCallback errorCallback;
		private DatagramSocket socket;
		private boolean shouldStop = false;

		/**
		 * Constructs a thread that receives broadcast messages over the network, on all available interfaces
		 *
		 * @param callback      The function to call on success
		 * @param errorCallback The function to call on error
		 */
		public BroadcastListener(BroadcastReceivedCallback callback, ErrorCallback errorCallback) {
			this.callback = callback;
			this.errorCallback = errorCallback;
		}

		@Override
		public void run() {
			try {
				socket = new DatagramSocket(DISCOVERY_PORT);
				byte[] buffer = new byte[BROADCAST_BUFFER_SIZE];

				while (!shouldStop) {
					DatagramPacket receivedPacket = new DatagramPacket(buffer, BROADCAST_BUFFER_SIZE);
					socket.receive(receivedPacket);
					Log.v(this.getClass().getSimpleName(), "Broadcast received from ip " + receivedPacket.getAddress().toString());
					callback.onBroadcastReceived(receivedPacket.getAddress(), new String(receivedPacket.getData()));
				}
			} catch (IOException e) {
				if (!shouldStop) {
					Log.e(this.getClass().getSimpleName(), "Error receiving broadcast message", e);
					errorCallback.onError(e);
				}
			} finally {
				if (socket != null)
					socket.close();
			}
		}

		public void stopListening() {
			shouldStop = true;
			socket.close();
		}
	}

	private static class ResponseSender extends Thread {
		private final String message;
		private final InetAddress address;
		private final ErrorCallback errorCallback;

		/**
		 * Constructs a thread that sends a UDP response to a broadcast
		 *
		 * @param address       The address of the remote host
		 * @param message       The message to send
		 * @param errorCallback The function to call on error
		 */
		public ResponseSender(InetAddress address, String message, ErrorCallback errorCallback) {
			this.address = address;
			this.message = message;
			this.errorCallback = errorCallback;
		}

		@Override
		public void run() {
			byte[] buf = message.getBytes();
			try (DatagramSocket responseSocket = new DatagramSocket()) {
				responseSocket.send(new DatagramPacket(buf, buf.length, address, RESPONSE_PORT));
				Log.v(this.getClass().getSimpleName(), "Broadcast response sent to ip " + address.toString());
			} catch (IOException e) {
				Log.e(this.getClass().getSimpleName(), "Error sending broadcast response to " + address.toString(), e);
				errorCallback.onError(e);
			}
		}
	}

	private static class ResponseListener extends Thread {
		private final ResponseReceivedCallback callback;
		private final ErrorCallback errorCallback;
		private DatagramSocket socket;
		private boolean shouldStop = false;

		/**
		 * Constructs a thread that receives all UDP responses, until stopListening() is called
		 *
		 * @param callback      The function to call on success
		 * @param errorCallback The function to call on error
		 */
		public ResponseListener(ResponseReceivedCallback callback, ErrorCallback errorCallback) {
			this.callback = callback;
			this.errorCallback = errorCallback;
		}

		@Override
		public void run() {
			try {
				socket = new DatagramSocket(RESPONSE_PORT);
				while (!shouldStop) {
					byte[] buffer = new byte[RESPONSE_BUFFER_SIZE];
					DatagramPacket receivedPacket = new DatagramPacket(buffer, RESPONSE_BUFFER_SIZE);
					socket.receive(receivedPacket);
					Log.v(this.getClass().getSimpleName(), "Broadcast response received from ip " + receivedPacket.getAddress().toString());
					callback.onResponseReceived(receivedPacket.getAddress(), new String(receivedPacket.getData()));
				}
			} catch (IOException e) {
				if (!shouldStop) {
					Log.e(this.getClass().getSimpleName(), "Error receiving broadcast response", e);
					errorCallback.onError(e);
				}
			}
		}

		public void stopListening() {
			shouldStop = true;
			socket.close();
		}

	}
}
