package cz.muni.fi.lasaris.sbms.data.util;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;

import cz.muni.fi.lasaris.sbms.data.entities.Address;
import cz.muni.fi.lasaris.sbms.data.entities.AggregationFunction;
import cz.muni.fi.lasaris.sbms.data.entities.DataType;
import cz.muni.fi.lasaris.sbms.data.entities.SeriesSpecs;
import cz.muni.fi.lasaris.sbms.data.entities.containers.Trend;
import cz.muni.fi.lasaris.sbms.data.entities.values.AggregatedValue;
import cz.muni.fi.lasaris.sbms.data.entities.values.Value;

public class Aggregator {
	public static AggregationFunction getAggregation(String agg) {
		switch(agg.toUpperCase().trim()) {
		case "COUNT":
			return AggregationFunction.COUNT;
		case "MAX":
			return AggregationFunction.MAX;
		case "MIN":
			return AggregationFunction.MIN;
		case "AVG":
			return AggregationFunction.AVG;
		case "MEDIAN":
			return AggregationFunction.MEDIAN;
		case "SUM":
			return AggregationFunction.SUM;
		case "TEMPORAL_AVG":
			return AggregationFunction.TEMPORAL_AVG;
		case "WEIGHTED_AVG":
			return AggregationFunction.WEIGHTED_AVG;
		case "WEIGHTED_TEMPORAL_AVG":
			return AggregationFunction.WEIGHTED_TEMPORAL_AVG;
		case "RATIO":
			return AggregationFunction.RATIO;
		case "TEMPORAL_RATIO":
			return AggregationFunction.TEMPORAL_RATIO;
		case "WEIGHTED_RATIO":
			return AggregationFunction.WEIGHTED_RATIO;
		case "WEIGHTED_TEMPORAL_RATIO":
			return AggregationFunction.WEIGHTED_TEMPORAL_RATIO;
		case "OR":
			return AggregationFunction.OR;
		case "AND":
			return AggregationFunction.AND;
		case "NONE":
			return AggregationFunction.NONE;
		default:
			throw new IllegalArgumentException("Unknown aggregation type.");
		}
		
	}

	public static AggregatedValue computeAggregation(Map<Address, ? extends Value> data, AggregationFunction aggregation) {
		return computeAggregation(data, aggregation, false);
	}
	
	private static AggregatedValue computeAggregation(Map<Address, ? extends Value> data, AggregationFunction aggregation, boolean temporalAllowed) {
		if(data == null) {
			throw new NullPointerException("data");
		}
		switch(aggregation) {
		case AVG:
			return avg(data);
		case TEMPORAL_AVG:
			if(temporalAllowed) {
				AggregatedValue tavg = avg(data);
				tavg.setAggregation(aggregation);
				return tavg;
			} else {
				return AggregatedValue.getAggregationError(aggregation, "Aggregation not supported for a Snapshot");
			}
		case WEIGHTED_AVG:
			return weightedAvg(data);
		case WEIGHTED_TEMPORAL_AVG:
			if(temporalAllowed) {
				AggregatedValue wavg = weightedAvg(data);
				wavg.setAggregation(aggregation);
				return wavg;
			} else {
				return AggregatedValue.getAggregationError(aggregation, "Aggregation not supported for a Snapshot");
			}
		case RATIO:
			return ratio(data);
		case WEIGHTED_RATIO:
			return weightedRatio(data);
		case TEMPORAL_RATIO:
			if(temporalAllowed) {
				AggregatedValue tr = ratio(data);
				tr.setAggregation(aggregation);
				return tr;
			} else {
				return AggregatedValue.getAggregationError(aggregation, "Aggregation not supported for a Snapshot");
			}
		case WEIGHTED_TEMPORAL_RATIO:
			if(temporalAllowed) {
				AggregatedValue tr = weightedRatio(data);
				tr.setAggregation(aggregation);
				return tr;
			} else {
				return AggregatedValue.getAggregationError(aggregation, "Aggregation not supported for a Snapshot");
			}
		case COUNT:
			return new AggregatedValue(data.size(), aggregation);
		case MEDIAN:
			return median(data);
		case MIN:
			return min(data);
		case MAX:
			return max(data);
		case SUM:
			return sum(data);
		case AND:
			return and(data);
		case OR:
			return or(data);
		case NONE:
		
		default:
			return AggregatedValue.getAggregationError(aggregation, "Aggregation not supported for a Snapshot");
			
		}
	}
	
	public static Map<Address, AggregatedValue> computeTemporalAggregations (Map<Address, Trend> data,
			AggregationFunction aggregation, SeriesSpecs s) {
		return computeTemporalAggregations(data, aggregation, s, false);
	}
	
