/**
 * Copyright (c) 2014 Etherios, a Division of Digi International, Inc.
 * All rights not expressly granted are reserved.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/.
 *
 * Etherios 11001 Bren Road East, Minnetonka, MN 55343
 * =======================================================================
 */
package com.etherios.connector.tank;

import com.etherios.connector.core.DataPoint;
import com.etherios.connector.core.DataStream;
import com.etherios.connector.core.facilities.messaging.IDeviceRequestListener;
import com.etherios.connector.core.log.Logger;
import com.etherios.connector.emulator.CloudConnector;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Timer;
import java.util.TimerTask;

/**
 * Class to monitor the simulated tank.
 * 
 * <p>It monitors the simulated tank by uploading the tank status (level, 
 * temperature, valve-In and valve-Out status) every {@value #CLOUD_PUSH_TIMEOUT}
 * milliseconds.</p>
 * 
 * <p>It listens and handles incoming device requests by implementing the 
 * {@link IDeviceRequestListener} interface, so the valve-In and valve-Out can 
 * be remotely managed.</p>
 */
public class TankMonitor implements IDeviceRequestListener {

	/* Constants */

	static final String LOGGER_PREFIX = "[TANK] ";

	// Device request constants.

	// This defines the target name we will use to receive data requests.
	private static final String TARGET_TANK = "tank";

	// Valve names.
	private static final String INPUT_VALVE = "valveIN";
	private static final String OUTPUT_VALVE = "valveOUT";

	// Valve status.
	private static final int ON = 0;
	private static final int OFF = 1;

	private static final String TRUE = "True";
	private static final String FALSE = "False";

	// Device request error messages.
	private static final String INVALID_REQUEST_DATA = "Invalid request data";
	private static final String INVALID_VALVE_NAME = "Invalid valve name (allowed: " + INPUT_VALVE + " or " + OUTPUT_VALVE + ")";
	private static final String INVALID_VALVE_STATUS = "Invalid valve status (allowed: " + ON + " or " + OFF + ")";

	// Data stream names
	private static final String TEMPERATURE_DATA_STREAM = TARGET_TANK + "/temperature";
	private static final String LEVEL_DATA_STREAM = TARGET_TANK + "/level";
	private static final String INPUT_VALVE_DATA_STREAM = TARGET_TANK + "/" + INPUT_VALVE;
	private static final String OUTPUT_VALVE_DATA_STREAM = TARGET_TANK + "/" + OUTPUT_VALVE;

	// Path and name of the remote log file in Device Cloud.
	private static final String LOG_FILE_PATH = TARGET_TANK + "/tank.log";

	// Timeout to push to Device Cloud in milliseconds.
	private static final int CLOUD_PUSH_TIMEOUT = 10 * 1000; // 10 seconds

	/* Variables */
	
	// Cloud Connector.
	private final CloudConnector connector;

	// Simulated tank.
	private final SimulatedTank tank;
	
	// Data streams.
	private DataStream temperatureStream;
	private DataStream levelStream;
	private DataStream valveInStream;
	private DataStream valveOutStream;

	// Timer to monitor the simulated tank.
	private Timer tankMonitorTimer;

	// Queue to store already sent data points.
	private ArrayDeque<List<DataPoint>> alreadySentQueue = new ArrayDeque<>(0);

	// Queue to store data points to be sent again.
	private ArrayDeque<List<DataPoint>> retryQueue = new ArrayDeque<>(0);

	// Log file reader.
	private LogFileReader logReader;

	/**
	 * Class constructor. Instances a new {@code TankMonitor} object to receive 
	 * device requests and send tank status data to Device Cloud.
	 *
	 * @param connector The Device Cloud Connector.
	 * @param tank The simulated tank.
	 */
	public TankMonitor(CloudConnector connector, SimulatedTank tank) {
		this.connector = connector;
		this.tank = tank;
		// Initialize data streams.
		initDataStreams();
	}

