package cz.muni.fi.lasaris.sbms.data.providers.bacnet;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.log4j.Logger;

import com.serotonin.bacnet4j.LocalDevice;
import com.serotonin.bacnet4j.RemoteDevice;
import com.serotonin.bacnet4j.ResponseConsumer;
import com.serotonin.bacnet4j.apdu.AckAPDU;
import com.serotonin.bacnet4j.exception.BACnetException;
import com.serotonin.bacnet4j.npdu.ip.IpNetworkBuilder;
import com.serotonin.bacnet4j.service.acknowledgement.AcknowledgementService;
import com.serotonin.bacnet4j.service.acknowledgement.ReadPropertyMultipleAck;
import com.serotonin.bacnet4j.service.acknowledgement.ReadRangeAck;
import com.serotonin.bacnet4j.service.confirmed.ConfirmedRequestService;
import com.serotonin.bacnet4j.service.confirmed.ReadPropertyMultipleRequest;
import com.serotonin.bacnet4j.service.confirmed.ReadRangeRequest;
import com.serotonin.bacnet4j.service.unconfirmed.WhoIsRequest;
import com.serotonin.bacnet4j.transport.DefaultTransport;
import com.serotonin.bacnet4j.transport.Transport;
import com.serotonin.bacnet4j.type.Encodable;
import com.serotonin.bacnet4j.type.constructed.Choice;
import com.serotonin.bacnet4j.type.constructed.DateTime;
import com.serotonin.bacnet4j.type.constructed.LogRecord;
import com.serotonin.bacnet4j.type.constructed.ReadAccessResult;
import com.serotonin.bacnet4j.type.constructed.ReadAccessResult.Result;
import com.serotonin.bacnet4j.type.constructed.ReadAccessSpecification;
import com.serotonin.bacnet4j.type.constructed.ResultFlags;
import com.serotonin.bacnet4j.type.constructed.SequenceOf;
import com.serotonin.bacnet4j.type.enumerated.ObjectType;
import com.serotonin.bacnet4j.type.enumerated.PropertyIdentifier;
import com.serotonin.bacnet4j.type.primitive.Date;
import com.serotonin.bacnet4j.type.primitive.ObjectIdentifier;
import com.serotonin.bacnet4j.type.primitive.SignedInteger;
import com.serotonin.bacnet4j.type.primitive.Time;
import com.serotonin.bacnet4j.type.primitive.UnsignedInteger;

import cz.muni.fi.lasaris.sbms.data.entities.Address;
import cz.muni.fi.lasaris.sbms.data.entities.DataType;
import cz.muni.fi.lasaris.sbms.data.entities.ValueState;
import cz.muni.fi.lasaris.sbms.data.entities.containers.Trend;
import cz.muni.fi.lasaris.sbms.data.entities.values.RawValue;
import cz.muni.fi.lasaris.sbms.data.providers.bacnet.util.MutableInteger;
import cz.muni.fi.lasaris.sbms.data.providers.bacnet.util.MutableLong;

public class BACnetConnector {
	
	final static Logger logger = Logger.getLogger(BACnetConnector.class);
	
	private static BACnetConnector bacnet;
	private static boolean initialized;
	
	private LocalDevice device;
	private Map<BACnetAddress, CachedValue> cache;
	private int cacheValidity;
	private int bufferReadLimit;
	private int maxRequests;
	
	private BACnetConnector() {
		if(device == null) {
			//throw new IllegalStateException("BACnet device was not initilaized.");
			logger.debug("First instance of the BACnetConnector created - BACnet layer not initialized yet.");
		}
		
		logger.debug("Instance of BACnetConnector created.");
	}
	
	public static BACnetConnector getInstance() {
		if(bacnet == null || !initialized) {
			throw new IllegalStateException("init was not called yet or the Connector was already closed.");
		}
		return bacnet;
	}
	
	public static boolean isInitialized() {
		return initialized;
	}
	
	public static void init(Properties props, String name, String propertiesPrefix) {
		if(bacnet == null || !initialized) {
			bacnet = new BACnetConnector(props, name, propertiesPrefix);
			initialized = true;
		} else {
			logger.error("Repeated call to init was ignored. Call close() on existing instance before new init.");
		}
	}
	
	public void close() {
		if(device != null && device.isInitialized()) {
			device.terminate();
			initialized = false;
			device = null;
		}
	}
	
