/**
 *
 */
package uk.me.hardill.TRADFRI2MQTT;

import static uk.me.hardill.TRADFRI2MQTT.TradfriConstants.*;

import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Vector;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.collections4.BidiMap;
import org.apache.commons.collections4.bidimap.DualHashBidiMap;
import org.eclipse.californium.core.CaliforniumLogger;
import org.eclipse.californium.core.CoapClient;
import org.eclipse.californium.core.CoapHandler;
import org.eclipse.californium.core.CoapObserveRelation;
import org.eclipse.californium.core.CoapResponse;
import org.eclipse.californium.core.coap.MediaTypeRegistry;
import org.eclipse.californium.core.network.CoapEndpoint;
import org.eclipse.californium.core.network.config.NetworkConfig;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.ScandiumLogger;
import org.eclipse.californium.scandium.config.DtlsConnectorConfig;
import org.eclipse.californium.scandium.dtls.pskstore.StaticPskStore;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.MqttCallback;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.eclipse.paho.client.mqttv3.MqttPersistenceException;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * @author hardillb & r41d
 *
 */
public class Main {

	static {
		CaliforniumLogger.disableLogging();
		ScandiumLogger.disable();
//		ScandiumLogger.initialize();
//		ScandiumLogger.setLevel(Level.FINE);
	}

	private DTLSConnector dtlsConnector;
	private MqttClient mqttClient;
	private CoapEndpoint endPoint;

	private String ip;
	private boolean retainedQueues;

	// mapping: device ID -> device names
	private BidiMap<Integer, String> id2device = new DualHashBidiMap<>();
	// mapping: room ID -> room name
	private BidiMap<Integer, String> id2room = new DualHashBidiMap<>();
	// mapping room ID -> Mood (ID,Name)
	// The outer HashMap must not be a BidiMap because it leads to bugs when more than one empty BidiMap is contained as a value
	private HashMap<Integer, BidiMap<Integer,String>> roomID2moods = new HashMap<>();
	private Vector<CoapObserveRelation> watching = new Vector<>();