	/**
	 * Starts the tank monitoring.
	 * 
	 * <p>Schedules the timer task that periodically updates the tank status 
	 * values and sends it to Device Cloud.</p>
	 */
	public synchronized void start() {
		// Initialize timer.
		if (tankMonitorTimer != null)
			tankMonitorTimer.cancel();
		tankMonitorTimer = new Timer();
		tankMonitorTimer.schedule(new TankMonitorTask(), 1, CLOUD_PUSH_TIMEOUT);
	}

	/**
	 * Stops the tank monitoring.
	 * 
	 * <p>Cancels the periodic task to update the tank status values.</p>
	 */
	public synchronized void stop() {
		if (tankMonitorTimer != null)
			tankMonitorTimer.cancel();
		tankMonitorTimer = null;
		synchronized (alreadySentQueue) {
			alreadySentQueue.clear();
			alreadySentQueue = null;
		}
		synchronized (retryQueue) {
			retryQueue.clear();
			retryQueue = null;
		}
		if (logReader != null) {
			// Upload the rest of the log before closing.
			uploadLogFile();
			logReader.close();
			logReader = null;
		}
	}

	/**
	 * Returns the Cloud Connector.
	 * 
	 * @return the Cloud Connector.
	 */
	public CloudConnector getCloudConnector() {
		return connector;
	}

	/**
	 * Adds a list of data points to be sent again later.
	 * 
	 * @param dataPoints The list of data points to be sent again.
	 * @throws NullPointerException if the specified data points list is empty.
	 */
	void retryToSendLater(List<DataPoint> dataPoints) {
		synchronized (retryQueue) {
			retryQueue.add(dataPoints);
		}
	}

	/**
	 * Retrieves the first list of data points already sent. This removes the 
	 * package from the queue.
	 * 
	 * @return the first list of data points already sent.
	 * @throws NoSuchElementException if the already sent queue is empty.
	 */
	List<DataPoint> getLastSentDataPoints() {
		synchronized (alreadySentQueue) {
			return alreadySentQueue.remove();
		}
	}

	/**
	 * Initializes data streams for tank temperature and level.
	 */
	private void initDataStreams() {
		// Use device time for data points.
		DataPoint.useSystemTime(true);
		
		// Create the tank temperature stream and set the units.
		temperatureStream = new DataStream(TEMPERATURE_DATA_STREAM);
		temperatureStream.setUnits("C");
		
		// Create the tank level stream and set the units.
		levelStream = new DataStream(LEVEL_DATA_STREAM);
		levelStream.setUnits("%");
		
		// Create the tank valve-In stream.
		valveInStream = new DataStream(INPUT_VALVE_DATA_STREAM);
		
		// Create the tank valve-Out stream.
		valveOutStream = new DataStream(OUTPUT_VALVE_DATA_STREAM);
	}

	/**
	 * Sets the simulated tank valve status.
	 * 
	 * @param valveName the name of the valve. This must be 
	 * {@value #INPUT_VALVE} or {@value #OUTPUT_VALVE}.
	 * @param valveStatus the status of the valve. This must be {@value #ON} to
	 *					open the valve, {@value #OFF} to close it.
	 * @return an error if the valve name/status is not valid, {@value #TRUE} 
	 *		 if the valve is open, {@value #FALSE} if it is closed.
	 * @see #INPUT_VALVE
	 * @see #OUTPUT_VALVE
	 * @see #ON
	 * @see #OFF
	 */
	private String setValveStatus (String valveName, String valveStatus) {
		// Get the status of the valve.
		boolean status = false;
		if (valveStatus.equals(""+ON))
			status = true;
		else if (!valveStatus.equals(""+OFF))
			return INVALID_VALVE_STATUS;
		
		// Set the valve status.
		if (valveName.equals(INPUT_VALVE))
			tank.openValveIn(status);
		else if (valveName.equals(OUTPUT_VALVE))
			tank.openValveOut(status);
		else
			return INVALID_VALVE_NAME;
		// Upload to Device Cloud the status of the valves.
		uploadTankStatus(false);
		return status ? TRUE : FALSE;
	}