	private BACnetConnector(Properties props, String name, String propertiesPrefix) {
		
		cache = new HashMap<BACnetAddress, CachedValue>();
		
		cacheValidity = MutableInteger.parsePositiveInt(props.getProperty(propertiesPrefix + ".cache.validity"), 60);
		
		bufferReadLimit = MutableInteger.parsePositiveInt(props.getProperty(propertiesPrefix + ".readRange.count"), Integer.MAX_VALUE);
		
		maxRequests = MutableInteger.parsePositiveInt(props.getProperty(propertiesPrefix + ".readRange.maxRequests"), 10);
		
		IpNetworkBuilder nb = new IpNetworkBuilder();
		String ip = props.getProperty(propertiesPrefix + ".ip.address");
		String broadcast = props.getProperty(propertiesPrefix + ".ip.broadcast");
		String mask = props.getProperty(propertiesPrefix + ".ip.mask");
		String port = props.getProperty(propertiesPrefix + ".tcp.port");
		if(ip != null && isIP(ip)){
			nb.localBindAddress(ip);
		}
		if(broadcast != null && isIP(broadcast)){
			nb.broadcastIp(broadcast);
		}
		if(mask != null & isIP(mask)) {
			nb.subnetMask(mask);
		}
		MutableInteger intPort = new MutableInteger();
		if(port != null && intPort.parsePositiveInt(port)) {
			nb.port(intPort.getValue());
		}
		
		try {
			Transport t = new DefaultTransport(nb.build());
			this.device = new LocalDevice(Integer.parseInt(props.getProperty(propertiesPrefix + ".device.id")), t);
			device.initialize();
			discoveryRoutine(
					props.getProperty(propertiesPrefix + ".device.discovery.low"),
					props.getProperty(propertiesPrefix + ".device.discovery.high"),
					props.getProperty(propertiesPrefix + ".device.discovery.max"),
					props.getProperty(propertiesPrefix + ".device.discovery.threshold"),
					props.getProperty(propertiesPrefix + ".device.discovery.sleep")
					);
			logger.debug("BACnet device initialized.");
		} catch (Exception e) {
			logger.error("BACnet device was unable to initialize.", e);
		}
		
	}

	private void discoveryRoutine(String lowS, String highS, String maxS, String thresholdS, String sleepS) {
		// sequence of whois - reacts on number of devices discovered in previous run
		// the goal is not to flood the network with IAm messages - they are broadcasted as required by the specification
		//device.sendGlobalBroadcast(new WhoIsRequest());
		
		new Thread(new Runnable() {

			@Override
			public void run() {
				long low = MutableLong.parsePositiveLong(lowS, 0);
				long high = MutableLong.parsePositiveLong(highS, 1000);
				long maxAddress = MutableLong.parsePositiveLong(maxS, 0xffffffffl - 1);
				long threshold = MutableLong.parsePositiveLong(thresholdS, 100);
				long sleep = MutableLong.parsePositiveLong(sleepS, 3000);
				logger.debug("Starting network discovery...");
				while(low < maxAddress) {
					int devs = device.getRemoteDevices().size();
					logger.debug(String.format("Sending WhoIs from %d to %d.", low, high));
					device.sendGlobalBroadcast(new WhoIsRequest(new UnsignedInteger(low), new UnsignedInteger(high)));
					try {
						Thread.sleep(sleep);
					} catch (InterruptedException e) {
						logger.error("Exception while waiting for completion.", e);
					}
					int newDevs = device.getRemoteDevices().size() - devs;
					logger.debug(String.format("Detected %d new devices", newDevs));
					low = high;
					threshold = threshold * (threshold / (newDevs + 1));
					high = Long.min(low + threshold, maxAddress);
				}
				logger.debug("Network discovery completed.");
			}}).start();
		
	}
	