	public static AggregatedValue computeOverallTemporalAggregation (Map<Address, Trend> data,
			AggregationFunction aggregation, SeriesSpecs s) {
		
		Map<Address, AggregatedValue> results = computeTemporalAggregations(data, aggregation, s, true);
		return computeAggregation(results, aggregation, true);
		
	}
	
	private static Map<Address, AggregatedValue> computeTemporalAggregations (Map<Address, Trend> data,
			AggregationFunction aggregation, SeriesSpecs s, boolean weightedAvgAllowed) {
		
		Map<Address, AggregatedValue> results = new LinkedHashMap<Address, AggregatedValue>(data.size()); 
		if(!weightedAvgAllowed && (aggregation == AggregationFunction.WEIGHTED_TEMPORAL_AVG || aggregation == AggregationFunction.WEIGHTED_AVG)) {
			for(Address a : data.keySet()) {
				results.put(a, AggregatedValue.getAggregationError(aggregation, "Aggregation is not supported when computing individual aggregations for each trend."));
			}
			return results;
		}
		for(Address a : data.keySet()) {
			results.put(a, computeTemporalAggregation(data.get(a).getNavigableData(), aggregation, s.getStart(), s.getEnd()));
		}
		return results;
	}
	
	public static AggregatedValue computeTemporalAggregation(NavigableMap<ZonedDateTime, ? extends Value> data,
			AggregationFunction aggregation, ZonedDateTime from, ZonedDateTime to) {
		if(data == null) {
			throw new NullPointerException("data");
		}
		switch(aggregation) {
		case AVG:
			return avg(data);
		case WEIGHTED_AVG:
			return avg(data);
		case TEMPORAL_AVG:
			return temporalAvg(data, from, to);
		case WEIGHTED_TEMPORAL_AVG:
			return temporalAvg(data, from, to);
		case TEMPORAL_RATIO:
			return temporalRatio(data, from, to);
		case COUNT:
			return new AggregatedValue(data.size(), aggregation);
		case MEDIAN:
			return median(data);
		case MIN:
			return min(data);
		case MAX:
			return max(data);
		case SUM:
			return sum(data);
		case AND:
			return and(data);
		case OR:
			return or(data);
		case RATIO:
			return ratio(data);
		case NONE:
		default:
			return AggregatedValue.getAggregationError(aggregation, "Aggregation not supported for a Series");
			
		}
		
	}
	
	private static Double getDoubleVal(Value v, boolean acceptBool, boolean acceptInt, boolean acceptReal) {
		DataType d = v.getDataType();
		if(acceptReal && d == DataType.REAL) {	
			return (Double)v.getValue();
		} else if(acceptInt && d == DataType.INTEGER) {
			return Double.valueOf(((Long)v.getValue()).doubleValue());
		} else if(acceptBool && d == DataType.BOOL) {
			return Double.valueOf((((Boolean)v.getValue()).booleanValue() ? 1 : 0));
		}
		else {
			return null;
		}
	}
	
	private static  <T extends Value> AggregatedValue temporalAvg(NavigableMap<ZonedDateTime, T> data, ZonedDateTime from, ZonedDateTime to) {
		return temporalAvg(data, from, to, false, true, true);
	}
	
	private static  <T extends Value> AggregatedValue temporalAvg(NavigableMap<ZonedDateTime, T> data, ZonedDateTime from, ZonedDateTime to, 
			boolean acceptBool, boolean acceptInt, boolean acceptReal) {
		double sum = 0;
		double count = 0;
		ZonedDateTime valStart = null;
		ZonedDateTime valEnd = null;
		Map.Entry<ZonedDateTime,T> item = null;
		Double dObj;
		double lastVal = 0;
		long duration = 0;

		Iterator<Map.Entry<ZonedDateTime,T>> i = data.entrySet().iterator();

		if(!i.hasNext()) {
			// no data, we can't do the aggregation
			new AggregatedValue(0, DataType.REAL, AggregationFunction.TEMPORAL_AVG);
		}

		// this should be the item that is older that the "from" timestamp
		item = i.next();
		// we have to check it
		dObj = getDoubleVal(item.getValue(), acceptBool, acceptInt, acceptReal);
		if(dObj != null) {
			lastVal = dObj.doubleValue();
		if(getDuration(from, item.getKey()) < 0) {
			// we have the older record - we can use it in the aggregation
			// we want to count only the time in the specified period
			valStart = from;
		} else {
			// this value lays in the interval - we omit what was before, because we don't have any data
			valStart = item.getKey();
		}}

		while (i.hasNext()) {
			item = i.next();
			valEnd = item.getKey();
			duration = getDuration(valStart, valEnd);
			sum += lastVal * duration;
			count += duration;

			dObj = getDoubleVal(item.getValue(), acceptBool, acceptInt, acceptReal);
			if(dObj != null) {
				lastVal = dObj.doubleValue();
				valStart = valEnd;
			}
		}

		// dealing with the interval from last record until end of the period
		duration = getDuration(valStart, to);
		sum += lastVal * duration;
		count += duration;


		if(count == 0) {
			return AggregatedValue.getAggregationError(AggregationFunction.TEMPORAL_AVG, "No data of supported data types provided.");		
		} else {
			return new AggregatedValue(sum / count, DataType.REAL, AggregationFunction.TEMPORAL_AVG);
		}
	}
	