	/**
	 * Uploads the simulated tank status to Device Cloud.
	 * 
	 * @param full {@code true} to send all data, {@code false} to only send 
	 *			 the status of the valves.
	 */
	private void uploadTankStatus(boolean full) {
		// Get the tank level.
		int level = tank.getLevel();
		// Get the tank temperature.
		double temperature = tank.getTemperature();
		// Get the Valve-In status.
		boolean valveInOpen = tank.isValveInOpen();
		// Get the Valve-Out status.
		boolean valveOutOpen = tank.isValveOutOpen();
		
		// Data points list.
		ArrayList<DataPoint> dataPoints = new ArrayList<>(0);
		
		if (full) {
			// Create the tank level data point.
			DataPoint levelDataPoint = new DataPoint(level, levelStream);
			Logger.logSystemDebug(connector.getLogger(), 
				LOGGER_PREFIX + "Sending LEVEL: " + levelDataPoint.toCSVFormat(false));
			// Add the data point to the list.
			dataPoints.add(levelDataPoint);
			
			// Create the tank temperature data point.
			DataPoint tempDataPoint = new DataPoint(temperature, temperatureStream);
			Logger.logSystemDebug(connector.getLogger(), 
				LOGGER_PREFIX + "Sending TEMPERATURE: " + tempDataPoint.toCSVFormat(false)); 
			// Add the data point to the list.
			dataPoints.add(tempDataPoint);
		}
		
		// Create the Valve-In data point.
		DataPoint inValveDataPoint = new DataPoint(valveInOpen ? ON : OFF, valveInStream);
		Logger.logSystemDebug(connector.getLogger(), 
				LOGGER_PREFIX + "Sending INPUT-VALVE: " + inValveDataPoint.toCSVFormat(false));
		// Add the data point to the list.
		dataPoints.add(inValveDataPoint);
		
		// Create the Valve-Out data point.
		DataPoint outValveDataPoint = new DataPoint(valveOutOpen ? ON : OFF, valveOutStream);
		Logger.logSystemDebug(connector.getLogger(), 
				LOGGER_PREFIX + "Sending OUTPUT-VALVE: " + outValveDataPoint.toCSVFormat(false));
		// Add the data point to the list.
		dataPoints.add(outValveDataPoint);
		
		// Send data points.
		if (dataPoints.size() > 0)
			sendDataPoints(dataPoints);
		
		if (!connector.isConnected())
			return;
		
		// Send data points in the retry list.
		while (!retryQueue.isEmpty())
			sendDataPoints(retryQueue.remove());
	}

	/**
	 * Sends the given list of data points.
	 * <p>If Cloud Connector is not connected, they will be store to be sent 
	 * later.</p>
	 * 
	 * @param dataPoints list of data points to be sent.
	 */
	private void sendDataPoints(List<DataPoint> dataPoints) {
		// If the connector is not connected, do not send the data points.
		if (!connector.isConnected()) {
			retryToSendLater(dataPoints);
			return;
		}
		// Send data points.
		connector.sendDataPoint(dataPoints);
		synchronized (alreadySentQueue) {
			alreadySentQueue.add(dataPoints);
		}
	}

	/**
	 * Uploads the log file to Device Cloud only if the connector is 
	 * logging to a file.
	 * @see #LOG_FILE_PATH
	 */
	private void uploadLogFile() {
		// Do not try to open the log file if it is not logging to it.
		if (!connector.isConnected() 
				|| !connector.isLoggingToFile())
			return;
		if (logReader == null)
			logReader = new LogFileReader(connector.getLoggerFilePath());
		String logString = logReader.read();
		if (logString != null)
			connector.appendDataToRemoteFile(logString, LOG_FILE_PATH);
	}