	public Map<Address,RawValue> getValues(List<Address> a, boolean allowCache) {
		Map<Address, RawValue> results = Collections.synchronizedMap(new LinkedHashMap<Address, RawValue>());
		Map<Integer, List<BACnetAddress>> remoteValues = Collections.synchronizedMap(new LinkedHashMap<Integer, List<BACnetAddress>>());
		if(allowCache) {
			for(Address add : a) {
				BACnetAddress ba = new BACnetAddress(add);
				// get value from cache if any
				if(cache.containsKey(ba) && cache.get(ba).isValid(cacheValidity)) {
					results.put(add, cache.get(ba).getValue());
				} else {
					// prepare value for remote read
					results.put(add, new RawValue(null, DataType.UNKNOWN));
					// place into appropriate bucket according to remote device (readPropertyMultiple optimization)
					addObjectToRPMSpecs(remoteValues, ba);
				}
			} 
		} else {
			// we have to get all values directly from the network
			for(Address add : a) {
				results.put(add, new RawValue(null, DataType.UNKNOWN));
				BACnetAddress ba = new BACnetAddress(add);
				addObjectToRPMSpecs(remoteValues, ba);
			}
		}

		final int totalNumberOfRequests = remoteValues.size();
		// better safe than sorry...
		AtomicInteger finished = new AtomicInteger(0);

		// get remote values
		for(Integer i : remoteValues.keySet()) {
			
			// TODO check if remote device is known (check implementation of getRemoteDevice)
			
			try {
				// prepare batch for the device i
				RemoteDevice rd = device.getRemoteDevice(i);

				SequenceOf<ReadAccessSpecification> specs = new SequenceOf<ReadAccessSpecification>();
				List<BACnetAddress> objects = remoteValues.get(i);
				for(BACnetAddress ba : objects) {
					ReadAccessSpecification ras = new ReadAccessSpecification(
							new ObjectIdentifier(new ObjectType(ba.getType()), ba.getInstance()), 
							new PropertyIdentifier(ba.getProperty()));
					specs.add(ras);
				}
				ConfirmedRequestService crs = new ReadPropertyMultipleRequest(specs);
				logger.debug("Sending ReadPropMulti request to device " + i.toString() + "...");
				// send asynchronous request
				device.send(rd, crs, new ResponseConsumer() {

					@Override
					public void ex(BACnetException ex) {
						fillReadingError(remoteValues.get(i), results, ex.toString(), finished);
						
					}

					@Override
					public void fail(AckAPDU fail) {
						if(fail instanceof com.serotonin.bacnet4j.apdu.Error) {
							fillReadingError(remoteValues.get(i), results, String.format("BACnet error: %s", 
									((com.serotonin.bacnet4j.apdu.Error)fail).getError().getError()), finished);
						} else {
							fillReadingError(remoteValues.get(i), results, "Unknown error", finished);
						}
					}

					@Override
					public void success(AcknowledgementService as) {
						logger.debug("ReadPropMulti response received");

						ReadPropertyMultipleAck result = (ReadPropertyMultipleAck)as;
						getValuesFromRPMResult(result, results, objects, i, finished);
					}

				});

			} catch (BACnetException e) {
				logger.debug("Unknown device", e);
				fillReadingError(remoteValues.get(i), results, "Unknown device", finished);
			}

		}

		//after all the request were sent, wait for them to complete
		synchronized(results) {
			while(finished.get() < totalNumberOfRequests) {
				try {
					results.wait();
				} catch (InterruptedException e) {
					logger.error("Unexpected interruption", e);
				}
			}
		}
		// return results map
		return results;
	}


	private void addObjectToRPMSpecs(Map<Integer, List<BACnetAddress>> remoteValues, BACnetAddress ba) {
		Integer device = new Integer(ba.getDevice());
		if(remoteValues.containsKey(device)) {
			remoteValues.get(device).add(ba);
		} else {
			List<BACnetAddress> l  = new ArrayList<BACnetAddress>();
			l.add(ba);
			remoteValues.put(device, l);
		}
	}
	