	private static long getDuration(ZonedDateTime from, ZonedDateTime to) {
		return (to.getLong(ChronoField.INSTANT_SECONDS) * 1000 + to.getLong(ChronoField.MILLI_OF_SECOND))
							- (from.getLong(ChronoField.INSTANT_SECONDS) * 1000 + from.getLong(ChronoField.MILLI_OF_SECOND));
	}
	
	private static AggregatedValue weightedAvg(Map<Address, ? extends Value> data) {
		return weightedAvg(data, false, true, true);
	}
	
	private static AggregatedValue weightedRatio(Map<Address, ? extends Value> data) {
		AggregatedValue v =  weightedAvg(data, true, false, false);
		v.setAggregation(AggregationFunction.WEIGHTED_RATIO);
		return v;
	}
	
	private static AggregatedValue weightedAvg(Map<Address, ? extends Value> data,
			boolean acceptBool, boolean acceptInt, boolean acceptReal) {
		// it could be possible to compute this using linear algebra (matrix operations)
		// however, we do not expect such large number of weights and data points to make significant difference
		double sum = 0;
		double count = 0;
		for(Map.Entry<Address,? extends Value> e : data.entrySet()) {
			Address a = e.getKey();
			Value v = e.getValue();

			Double d = getDoubleVal(v, acceptBool, acceptInt, acceptReal);
			if(d != null) {
				double weight = getWeight(a);
				sum += d.doubleValue() * weight;
				count += weight;
			}
		}

		if(count == 0) {
			return AggregatedValue.getAggregationError(AggregationFunction.WEIGHTED_AVG, "No data of supported data types provided.");		
		} else {
			return new AggregatedValue(sum / count, DataType.REAL, AggregationFunction.WEIGHTED_AVG);
		}
	}
	
	private static double getWeight(Address a) {
		double weight = 1;
		if(a.getWeights() != null) {
			for(int i = 0; i < a.getWeights().length; i++) {
				weight *= a.getWeights()[i];
			}
		}
		return weight;
	}

	private static AggregatedValue temporalRatio(NavigableMap<ZonedDateTime, ? extends Value> data, ZonedDateTime from,
			ZonedDateTime to) {
		AggregatedValue v = temporalAvg(data, from, to, true, false, false);
		v.setAggregation(AggregationFunction.TEMPORAL_RATIO);
		return v;
	}
	