	@Override
	public String handleDeviceRequest(String target, byte[] data) {
		return handleDeviceRequest(target, new String(data));
	}

	@Override
	public String handleDeviceRequest(String target, String data) {
		if (target == null || !target.equals(TARGET_TANK))
			return null; // Target not registered.
		if (data == null)
			return INVALID_REQUEST_DATA;
		data = data.trim();
		if (data.length() == 0)
			return INVALID_REQUEST_DATA;
		// Set the status of the valves.
		int equalIndex = data.indexOf('=');
		if (equalIndex <= 0
				|| equalIndex == data.length() - 1)
			return INVALID_REQUEST_DATA;
		Logger.logSystemDebug(connector.getLogger(), 
				LOGGER_PREFIX + "Received request: " + data);
		String result = setValveStatus(data.substring(0, equalIndex), 
				data.substring(equalIndex + 1));
		Logger.logSystemDebug(connector.getLogger(), 
				LOGGER_PREFIX + result);
		return result;
	}

	/**
	 * Device Cloud Upload task to be performed every 
	 * {@value #CLOUD_PUSH_TIMEOUT} milliseconds.
	 * 
	 * <p>The task will get the tank status values (level, temperature, valve-In 
	 * and valve-Out) and send them to Device Cloud using the Cloud Connector
	 * multiple data streams upload.</p>
	 */
	private class TankMonitorTask extends TimerTask {
		@Override
		public void run() {
			// Send data to Device Cloud.
			uploadTankStatus(true);
			// Upload the log file.
			uploadLogFile();
		}
	}

	/**
	 * Log file reader.
	 */
	private class LogFileReader {

		private static final int MAXIMUM_CHARS = 512;

		private SeekableByteChannel logSbc = null;

		private long lastLogByteRead = 0;

		private String logFilePathString;

		public LogFileReader(String logFilePath) {
			this.logFilePathString = logFilePath;
		}

		/**
		 * Reads the next available text from the file with a max of 
		 * {@value #MAXIMUM_CHARS} characters.
		 * 
		 * @return the next available string, or {@code null} if it was not 
		 *		 possible to continue reading.
		 * @see #MAXIMUM_CHARS
		 */
		String read() {
			if (!open())
				return null;
			ByteBuffer buff = ByteBuffer.wrap(new byte[MAXIMUM_CHARS]);
			try {
				logSbc.position(lastLogByteRead);
				int nReadBytes = logSbc.read(buff);
				if (nReadBytes == -1)
					return null;
				lastLogByteRead = lastLogByteRead + nReadBytes;
				return new String(buff.array(), 0, nReadBytes);
			} catch (IOException e) {
				e.printStackTrace();
			} finally {
				buff.clear();
				buff = null;
			}
			return null;
		}

		/**
		 * Opens the log file file to be read.
		 * 
		 * @return {@code true} if success, {@code false} otherwise.
		 */
		private boolean open() {
			// If it is already open return.
			if (logSbc != null && logSbc.isOpen())
				return true;
			// Check the log file exists.
			if (logFilePathString == null)
				return false;
			// We have to add this because it is using the PC file system
			// instead of the Emulator file system.
			logFilePathString = "appdb/filesystem" + logFilePathString;
			Path logFilePath = Paths.get(logFilePathString);
			if (logFilePath == null || !Files.exists(logFilePath.toAbsolutePath()))
				return false;
			lastLogByteRead = 0;
			// Create the seekable byte channel.
			try {
				logSbc = Files.newByteChannel(logFilePath, StandardOpenOption.READ);
			} catch (IOException e) {
				e.printStackTrace();
				return false;
			}
			return true;
		}

		/**
		 * Closes this reader and releases any system resources associated with 
		 * it.
		 */
		void close() {
			try {
				if (logSbc != null)
					logSbc.close();
			} catch (IOException e) {}
			logSbc = null;
		}
	}
}