	private RawValue getValueFromEncodable(Encodable data) {
		
		if(data instanceof com.serotonin.bacnet4j.type.primitive.Boolean) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.Boolean)data).booleanValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.SignedInteger) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.SignedInteger)data).intValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.Unsigned32) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.Unsigned32)data).intValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.Unsigned16) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.Unsigned16)data).intValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.Unsigned8) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.Unsigned8)data).intValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.Real) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.Real)data).floatValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.Enumerated) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.Enumerated)data).intValue());
		}
		if(data instanceof com.serotonin.bacnet4j.type.primitive.CharacterString) {
			return new RawValue(((com.serotonin.bacnet4j.type.primitive.CharacterString)data).getValue());
		}
		return new RawValue("Generic toString: " + data.toString());
		//return RawValue.getReadingError("Unsupported data type");
	}
	
	private void fillReadingError(List<BACnetAddress> list, Map<Address, RawValue> results, String msg, AtomicInteger counter) {
		for(BACnetAddress ba : list) {
			synchronized(results) {
				results.put(ba.getAddress(), RawValue.getReadingError(msg));
				counter.incrementAndGet();
				results.notifyAll();
			}
		}
		
	}
	
	private void getValuesFromRPMResult(ReadPropertyMultipleAck result, Map<Address, RawValue> results,
			List<BACnetAddress> objects, Integer device, AtomicInteger counter) {
		for (int j = 1; j <= result.getListOfReadAccessResults().getCount(); j++) {
			ReadAccessResult rar = result.getListOfReadAccessResults().get(j);
			int instance = rar.getObjectIdentifier().getInstanceNumber();
			int objType = rar.getObjectIdentifier().getObjectType().intValue();
			for (int k = 1; k <= rar.getListOfResults().getCount(); k++) {
				Result r = rar.getListOfResults().get(k);
				int property = r.getPropertyIdentifier().intValue();
				BACnetAddress baFromResult = new BACnetAddress(device, objType, instance, property);
				Choice c = r.getReadResult();
				RawValue v = getValueFromEncodable(c.getDatum());

				// find the original BACnet address with reference to the original address
				// to be able to pair it with records in results collection

				objects.forEach((ba) -> { 
					if(ba.equals(baFromResult)) {
						// update cache and results collection 
						refreshCache(ba,v);
						synchronized(results) {
							results.put(ba.getAddress(), v);
							counter.incrementAndGet();
							results.notifyAll();
						}
					}
				});

			}	
		}
	}
	
	
	private class TLDataGetter implements com.serotonin.bacnet4j.ResponseConsumer {
		
		private Map<Address, Trend> results;
		private Trend values;
		private AtomicInteger counter;
		private LocalDevice device;
		private BACnetAddress trend;
		private ZonedDateTime to;
		private ZonedDateTime from;
		private boolean readByLimit;
		private int bufferReadLimit;
		private int retries;
		private int maxRequests;
		private int originalBufferLimit;
		
		public TLDataGetter(BACnetAddress trend, AtomicInteger counter, LocalDevice device, Map<Address, Trend> results, int bufferReadLimit, int maxRequests) {
			this.results = results;
			this.counter = counter;
			this.device = device;
			this.trend = trend;
			this.bufferReadLimit = bufferReadLimit;
			this.originalBufferLimit = bufferReadLimit;
			this.maxRequests = maxRequests;
			this.values = new Trend();
		}
		
		public void getData(ZonedDateTime from, ZonedDateTime to) {
			if(from == null) {
				throw new NullPointerException("from");
			}
			if(to == null) {
				this.readByLimit = true;
			}
			if(this.bufferReadLimit == 0) {
				exit();
			}
			
			// limit to reduce number of requests wehen communicating directly with the automation network
			if(retries > maxRequests) {
				exit();
			}
			
			this.from = from;
			this.to = to;
			
			//logger.info("Preparing ReadRange request...");
			RemoteDevice rd = null;
			try {
				rd = device.getRemoteDevice(trend.getDevice());
			} catch (BACnetException e) {
				exit();
				return;
			}
			
			ConfirmedRequestService crs = new ReadRangeRequest(new ObjectIdentifier(ObjectType.trendLog, trend.getInstance()), PropertyIdentifier.logBuffer, null, 
					new ReadRangeRequest.ByTime(getBACnetDateTime(from), new SignedInteger(this.bufferReadLimit)));
			
			//logger.info("Sending ReadRange request...");
			this.retries+=1;
			device.send(rd, crs, this);
		}
		// Possibly solved by switching to ZonedDateTime:  
		// there is a problem with time change - records are stored in the trend ordered by sequence number in
		// order to account for time changes. Sorting provided by the TreeMap object will mix the record ordering during the
		// time change
		
		@Override
		public void success(AcknowledgementService acks) {
			ReadRangeAck ack = (ReadRangeAck)acks;
			SequenceOf<? extends Encodable> r =ack.getItemData();
			ResultFlags rf = ack.getResultFlags();
			
			if(!readByLimit) {
				readByTs(r, rf, ack.getItemCount().intValue());
			} else {
				readByLimit(r, rf, ack.getItemCount().intValue());
			}
		}
		
		private void readByTs(SequenceOf<? extends Encodable> r, ResultFlags rf, int itemCount) {
			ZonedDateTime last = from;
			int i = 1;
			
			while (last.compareTo(to) < 0 && i <= itemCount) {
				LogRecord lr = (LogRecord)r.get(i);
				last = getZonedDateTime(lr.getTimestamp());
				// filters valid data records (management events - such as log enable/disable or time changes
				// do not have status flags set
				if(lr.getStatusFlags() != null) {
					values.add(last, getValueFromEncodable(lr.getEncodable()));
				}
				i++;
			} 
			
			if(last.compareTo(to) < 0 && (rf.isMoreItems() || !rf.isLastItem())) {
				getData(last, to);
			} else {
				exit();
			}
		}
		
		private void readByLimit(SequenceOf<? extends Encodable> r, ResultFlags rf, int itemCount) {
			ZonedDateTime last = from;
			int i = 1;
			int i2 = 1;
			if(this.originalBufferLimit < 0) {
				i = itemCount;
				i2= -1;
			}
			
			
			while (values.getData().size() < this.originalBufferLimit && i <= itemCount && i > 0) {
				LogRecord lr = (LogRecord)r.get(i);
				last = getZonedDateTime(lr.getTimestamp());
				// filters valid data records (management events - such as log enable/disable or time changes
				// do not have status flags set
				if(lr.getStatusFlags() != null) {
					values.add(last, getValueFromEncodable(lr.getEncodable()));
				}
				i+=i2;
			} 
			
			// no values were valid
			if (values.getData().size() == 0) {
				this.bufferReadLimit = Integer.min(Integer.MAX_VALUE, this.bufferReadLimit * 10); 
				getData(last, to);
			}
		}
		
		
		@Override
		public void fail(AckAPDU ack) {
			logger.info("fail");
			exit();
			

		}

		@Override
		public void ex(BACnetException e) {
			logger.error("Error", e);
			exit();

		}
		
		private void exit() {
			synchronized(results) {
				counter.incrementAndGet();
				results.put(trend.getAddress(), values);
				results.notifyAll();
			}
		}
	}	
	
	public Map<Address, Trend> getRecords(List<Address> trends, ZonedDateTime from, ZonedDateTime to) {
		return getRecords(trends, from, to, bufferReadLimit);
	}
	
	public Map<Address, Trend> getClosestOlderRecord(List<Address> trends, ZonedDateTime timestamp) {
		return getRecords(trends, timestamp, null, -1);
	}
	
	public Map<Address, Trend> getClosestNewerRecord(List<Address> trends, ZonedDateTime timestamp) {
		return getRecords(trends, timestamp, null, 1);
	}
	
	public Map<Address, Trend> getRecords(List<Address> trends, ZonedDateTime from, ZonedDateTime to, int readLimit) {
		Map<Address, Trend> results = Collections.synchronizedMap(new LinkedHashMap<Address, Trend>());
		
		AtomicInteger counter = new AtomicInteger(0);
		int total = trends.size();
		for(Address a : trends) {
			results.put(a, null);	
			TLDataGetter g = new TLDataGetter(new BACnetAddress(a), counter, device, results, readLimit, maxRequests);
			g.getData(from, to);
		}
		
		synchronized(results) {
			while(counter.get() < total) {
				try {
					results.wait();
				} catch (InterruptedException e) {
				}
			}
		}
		return results;
	}
	
	private DateTime getBACnetDateTime(ZonedDateTime ldt) {
		return new DateTime(
				new Date(ldt.getYear(), com.serotonin.bacnet4j.enums.Month.valueOf(ldt.getMonthValue()), ldt.getDayOfMonth(), com.serotonin.bacnet4j.enums.DayOfWeek.UNSPECIFIED),
				new Time(ldt.getHour(), ldt.getMinute(), ldt.getSecond(), 0)
		);
	}
	
	private ZonedDateTime getZonedDateTime(DateTime bdt) {
		return ZonedDateTime.of(LocalDateTime.of(bdt.getDate().getYear() + 1900, 
								(int)bdt.getDate().getMonth().getId(), 
								bdt.getDate().getDay(),
								bdt.getTime().getHour(),
								bdt.getTime().getMinute(),
								bdt.getTime().getSecond()), ZoneId.systemDefault());
	}
	
	private void refreshCache(BACnetAddress a, RawValue v) {
		if(cache.containsKey(a)) {
			CachedValue cv = cache.get(a);
			if(v.getState() == ValueState.SUCCESS || !cv.isValid(cacheValidity))
				cv.setValue(v);
		} else {
			if(v.getState() == ValueState.SUCCESS) {
				cache.put(a, new CachedValue(v));
			}
		}
	}
	
	private boolean isIP(String ip) {
		//http://stackoverflow.com/questions/4581877/validating-ipv4-string-in-java    
		if (ip == null || ip.isEmpty()) {
			return false;
		}

		String[] parts = ip.split("\\.");
		if (parts.length != 4) {
			return false;
		}

		for ( String s : parts ) {
			MutableInteger i = new MutableInteger();
			if(!i.parsePositiveInt(s)) {
				return false;
			}

			if ((i.getValue() < 0) || (i.getValue() > 255)) {
				return false;
			}
		}
		if (ip.endsWith(".")) {
			return false;
		}

		return true;
	}

}