	Main(String psk, String ip, String broker, boolean retained) {
		this.ip = ip;
		this.retainedQueues = retained;
		DtlsConnectorConfig.Builder builder = new DtlsConnectorConfig.Builder(new InetSocketAddress(0));
		builder.setPskStore(new StaticPskStore("", psk.getBytes()));
		dtlsConnector = new DTLSConnector(builder.build());
		endPoint = new CoapEndpoint(dtlsConnector, NetworkConfig.getStandard());

		MemoryPersistence persistence = new MemoryPersistence();
		try {
			mqttClient = new MqttClient(broker, MqttClient.generateClientId(), persistence);
			MqttConnectOptions opts = new MqttConnectOptions();
			opts.setMaxInflight(200);
			opts.setAutomaticReconnect(true);
			mqttClient.connect(opts);
			mqttClient.setCallback(new MqttCallback() {

				@Override
				public void messageArrived(String topic, MqttMessage message) throws Exception {
					// TODO Auto-generated method stub
					System.out.println(topic + " " + message.toString());
					String parts[] = topic.split("/");

					String entityType = parts[1]; // bulb or room
					String entityName = parts[2]; // name of bulb or room
					assert parts[3].equals("control"); // something *really* went wrong if this doesn't hold
					String command = parts[4];

					try {
						JSONObject json = new JSONObject();
						String payload;

						switch (entityType) {

						case "bulb": // single bulb
							JSONObject settings = new JSONObject();
							JSONArray array = new JSONArray();
							array.put(settings);
							json.put(LIGHT, array);

							switch (command) {
							case "on":
								switch (message.toString()) {
								case "0":
								case "1":
									settings.put(ONOFF, Integer.parseInt(message.toString()));
									break;
								default:
									System.err.println("Invalid OnOff value '" + message.toString() + "'for bulb " + entityName);
									return;
								}
								break;
							case "dim":
								int dimval = Integer.parseInt(message.toString());
								settings.put(DIMMER, Math.min(DIMMER_MAX, Math.max(DIMMER_MIN, dimval)));
								settings.put(TRANSITION_TIME, 3); // transition in seconds
								break;
							case "temperature":
								// not sure what the COLOR_X and COLOR_Y values
								// do, it works without them...
								switch (message.toString()) {
								case "cold":
									settings.put(COLOR, COLOR_COLD);
									break;
								case "normal":
									settings.put(COLOR, COLOR_NORMAL);
									break;
								case "warm":
									settings.put(COLOR, COLOR_WARM);
									break;
								default:
									System.err.println("Invalid temperature supplied: " + message.toString());
									return;
								}
								break;
							default:
								System.err.println("Invalid command supplied: " + command);
								return;
							}
							payload = json.toString();
							Main.this.set("coaps://" + Main.this.ip + "//" + DEVICES + "/" + id2device.getKey(entityName), payload);
							break;

						case "room": // whole room
							switch (command) {
							case "on":
								switch (message.toString()) {
								case "0":
								case "1":
									json.put(ONOFF, Integer.parseInt(message.toString()));
									break;
								default:
									System.err.println("Invalid OnOff value '" + message.toString() + "'for room " + entityName);
									return;
								}
								break;
							case "dim":
								json.put(DIMMER, Integer.parseInt(message.toString()));
								json.put(TRANSITION_TIME, 3);
								break;
							case "mood":
								String moodName = message.toString();
								int roomID = id2room.getKey(entityName);
								Integer moodID = roomID2moods.get(roomID).getKey(moodName);
								if (moodID != null) {
									json.put(SCENE_ID, moodID);
								} else {
									System.err.println("Mood " + moodName + " for room " + entityName + " not found");
									return;
								}
								break;
							default:
								System.err.println("Invalid command for room: " + command);
								return;
							}
							payload = json.toString();
							System.err.println(payload);
							Main.this.set("coaps://" + Main.this.ip + "//" + GROUPS + "/" + id2room.getKey(entityName), payload);
							break;

						default:
							System.err.println("Invalid entityType: " + entityType);
							return;
						}

					} catch (Exception e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
				}

				@Override
				public void deliveryComplete(IMqttDeliveryToken token) {
					// TODO Auto-generated method stub
				}

				@Override
				public void connectionLost(Throwable cause) {
					// TODO Auto-generated method stub
				}
			});
			mqttClient.subscribe("TRÅDFRI/bulb/+/control/+");
			mqttClient.subscribe("TRÅDFRI/room/+/control/+");
		} catch (MqttException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
		Runnable command = new Runnable() {
			@Override
			public void run() {
				System.out.println("re-reg");
				for (CoapObserveRelation rel : watching) {
					rel.reregister();
				}
			}
		};
		executor.scheduleAtFixedRate(command, 120, 120, TimeUnit.SECONDS);
	}

	private void discover() {

		// Discover Bulbs
		try {
			URI uri = new URI("coaps://" + ip + "//" + DEVICES);
			CoapClient client = new CoapClient(uri);
			client.setEndpoint(endPoint);
			CoapResponse response = client.get();
			if (response == null) {
				System.out.println("Connection to Gateway timed out, please check ip address or increase the ACK_TIMEOUT in the Californium.properties file");
				System.exit(-1);
			}
			JSONArray devices = new JSONArray(response.getResponseText());
			for (int i = 0; i < devices.length(); i++) {
				this.watch(DEVICES, ""+devices.getInt(i));
			}
			client.shutdown();
		} catch (URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (JSONException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		// Discover Rooms/Groups
		try {
			URI uri = new URI("coaps://" + ip + "//" + GROUPS);
			CoapClient client = new CoapClient(uri);
			client.setEndpoint(endPoint);
			CoapResponse response = client.get();
			if (response == null) {
				System.out.println("Connection to Gateway timed out, please check ip address or increase the ACK_TIMEOUT in the Californium.properties file");
				System.exit(-1);
			}
			JSONArray rooms = new JSONArray(response.getResponseText());
			for (int i = 0; i < rooms.length(); i++) {
				this.watch(GROUPS, "" + rooms.getInt(i));
			}
			client.shutdown();
		} catch (URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (JSONException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		// Discover all Moods for all Rooms that were found
		// Moods are structured as follows: IP:5684/15005/RoomID/MoodID
		try {
			URI sceneURI = new URI("coaps://" + ip + "//" + SCENE);
			CoapClient client = new CoapClient(sceneURI);
			client.setEndpoint(endPoint);
			CoapResponse responseRooms = client.get();
			if (responseRooms == null) {
				System.out.println("Connection to Gateway timed out, please check ip address or increase the ACK_TIMEOUT in the Californium.properties file");
				System.exit(-1);
			}
			JSONArray moodRooms = new JSONArray(responseRooms.getResponseText());
			for (int roomIdx = 0; roomIdx < moodRooms.length(); roomIdx++) {
				int roomID = moodRooms.getInt(roomIdx);
				// prepare Bidirectional Map for storing the moods for the current room
				roomID2moods.put(roomID, new DualHashBidiMap<Integer, String>());

				URI moodUri = new URI("coaps://" + ip + "//" + SCENE + "/" + roomID);
				client = new CoapClient(moodUri);
				client.setEndpoint(endPoint);
				CoapResponse responseMoods = client.get();
				if (responseMoods == null) {
					System.out.println("Connection to Gateway timed out, please check ip address or increase the ACK_TIMEOUT in the Californium.properties file");
					System.exit(-1);
				}
				JSONArray moodsIDs = new JSONArray(responseMoods.getResponseText());
				for (int moodIdx = 0; moodIdx < moodsIDs.length(); moodIdx++) {
					this.watch(SCENE, "" + roomID, "" + moodsIDs.getInt(moodIdx));
				}
				client.shutdown();
			}
		} catch (URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (JSONException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

	}

	private void set(String uriString, String payload) {
		//System.out.println("payload\n" + payload);
		try {
			URI uri = new URI(uriString);
			CoapClient client = new CoapClient(uri);
			client.setEndpoint(endPoint);
			CoapResponse response = client.put(payload, MediaTypeRegistry.TEXT_PLAIN);
			if (response != null && response.isSuccess()) {
				//System.out.println("Yay");
			} else {
				System.out.println("Sending payload to " + uriString + " failed!");
			}
			client.shutdown();
		} catch (URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}

	private void watch(String realm, final String... path) {

		try {
			String uriString = "coaps://" + ip + "//" + realm + "/" + Arrays.asList(path).stream().collect(Collectors.joining("/"));
			CoapClient client = new CoapClient(new URI(uriString));
			client.setEndpoint(endPoint);
			CoapHandler handler = new CoapHandler() {

				@Override
				public void onLoad(CoapResponse response) {
					//System.out.println(response.getResponseText());
					//System.out.println(response.getOptions().toString());
					try {
						JSONObject json = new JSONObject(response.getResponseText());
						int ID = json.getInt(INSTANCE_ID);
						String name = json.getString(NAME);
						// TODO change this test to something based on 5750 values
						// 2 = light?
						// 0 = remote/dimmer?
						if (json.has(TYPE) && json.getInt(TYPE) == TYPE_BULB && json.has(LIGHT)) { // single bulb
							String socket = "";
							try {
								socket = " " + json.getJSONObject("3").getString("1");
								if (socket.startsWith("TRADFRI bulb ")) {
									socket = " " + socket.split(" ")[3];
								}
							} catch (JSONException e) {}
							System.out.println("Processing" + socket + " Bulb " + name + " " + response.getResponseText());

							id2device.put(ID, name);

							JSONObject light = json.getJSONArray(LIGHT).getJSONObject(0);

							if (!light.has(ONOFF)) {
								System.err.println("Bulb '" + name + "' has no On/Off value (probably no power on lightbulb socket)");
								return; // skip this lamp for now
							}
							int state = light.getInt(ONOFF);
							String topicBulbOnOff = "TRÅDFRI/bulb/" + name + "/state/on";
							String topicBulbDim = "TRÅDFRI/bulb/" + name + "/state/dim";
							String topicBulbTemp = "TRÅDFRI/bulb/" + name + "/state/temperature";

							MqttMessage messageBulbOnOff = new MqttMessage();
							messageBulbOnOff.setPayload(Integer.toString(state).getBytes());
							if (retainedQueues) {
								messageBulbOnOff.setRetained(true);
							}
							mqttClient.publish(topicBulbOnOff, messageBulbOnOff);

							MqttMessage messageBulbDim = null;
							if (light.has(DIMMER)) {
								messageBulbDim = new MqttMessage();
								int dim = light.getInt(DIMMER);
								messageBulbDim.setPayload(Integer.toString(dim).getBytes());
								if (retainedQueues) {
									messageBulbDim.setRetained(true);
								}
								mqttClient.publish(topicBulbDim, messageBulbDim);
							} else {
								System.err.println("Bulb '" + name + "' has no dimming value (maybe just no power on lightbulb socket)");
								// no dim topic is created for this bulb
							}

							MqttMessage messageBulbTemp = null;
							if (light.has(COLOR)) {
								messageBulbTemp = new MqttMessage();
								String temperature = light.getString(COLOR);
								messageBulbTemp.setPayload(temperature.getBytes());
								if (retainedQueues) {
									messageBulbTemp.setRetained(true);
								}
								mqttClient.publish(topicBulbTemp, messageBulbTemp);
							} else { // just fyi for the user. maybe add further handling later
								System.out.println("Bulb '" + name + "' doesn't support color temperature");
							}

						} else if (json.has(HS_ACCESSORY_LINK)) { // groups have this entry
							JSONArray lamps = null;
							try {
								lamps = json.getJSONObject("9018").getJSONObject("15002").getJSONArray("9003");
							} catch (JSONException e) {}
							List<String> lampNames = new ArrayList<>();
							if (lamps != null)
								for (int i = 0; i < lamps.length(); i++)
									lampNames.add(id2device.get(lamps.getInt(i)));
							String ll = " (" + lampNames.stream().collect(Collectors.joining(" ")) + ")";
							System.out.println("Processing Room " + name + ll + " " + response.getResponseText());
							id2room.put(json.getInt(INSTANCE_ID), name);

							String topicRoomOnOff = "TRÅDFRI/room/" + name + "/state/on";
							String topicRoomDim = "TRÅDFRI/room/" + name + "/state/dim";

							MqttMessage messageRoomOnOff = new MqttMessage();
							int state = json.getInt(ONOFF);
							messageRoomOnOff.setPayload(Integer.toString(state).getBytes());
							if (retainedQueues) {
								messageRoomOnOff.setRetained(true);
							}
							mqttClient.publish(topicRoomOnOff, messageRoomOnOff);

							MqttMessage messageRoomDim = new MqttMessage();
							int dim = json.getInt(DIMMER);
							messageRoomDim.setPayload(Integer.toString(dim).getBytes());
							if (retainedQueues) {
								messageRoomDim.setRetained(true);
							}
							mqttClient.publish(topicRoomDim, messageRoomDim);

						} else if (json.has(IKEA_MOODS)) {
							System.out.println("Processing Mood " + name + " for Room ID " + path[0] + " " + response.getResponseText());
							// Store Mood in database
							roomID2moods.get(Integer.parseInt(path[0])).put(ID, name);
						} else if (json.has(TYPE) && json.getInt(TYPE) == TYPE_REMOTE) {
							System.out.println("Processing Remote " + name + " " + response.getResponseText());
							// save this to device list, even though it's not used yet
							id2device.put(json.getInt(INSTANCE_ID), name);
						} else {
							System.out.println("Got entity '" + name + "' that is neither bulb, group or remote..." + " " + response.getResponseText());
						}
					} catch (JSONException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					} catch (MqttPersistenceException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					} catch (MqttException e) {
						System.err.println("Publishing failed: " + e.getMessage());
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
				}

				@Override
				public void onError() {
					System.out.println("CoAP request timed out or was rejected by the server.");
				}
			};
			CoapObserveRelation relation = client.observe(handler);
			watching.add(relation);

		} catch (URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

	}

	/**
	 * @param args Command line arguments
	 * @throws InterruptedException
	 */
	public static void main(String[] args) throws InterruptedException {
		Options options = new Options();
		options.addOption("psk", true, "The Secret on the base of the gateway");
		options.addOption("ip", true, "The IP address of the gateway");
		options.addOption("broker", true, "MQTT URL");
		options.addOption("retained", "Topics are retained");
		options.addOption("help", "Shows this usage information");

		CommandLineParser parser = new DefaultParser();
		CommandLine cmd = null;
		try {
			cmd = parser.parse(options, args);
		} catch (ParseException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		String psk = cmd.getOptionValue("psk");
		String ip = cmd.getOptionValue("ip");
		String broker = cmd.getOptionValue("broker");
		boolean retained = cmd.hasOption("retained");
		boolean help = cmd.hasOption("help");

		if (help || psk == null || ip == null || broker == null) {
			HelpFormatter formatter = new HelpFormatter();
			formatter.printHelp("TRADFRI2MQTT", options);
			System.exit(1);
		}

		Main m = new Main(psk, ip, broker, retained);
		m.discover();
	}

}