	private static AggregatedValue avg(Map<?, ? extends Value> data) {
		double sum = 0;
		int count = 0;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			double val = 0;
			if(d == DataType.REAL) {	
				val = ((Double)v.getValue()).doubleValue();
			} else if(d == DataType.INTEGER) {
				val = ((Long)v.getValue()).intValue();
			} else {
				continue;
			}
			sum += val;
			count += 1;
			
		}
		if(count == 0) {
			return AggregatedValue.getAggregationError(AggregationFunction.AVG, "No data of supported data types provided.");		
		} else {
			return new AggregatedValue(sum / count, DataType.REAL, AggregationFunction.AVG);
		}
	}

	private static AggregatedValue median(Map<?, ? extends Value> data) {
		List<Value> filtered = new ArrayList<Value>(data.size());
		for(Value v : data.values()) {
			if(v.getDataType() == DataType.INTEGER || v.getDataType() == DataType.REAL) {
				filtered.add(v);
			}
		}
		
		if(filtered.size() == 0) {
			return AggregatedValue.getAggregationError(AggregationFunction.MEDIAN, "No data of supported data types provided.");	
		}
		
		Collections.sort(filtered, new Comparator<Value>() {
			@Override
			public int compare(Value o1, Value o2) {
				Double d1 = getDoubleVal(o1, false, true, true);
				Double d2 = getDoubleVal(o2, false, true, true);
				// the values should never be null, so this should work as we copare only over the filtered list
				return Double.compare(d1, d2);
			}});
		Value median = filtered.get(filtered.size() / 2);
		return new AggregatedValue(median.getValue(), median.getDataType(), AggregationFunction.MEDIAN);
	}

	private static AggregatedValue max(Map<?, ? extends Value> data) {
		double max = Double.NEGATIVE_INFINITY;
		boolean hasFloat = false;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			double val = Double.NEGATIVE_INFINITY;
			if(d == DataType.REAL) {
				hasFloat = true;
				val = ((Double)v.getValue()).doubleValue();
			}
			if(d == DataType.INTEGER) {
				val = ((Long)v.getValue()).intValue();
			}
			if(val > max) {
				max = val;
			}
		}
		if(max == Double.NEGATIVE_INFINITY) {
			return AggregatedValue.getAggregationError(AggregationFunction.MAX, "No data of supported data types provided.");	
		} else if (hasFloat) {
			return new AggregatedValue(max, DataType.REAL, AggregationFunction.MAX);	
		} else {
			return new AggregatedValue((long)max, DataType.INTEGER, AggregationFunction.MAX);
		}
	}
	
	private static AggregatedValue min(Map<?, ? extends Value> data) {
		double min = Double.POSITIVE_INFINITY;
		boolean hasFloat = false;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			double val = Double.POSITIVE_INFINITY;
			if(d == DataType.REAL) {
				hasFloat = true;
				val = ((Double)v.getValue()).doubleValue();
			}
			if(d == DataType.INTEGER) {
				val = ((Long)v.getValue()).intValue();
			}
			
			if(val < min) {
				min = val;
			}
		}
		if(min == Double.POSITIVE_INFINITY) {
			return AggregatedValue.getAggregationError(AggregationFunction.MIN, "No data of supported data types provided.");	
		} else if (hasFloat) {
			return new AggregatedValue(min, DataType.REAL, AggregationFunction.MIN);	
		} else {
			return new AggregatedValue((long)min, DataType.INTEGER, AggregationFunction.MIN);
		}
	}
	
	private static AggregatedValue sum(Map<?, ? extends Value> data) {
		
		double sum = 0;
		boolean hasFloat = false;
		boolean hasInt = false;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			if(d == DataType.REAL) {
				hasFloat = true;
				sum += ((Double)v.getValue()).doubleValue();
			}
			if(d == DataType.INTEGER) {
				hasInt = true;
				sum += ((Long)v.getValue()).intValue();
			}
			/*
			if(d == DataType.BOOL) {
				if(((Boolean)v.getValue()).booleanValue()) {
					sum += 1;
				}
			}
			*/
		}
		
		if(hasFloat) {
			return new AggregatedValue(sum, AggregationFunction.SUM);
		} else if(hasInt) {
			return new AggregatedValue((long)sum, AggregationFunction.SUM);
		} else {
			return AggregatedValue.getAggregationError(AggregationFunction.SUM, "No data of supported data types provided.");	
		}
	}
	
	private static AggregatedValue and(Map<?, ? extends Value> data) {
		
		boolean r = true;
		boolean has = false;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			
			if(d == DataType.BOOL) {
				has = true;
				r = r && (((Boolean)v.getValue()).booleanValue()); 
			}
			
		}
		
		if(!has) {
			return AggregatedValue.getAggregationError(AggregationFunction.AND, "No data of supported data types provided.");		
		} else {
			return new AggregatedValue(r, AggregationFunction.AND);
		}
	}
	
	private static AggregatedValue or(Map<?, ? extends Value> data) {
		
		boolean r = false;
		boolean has = false;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			
			if(d == DataType.BOOL) {
				has = true;
				r = r || (((Boolean)v.getValue()).booleanValue()); 
			}
			
		}
		if(!has) {
			return AggregatedValue.getAggregationError(AggregationFunction.OR, "No data of supported data types provided.");		
		} else {
			return new AggregatedValue(r, AggregationFunction.OR);
		}
	}
	
	private static AggregatedValue ratio(Map<?, ? extends Value> data) {
		int count = 0;
		int t = 0;
		for(Value v : data.values()) {
			DataType d = v.getDataType();
			
			if(d == DataType.BOOL) {
				count += 1;
				if(((Boolean)v.getValue()).booleanValue()) {
					t += 1;
				}; 
			}
			
		}
		if(count == 0) {
			return AggregatedValue.getAggregationError(AggregationFunction.RATIO, "No data of supported data types provided.");		
		} else {
			return new AggregatedValue(((double)t) / count, DataType.REAL, AggregationFunction.RATIO);
		}
	}
}
